Add functional QWord ribbon tabs

This commit is contained in:
Курнат Андрей
2026-06-02 08:25:50 +03:00
parent 24fbae8a01
commit 06c6a99e6d
3 changed files with 216 additions and 8 deletions
+86 -2
View File
@@ -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;
+80 -6
View File
@@ -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>
+50
View File
@@ -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;