Add functional QWord ribbon tabs
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import type { WordDocument } from "../../types";
|
import type { WordDocument } from "../../types";
|
||||||
import { QWord, qwordCurrentPageFromViewport } from "./QWord";
|
import { QWord, qwordCurrentPageFromViewport } from "./QWord";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
describe("qwordCurrentPageFromViewport", () => {
|
describe("qwordCurrentPageFromViewport", () => {
|
||||||
it("вычисляет текущую страницу по положению листа в области просмотра", () => {
|
it("вычисляет текущую страницу по положению листа в области просмотра", () => {
|
||||||
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: 227, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(1);
|
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: 227, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(1);
|
||||||
@@ -18,6 +22,86 @@ describe("qwordCurrentPageFromViewport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("QWord", () => {
|
describe("QWord", () => {
|
||||||
|
it("переключает вкладки ленты и показывает команды выбранной вкладки", () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
|
||||||
|
const document: WordDocument = {
|
||||||
|
id: "word-ribbon-tabs-test",
|
||||||
|
title: "Лента.docx",
|
||||||
|
updatedAt: "2026-06-02T00:00:00.000Z",
|
||||||
|
html: "<p>Текст</p>"
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<QWord document={document} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("tab", { name: "Главная" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByRole("region", { name: "Шрифт" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Вставка" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("tab", { name: "Вставка" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByRole("region", { name: "Вставка" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("region", { name: "Шрифт" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Файл" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("tab", { name: "Файл" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByRole("region", { name: "Файл" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("region", { name: "Вставка" })).not.toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("переключает режим просмотра документа на вкладке Вид", () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
|
||||||
|
const document: WordDocument = {
|
||||||
|
id: "word-view-mode-test",
|
||||||
|
title: "Вид.docx",
|
||||||
|
updatedAt: "2026-06-02T00:00:00.000Z",
|
||||||
|
html: "<p>Текст</p>"
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<QWord document={document} onChange={vi.fn()} />);
|
||||||
|
const stage = screen.getByLabelText("Редактор QWord");
|
||||||
|
|
||||||
|
expect(stage).toHaveClass("qword-view-print");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Вид" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Веб-документ" }));
|
||||||
|
|
||||||
|
expect(stage).toHaveClass("qword-view-web");
|
||||||
|
expect(screen.getByRole("button", { name: "Веб-документ" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("обновляет текущую страницу в статусе при прокрутке документа", async () => {
|
it("обновляет текущую страницу в статусе при прокрутке документа", async () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|||||||
@@ -143,7 +143,27 @@ const styleGalleryOptions = [
|
|||||||
{ value: "h6", label: "Заголовок 6" },
|
{ value: "h6", label: "Заголовок 6" },
|
||||||
{ value: "blockquote", label: "Цитата" }
|
{ value: "blockquote", label: "Цитата" }
|
||||||
];
|
];
|
||||||
const wordRibbonTabs = ["Файл", "Главная", "Вставка", "Конструктор", "Макет", "Ссылки", "Рассылки", "Рецензирование", "Вид", "Справка"];
|
type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help";
|
||||||
|
type WordViewMode = "print" | "read" | "web";
|
||||||
|
|
||||||
|
const wordRibbonTabs: Array<{ id: WordRibbonTabId; label: string }> = [
|
||||||
|
{ id: "file", label: "Файл" },
|
||||||
|
{ id: "home", label: "Главная" },
|
||||||
|
{ id: "insert", label: "Вставка" },
|
||||||
|
{ id: "design", label: "Конструктор" },
|
||||||
|
{ id: "layout", label: "Макет" },
|
||||||
|
{ id: "references", label: "Ссылки" },
|
||||||
|
{ id: "mailings", label: "Рассылки" },
|
||||||
|
{ id: "review", label: "Рецензирование" },
|
||||||
|
{ id: "view", label: "Вид" },
|
||||||
|
{ id: "help", label: "Справка" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const wordViewModes: Array<{ id: WordViewMode; label: string }> = [
|
||||||
|
{ id: "print", label: "Разметка" },
|
||||||
|
{ id: "read", label: "Чтение" },
|
||||||
|
{ id: "web", label: "Веб-документ" }
|
||||||
|
];
|
||||||
|
|
||||||
function findInPage(query: string) {
|
function findInPage(query: string) {
|
||||||
return (window as PageSearchWindow).find?.(query) ?? false;
|
return (window as PageSearchWindow).find?.(query) ?? false;
|
||||||
@@ -169,9 +189,13 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
||||||
|
const [viewMode, setViewMode] = useState<WordViewMode>("print");
|
||||||
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
|
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
|
||||||
const wordCount = useMemo(() => countWords(document.html), [document.html]);
|
const wordCount = useMemo(() => countWords(document.html), [document.html]);
|
||||||
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
|
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
|
||||||
|
const activeRibbonTabLabel = wordRibbonTabs.find((tab) => tab.id === activeRibbonTab)?.label ?? "Главная";
|
||||||
|
const stageClassName = ["document-stage", `qword-view-${viewMode}`].join(" ");
|
||||||
|
|
||||||
function currentPageFromStage(pageCount: number, pageHeight: number) {
|
function currentPageFromStage(pageCount: number, pageHeight: number) {
|
||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
@@ -403,7 +427,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-layout qword-layout">
|
<div className="editor-layout qword-layout">
|
||||||
<section ref={stageRef} className="document-stage" style={stageStyle} onScroll={refreshPageView} aria-label="Редактор QWord">
|
<section ref={stageRef} className={stageClassName} style={stageStyle} onScroll={refreshPageView} aria-label="Редактор QWord">
|
||||||
<div className="module-toolbar word-module-toolbar">
|
<div className="module-toolbar word-module-toolbar">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -422,13 +446,29 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
aria-label="Вставить изображение"
|
aria-label="Вставить изображение"
|
||||||
/>
|
/>
|
||||||
<div className="word-ribbon-tabs" role="tablist" aria-label="Вкладки QWord">
|
<div className="word-ribbon-tabs" role="tablist" aria-label="Вкладки QWord">
|
||||||
{wordRibbonTabs.map((tab, index) => (
|
{wordRibbonTabs.map((tab) => (
|
||||||
<button key={tab} className={index === 1 ? "is-active" : ""} type="button" role="tab" aria-selected={index === 1}>
|
<button
|
||||||
{tab}
|
key={tab.id}
|
||||||
|
id={`qword-ribbon-tab-${tab.id}`}
|
||||||
|
className={activeRibbonTab === tab.id ? "is-active" : ""}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="qword-ribbon-panel"
|
||||||
|
aria-selected={activeRibbonTab === tab.id}
|
||||||
|
onClick={() => setActiveRibbonTab(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="word-ribbon" aria-label="Лента команд QWord">
|
<div
|
||||||
|
id="qword-ribbon-panel"
|
||||||
|
className={`word-ribbon word-ribbon-${activeRibbonTab}`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-label={`Лента команд QWord: ${activeRibbonTabLabel}`}
|
||||||
|
aria-labelledby={`qword-ribbon-tab-${activeRibbonTab}`}
|
||||||
|
>
|
||||||
|
{activeRibbonTab === "file" ? (
|
||||||
<section className="word-ribbon-group word-file-group" aria-label="Файл">
|
<section className="word-ribbon-group word-file-group" aria-label="Файл">
|
||||||
<div className="word-file-actions">
|
<div className="word-file-actions">
|
||||||
<button className="word-icon-command" type="button" onClick={() => void chooseDocx()} title="Открыть" aria-label="Открыть">
|
<button className="word-icon-command" type="button" onClick={() => void chooseDocx()} title="Открыть" aria-label="Открыть">
|
||||||
@@ -452,7 +492,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</div>
|
</div>
|
||||||
<span className="word-ribbon-title">Файл</span>
|
<span className="word-ribbon-title">Файл</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "home" ? (
|
||||||
<section className="word-ribbon-group word-clipboard-group" aria-label="Буфер обмена">
|
<section className="word-ribbon-group word-clipboard-group" aria-label="Буфер обмена">
|
||||||
<button className="word-large-command" type="button" onClick={() => runCommand("paste")} title="Вставить">
|
<button className="word-large-command" type="button" onClick={() => runCommand("paste")} title="Вставить">
|
||||||
<ClipboardPaste size={22} />
|
<ClipboardPaste size={22} />
|
||||||
@@ -474,7 +516,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</div>
|
</div>
|
||||||
<span className="word-ribbon-title">Буфер обмена</span>
|
<span className="word-ribbon-title">Буфер обмена</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "home" || activeRibbonTab === "design" ? (
|
||||||
<section className="word-ribbon-group word-font-group" aria-label="Шрифт">
|
<section className="word-ribbon-group word-font-group" aria-label="Шрифт">
|
||||||
<div className="word-select-row">
|
<div className="word-select-row">
|
||||||
<select className="word-font-select" aria-label="Шрифт" defaultValue="Calibri" onChange={(event) => applyFontFamily(event.target.value)}>
|
<select className="word-font-select" aria-label="Шрифт" defaultValue="Calibri" onChange={(event) => applyFontFamily(event.target.value)}>
|
||||||
@@ -544,7 +588,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</div>
|
</div>
|
||||||
<span className="word-ribbon-title">Шрифт</span>
|
<span className="word-ribbon-title">Шрифт</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "home" || activeRibbonTab === "layout" ? (
|
||||||
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
|
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
|
||||||
<div className="segmented-tools" aria-label="Списки">
|
<div className="segmented-tools" aria-label="Списки">
|
||||||
<button type="button" onClick={() => runCommand("insertUnorderedList")} title="Маркированный список">
|
<button type="button" onClick={() => runCommand("insertUnorderedList")} title="Маркированный список">
|
||||||
@@ -574,7 +620,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</button>
|
</button>
|
||||||
<span className="word-ribbon-title">Абзац</span>
|
<span className="word-ribbon-title">Абзац</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "home" || activeRibbonTab === "design" ? (
|
||||||
<section className="word-ribbon-group word-styles-group" aria-label="Стили">
|
<section className="word-ribbon-group word-styles-group" aria-label="Стили">
|
||||||
<div className="word-style-gallery">
|
<div className="word-style-gallery">
|
||||||
{styleGalleryOptions.map((style) => (
|
{styleGalleryOptions.map((style) => (
|
||||||
@@ -586,7 +634,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</div>
|
</div>
|
||||||
<span className="word-ribbon-title">Стили</span>
|
<span className="word-ribbon-title">Стили</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "insert" || activeRibbonTab === "references" ? (
|
||||||
<section className="word-ribbon-group word-insert-group" aria-label="Вставка">
|
<section className="word-ribbon-group word-insert-group" aria-label="Вставка">
|
||||||
<button className="compact-command" type="button" onClick={insertLink}>
|
<button className="compact-command" type="button" onClick={insertLink}>
|
||||||
<Link size={16} />
|
<Link size={16} />
|
||||||
@@ -598,7 +648,30 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</button>
|
</button>
|
||||||
<span className="word-ribbon-title">Вставка</span>
|
<span className="word-ribbon-title">Вставка</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "view" ? (
|
||||||
|
<section className="word-ribbon-group word-view-group" aria-label="Режимы просмотра">
|
||||||
|
<div className="word-view-mode-row">
|
||||||
|
{wordViewModes.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode.id}
|
||||||
|
className={`compact-command${viewMode === mode.id ? " is-active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={mode.label}
|
||||||
|
aria-pressed={viewMode === mode.id}
|
||||||
|
onClick={() => setViewMode(mode.id)}
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
{mode.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="word-ribbon-title">Вид</span>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeRibbonTab === "home" || activeRibbonTab === "review" ? (
|
||||||
<section className="word-ribbon-group word-edit-group" aria-label="Редактирование">
|
<section className="word-ribbon-group word-edit-group" aria-label="Редактирование">
|
||||||
<button className="compact-command" type="button" onClick={findText}>
|
<button className="compact-command" type="button" onClick={findText}>
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
@@ -610,6 +683,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</button>
|
</button>
|
||||||
<span className="word-ribbon-title">Редактирование</span>
|
<span className="word-ribbon-title">Редактирование</span>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -679,6 +679,26 @@ button {
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.word-view-group {
|
||||||
|
flex: 0 0 320px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-view-mode-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-view-mode-row .compact-command {
|
||||||
|
flex: 1 1 0;
|
||||||
|
height: 58px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.word-edit-group .toolbar-status {
|
.word-edit-group .toolbar-status {
|
||||||
min-height: 26px;
|
min-height: 26px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -1006,6 +1026,36 @@ button {
|
|||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qword-view-read .page-ruler,
|
||||||
|
.qword-view-read .vertical-page-ruler,
|
||||||
|
.qword-view-web .page-ruler,
|
||||||
|
.qword-view-web .vertical-page-ruler {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qword-view-read .paper-frame {
|
||||||
|
width: min(920px, calc(100% - 96px));
|
||||||
|
margin-top: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qword-view-read .paper {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 56px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qword-view-web .paper-frame {
|
||||||
|
width: min(1040px, calc(100% - 64px));
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qword-view-web .paper {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 36px 48px;
|
||||||
|
border-right: 0;
|
||||||
|
border-left: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.page-break-guides {
|
.page-break-guides {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user