diff --git a/src/App.test.tsx b/src/App.test.tsx index fd4d244..a11e9ce 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { initialState } from "./data/sampleData"; import type { QOfficeState } from "./types"; @@ -64,4 +64,45 @@ describe("qofficeWindowTitle", () => { window.requestAnimationFrame = originalRequestAnimationFrame; } }); + + it("syncs QWord statusbar view buttons with the active document view mode", () => { + 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; + window.history.pushState({}, "", "/qword"); + + try { + render(); + const stage = screen.getByLabelText("Редактор QWord"); + const printButton = screen.getByRole("button", { name: "Разметка страницы" }); + const webButton = screen.getByRole("button", { name: "Веб-документ" }); + + expect(stage).toHaveClass("qword-view-print"); + expect(printButton).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "Черновик" })).not.toBeInTheDocument(); + + fireEvent.click(webButton); + + expect(stage).toHaveClass("qword-view-web"); + expect(webButton).toHaveAttribute("aria-pressed", "true"); + expect(printButton).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(screen.getByRole("tab", { name: "Вид" })); + fireEvent.click(screen.getByRole("button", { name: "Чтение" })); + + expect(stage).toHaveClass("qword-view-read"); + expect(screen.getByRole("button", { name: "Режим чтения" })).toHaveAttribute("aria-pressed", "true"); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + window.requestAnimationFrame = originalRequestAnimationFrame; + } + }); }); diff --git a/src/App.tsx b/src/App.tsx index 1fccdbb..856e45a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { BookOpen, Columns2, FileText, Minus, Plus, Rows3 } from "lucide-react"; +import { BookOpen, Columns2, FileText, Minus, Plus } from "lucide-react"; import { AppShell } from "./components/AppShell"; import { QOutlook } from "./features/qoutlook/QOutlook"; import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint"; -import { QWord, type QWordStatus } from "./features/qword/QWord"; +import { QWord, type QWordStatus, type WordViewMode } from "./features/qword/QWord"; import { QExcell } from "./features/qexcell/QExcell"; import { initialState } from "./data/sampleData"; import { usePersistentState } from "./hooks/usePersistentState"; @@ -12,6 +12,11 @@ import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } fro const STORAGE_KEY = "qoffice-state-v1"; const defaultQWordStatus: QWordStatus = { currentPage: 1, pageCount: 1, wordCount: 0 }; +const wordStatusbarViewModes = [ + { id: "print", label: "Разметка страницы", Icon: FileText }, + { id: "read", label: "Режим чтения", Icon: BookOpen }, + { id: "web", label: "Веб-документ", Icon: Columns2 } +] as const; function clampedWordZoom(value: number) { return Math.min(200, Math.max(50, value)); @@ -122,6 +127,7 @@ export function App() { const [state, setState] = usePersistentState(STORAGE_KEY, initialState, migrateQOfficeState); const [wordStatus, setWordStatus] = useState(defaultQWordStatus); const [wordZoom, setWordZoom] = useState(100); + const [wordViewMode, setWordViewMode] = useState("print"); const activeApp = activeAppFromLocation(window.location); const windowTitle = qofficeWindowTitle(state, activeApp); @@ -164,18 +170,23 @@ export function App() { trailing: ( - - - - - - - - - - - - + {wordStatusbarViewModes.map((mode) => { + const Icon = mode.Icon; + const isActive = wordViewMode === mode.id; + return ( + setWordViewMode(mode.id)} + > + + + ); + })} adjustWordZoom(-10)}> @@ -212,6 +223,7 @@ export function App() { wordStatus.currentPage, wordStatus.pageCount, wordStatus.wordCount, + wordViewMode, wordZoom ]); @@ -244,7 +256,14 @@ export function App() { const activeSurface = activeApp === "word" ? ( - + ) : activeApp === "sheets" ? ( ) : activeApp === "slides" ? ( diff --git a/src/features/qword/QWord.tsx b/src/features/qword/QWord.tsx index b95ce7e..47a3310 100644 --- a/src/features/qword/QWord.tsx +++ b/src/features/qword/QWord.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, MouseEvent } from "react"; import { AlignCenter, @@ -43,8 +43,10 @@ import { useQOfficeCommand } from "../../hooks/useQOfficeCommand"; interface QWordProps { document: WordDocument; zoom?: number; + viewMode?: WordViewMode; onChange: (document: WordDocument) => void; onStatusChange?: (status: QWordStatus) => void; + onViewModeChange?: (viewMode: WordViewMode) => void; } interface TableCellLocation { @@ -153,7 +155,7 @@ const styleGalleryOptions = [ { value: "blockquote", label: "Цитата" } ]; type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help"; -type WordViewMode = "print" | "read" | "web"; +export type WordViewMode = "print" | "read" | "web"; const wordRibbonTabs: Array<{ id: WordRibbonTabId; label: string }> = [ { id: "file", label: "Файл" }, @@ -256,7 +258,7 @@ export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageHeight) + 1)); } -export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordProps) { +export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange, onViewModeChange }: QWordProps) { const stageRef = useRef(null); const editorRef = useRef(null); const activeTableCellRef = useRef(null); @@ -265,7 +267,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP const fileInputRef = useRef(null); const imageInputRef = useRef(null); const [activeRibbonTab, setActiveRibbonTab] = useState("home"); - const [viewMode, setViewMode] = useState("print"); + const [internalViewMode, setInternalViewMode] = useState("print"); const [tableRowCount, setTableRowCount] = useState(3); const [tableColumnCount, setTableColumnCount] = useState(3); const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight }); @@ -278,7 +280,16 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP paddingBottom: pageView.count > 1 ? `${(pageView.count - 1) * defaultPageGap}px` : undefined } as CSSProperties; const activeRibbonTabLabel = wordRibbonTabs.find((tab) => tab.id === activeRibbonTab)?.label ?? "Главная"; - const stageClassName = ["document-stage", `qword-view-${viewMode}`].join(" "); + const activeViewMode = viewMode ?? internalViewMode; + const stageClassName = ["document-stage", `qword-view-${activeViewMode}`].join(" "); + + const changeViewMode = useCallback( + (nextViewMode: WordViewMode) => { + setInternalViewMode(nextViewMode); + onViewModeChange?.(nextViewMode); + }, + [onViewModeChange] + ); useLayoutEffect(() => { const editor = editorRef.current; @@ -970,11 +981,11 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP {wordViewModes.map((mode) => ( setViewMode(mode.id)} + aria-pressed={activeViewMode === mode.id} + onClick={() => changeViewMode(mode.id)} > {mode.label}