From 07f7335c7413b487ff9a5e734428bec45ed166dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Tue, 2 Jun 2026 07:08:43 +0300 Subject: [PATCH] Add QWord Word-style status bar --- src/App.tsx | 76 ++++++++++++++++++++++++++++++-- src/components/AppShell.test.tsx | 24 ++++++++++ src/components/AppShell.tsx | 19 ++++++-- src/features/qword/QWord.tsx | 22 ++++++--- src/styles.css | 73 ++++++++++++++++++++++++++++-- 5 files changed, 197 insertions(+), 17 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b698635..382b2b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,9 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { BookOpen, Columns2, FileText, Minus, Plus, Rows3 } from "lucide-react"; import { AppShell } from "./components/AppShell"; import { QOutlook } from "./features/qoutlook/QOutlook"; import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint"; -import { QWord } from "./features/qword/QWord"; +import { QWord, type QWordStatus } from "./features/qword/QWord"; import { QExcell } from "./features/qexcell/QExcell"; import { initialState } from "./data/sampleData"; import { usePersistentState } from "./hooks/usePersistentState"; @@ -10,6 +11,11 @@ import { activeAppFromLocation } from "./appRouting"; import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types"; const STORAGE_KEY = "qoffice-state-v1"; +const defaultQWordStatus: QWordStatus = { pageCount: 1, wordCount: 0 }; + +function clampedWordZoom(value: number) { + return Math.min(200, Math.max(50, value)); +} function migrateQOfficeState(state: QOfficeState): QOfficeState { const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments; @@ -92,8 +98,20 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState { export function App() { const [state, setState] = usePersistentState(STORAGE_KEY, initialState, migrateQOfficeState); + const [wordStatus, setWordStatus] = useState(defaultQWordStatus); + const [wordZoom, setWordZoom] = useState(100); const activeApp = activeAppFromLocation(window.location); + const updateWordStatus = useCallback((nextStatus: QWordStatus) => { + setWordStatus((current) => + current.pageCount === nextStatus.pageCount && current.wordCount === nextStatus.wordCount ? current : nextStatus + ); + }, []); + + const adjustWordZoom = useCallback((delta: number) => { + setWordZoom((current) => clampedWordZoom(current + delta)); + }, []); + const status = useMemo(() => { const updatedAt = activeApp === "word" @@ -104,11 +122,61 @@ export function App() { ? state.deck.updatedAt : undefined; + if (activeApp === "word") { + return { + savedLabel: "Сохранено локально", + updatedAt, + className: "word-statusbar", + content: ( + <> + Страница 1 из {wordStatus.pageCount} + Число слов: {wordStatus.wordCount} + русский + + ), + trailing: ( +
+
+ + + + +
+ + setWordZoom(clampedWordZoom(Number(event.target.value)))} + aria-label="Масштаб QWord" + /> + + {wordZoom}% +
+ ) + }; + } + return { savedLabel: "Сохранено локально", updatedAt }; - }, [activeApp, state.deck.updatedAt, state.word.updatedAt, state.workbook.updatedAt]); + }, [activeApp, adjustWordZoom, state.deck.updatedAt, state.word.updatedAt, state.workbook.updatedAt, wordStatus.pageCount, wordStatus.wordCount, wordZoom]); function updateWord(word: WordDocument) { setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } })); @@ -139,7 +207,7 @@ export function App() { const activeSurface = activeApp === "word" ? ( - + ) : activeApp === "sheets" ? ( ) : activeApp === "slides" ? ( diff --git a/src/components/AppShell.test.tsx b/src/components/AppShell.test.tsx index 6d75c3d..f402f60 100644 --- a/src/components/AppShell.test.tsx +++ b/src/components/AppShell.test.tsx @@ -13,4 +13,28 @@ describe("AppShell", () => { expect(screen.getByText("Рабочая область QWord")).toBeInTheDocument(); expect(view.container.querySelector(".ribbon")).not.toBeInTheDocument(); }); + + it("показывает пользовательскую строку состояния активного приложения", () => { + render( + + Страница 1 из 2 + Число слов: 42 + + ), + trailing: 125% + }} + > +
Рабочая область QWord
+
+ ); + + expect(screen.getByText("Страница 1 из 2")).toBeInTheDocument(); + expect(screen.getByText("Число слов: 42")).toBeInTheDocument(); + expect(screen.getByText("125%")).toBeInTheDocument(); + }); }); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index f1286e5..5f4c3e4 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -14,6 +14,9 @@ interface AppShellProps { status: { savedLabel: string; updatedAt?: string; + content?: ReactNode; + trailing?: ReactNode; + className?: string; }; children: ReactNode; } @@ -26,6 +29,8 @@ const appTitles: Record = { }; export function AppShell({ activeApp, status, children }: AppShellProps) { + const statusClassName = ["statusbar", status.className].filter(Boolean).join(" "); + return (
@@ -59,10 +64,16 @@ export function AppShell({ activeApp, status, children }: AppShellProps) {
{children}
-
- Готово - {status.updatedAt ? `Обновлено: ${new Date(status.updatedAt).toLocaleString("ru-RU")}` : "Локальная почта"} - Масштаб 100% +
+
+ {status.content ?? ( + <> + Готово + {status.updatedAt ? `Обновлено: ${new Date(status.updatedAt).toLocaleString("ru-RU")}` : "Локальная почта"} + + )} +
+
{status.trailing ?? Масштаб 100%}
diff --git a/src/features/qword/QWord.tsx b/src/features/qword/QWord.tsx index f795d8a..48da602 100644 --- a/src/features/qword/QWord.tsx +++ b/src/features/qword/QWord.tsx @@ -1,4 +1,5 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties } from "react"; import { AlignCenter, AlignJustify, @@ -38,7 +39,14 @@ import { useQOfficeCommand } from "../../hooks/useQOfficeCommand"; interface QWordProps { document: WordDocument; + zoom?: number; onChange: (document: WordDocument) => void; + onStatusChange?: (status: QWordStatus) => void; +} + +export interface QWordStatus { + pageCount: number; + wordCount: number; } type PageSearchWindow = Window & @@ -147,12 +155,13 @@ function pageHeightFromStyles(element: HTMLElement) { return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight; } -export function QWord({ document, onChange }: QWordProps) { +export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordProps) { const editorRef = useRef(null); const fileInputRef = useRef(null); const imageInputRef = useRef(null); const [pageView, setPageView] = useState({ count: 1, height: defaultPageViewHeight }); const wordCount = useMemo(() => countWords(document.html), [document.html]); + const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties; function refreshPageView() { window.requestAnimationFrame(() => { @@ -177,7 +186,11 @@ export function QWord({ document, onChange }: QWordProps) { const observer = new ResizeObserver(refreshPageView); observer.observe(editor); return () => observer.disconnect(); - }, [document.html]); + }, [document.html, zoom]); + + useLayoutEffect(() => { + onStatusChange?.({ pageCount: pageView.count, wordCount }); + }, [onStatusChange, pageView.count, wordCount]); function runCommand(command: string, value?: string) { editorRef.current?.focus(); @@ -358,7 +371,7 @@ export function QWord({ document, onChange }: QWordProps) { return (
-
+
Заменить - - Страниц: {pageView.count} · Слов: {wordCount} - Редактирование
diff --git a/src/styles.css b/src/styles.css index eee69bf..66188a0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -281,18 +281,83 @@ button { .statusbar { display: flex; align-items: center; - gap: 28px; - padding: 0 18px; + gap: 0; + justify-content: space-between; + overflow: hidden; + padding: 0 10px 0 18px; border-top: 1px solid var(--line); background: var(--surface); color: var(--muted); font-size: 12px; } -.statusbar span:last-child { +.statusbar-main, +.statusbar-trailing { + display: flex; + align-items: center; + min-width: 0; +} + +.statusbar-main { + flex: 1 1 auto; + gap: 28px; + overflow: hidden; +} + +.statusbar-trailing { + flex: 0 0 auto; + gap: 8px; margin-left: auto; } +.statusbar span { + white-space: nowrap; +} + +.word-statusbar { + color: #4f5967; +} + +.word-statusbar-tools, +.word-view-buttons { + display: inline-flex; + align-items: center; +} + +.word-statusbar-tools { + gap: 6px; +} + +.word-view-buttons { + gap: 2px; + padding-right: 8px; + border-right: 1px solid #d2d9e3; +} + +.word-statusbar-tools button { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: #4f5967; + cursor: pointer; +} + +.word-statusbar-tools button:hover, +.word-statusbar-tools button.is-active { + border-color: #c3d3e6; + background: #eef5fd; + color: #1f4f8a; +} + +.word-zoom-slider { + width: 120px; + accent-color: #6b7280; +} + .editor-layout { display: grid; grid-template-columns: minmax(0, 1fr); @@ -869,6 +934,7 @@ button { display: grid; grid-template-columns: repeat(9, 1fr); width: var(--qword-page-width, 794px); + zoom: var(--qword-zoom, 1); height: 26px; margin: 12px auto 0; color: #7a8797; @@ -905,6 +971,7 @@ button { .paper-frame { position: relative; width: var(--qword-page-width, 794px); + zoom: var(--qword-zoom, 1); margin: 0 auto 38px; }