diff --git a/src/features/qword/QWord.test.tsx b/src/features/qword/QWord.test.tsx
index be0f9fd..305bfa5 100644
--- a/src/features/qword/QWord.test.tsx
+++ b/src/features/qword/QWord.test.tsx
@@ -1,9 +1,13 @@
-import { fireEvent, render, waitFor } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { WordDocument } from "../../types";
import { QWord, qwordCurrentPageFromViewport } from "./QWord";
+afterEach(() => {
+ cleanup();
+});
+
describe("qwordCurrentPageFromViewport", () => {
it("вычисляет текущую страницу по положению листа в области просмотра", () => {
expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: 227, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(1);
@@ -18,6 +22,86 @@ describe("qwordCurrentPageFromViewport", () => {
});
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: "
Текст
"
+ };
+
+ try {
+ render();
+
+ 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: "Текст
"
+ };
+
+ try {
+ render();
+ 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 () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
diff --git a/src/features/qword/QWord.tsx b/src/features/qword/QWord.tsx
index 55e0be2..507a225 100644
--- a/src/features/qword/QWord.tsx
+++ b/src/features/qword/QWord.tsx
@@ -143,7 +143,27 @@ const styleGalleryOptions = [
{ value: "h6", label: "Заголовок 6" },
{ 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) {
return (window as PageSearchWindow).find?.(query) ?? false;
@@ -169,9 +189,13 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
const editorRef = useRef(null);
const fileInputRef = useRef(null);
const imageInputRef = useRef(null);
+ const [activeRibbonTab, setActiveRibbonTab] = useState("home");
+ const [viewMode, setViewMode] = useState("print");
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
const wordCount = useMemo(() => countWords(document.html), [document.html]);
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) {
const stage = stageRef.current;
@@ -403,7 +427,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
return (
-
+
- {wordRibbonTabs.map((tab, index) => (
-
-
+
+ {activeRibbonTab === "file" ? (
void chooseDocx()} title="Открыть" aria-label="Открыть">
@@ -452,7 +492,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
Файл
+ ) : null}
+ {activeRibbonTab === "home" ? (
runCommand("paste")} title="Вставить">
@@ -474,7 +516,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
Буфер обмена
+ ) : null}
+ {activeRibbonTab === "home" || activeRibbonTab === "design" ? (
Шрифт
+ ) : null}
+ {activeRibbonTab === "home" || activeRibbonTab === "layout" ? (
runCommand("insertUnorderedList")} title="Маркированный список">
@@ -574,7 +620,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
Абзац
+ ) : null}
+ {activeRibbonTab === "home" || activeRibbonTab === "design" ? (
{styleGalleryOptions.map((style) => (
@@ -586,7 +634,9 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
Стили
+ ) : null}
+ {activeRibbonTab === "insert" || activeRibbonTab === "references" ? (
@@ -598,7 +648,30 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
Вставка
+ ) : null}
+ {activeRibbonTab === "view" ? (
+
+
+ {wordViewModes.map((mode) => (
+ setViewMode(mode.id)}
+ >
+
+ {mode.label}
+
+ ))}
+
+ Вид
+
+ ) : null}
+
+ {activeRibbonTab === "home" || activeRibbonTab === "review" ? (
@@ -610,6 +683,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
Редактирование
+ ) : null}
diff --git a/src/styles.css b/src/styles.css
index 0d088b5..be4e689 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -679,6 +679,26 @@ button {
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 {
min-height: 26px;
padding: 0;
@@ -1006,6 +1026,36 @@ button {
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 {
position: absolute;
inset: 0;