From 7ace47dd2a3398c574851eec270c716f64b7b710 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:16:15 +0300 Subject: [PATCH] Track QWord current page in status bar --- src/App.tsx | 18 +++++++--- src/features/qword/QWord.test.tsx | 59 +++++++++++++++++++++++++++++++ src/features/qword/QWord.tsx | 42 +++++++++++++++++++--- 3 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 src/features/qword/QWord.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 382b2b6..f011e0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ 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 }; +const defaultQWordStatus: QWordStatus = { currentPage: 1, pageCount: 1, wordCount: 0 }; function clampedWordZoom(value: number) { return Math.min(200, Math.max(50, value)); @@ -104,7 +104,7 @@ export function App() { const updateWordStatus = useCallback((nextStatus: QWordStatus) => { setWordStatus((current) => - current.pageCount === nextStatus.pageCount && current.wordCount === nextStatus.wordCount ? current : nextStatus + current.currentPage === nextStatus.currentPage && current.pageCount === nextStatus.pageCount && current.wordCount === nextStatus.wordCount ? current : nextStatus ); }, []); @@ -129,7 +129,7 @@ export function App() { className: "word-statusbar", content: ( <> - Страница 1 из {wordStatus.pageCount} + Страница {wordStatus.currentPage} из {wordStatus.pageCount} Число слов: {wordStatus.wordCount} русский @@ -176,7 +176,17 @@ export function App() { savedLabel: "Сохранено локально", updatedAt }; - }, [activeApp, adjustWordZoom, state.deck.updatedAt, state.word.updatedAt, state.workbook.updatedAt, wordStatus.pageCount, wordStatus.wordCount, wordZoom]); + }, [ + activeApp, + adjustWordZoom, + state.deck.updatedAt, + state.word.updatedAt, + state.workbook.updatedAt, + wordStatus.currentPage, + wordStatus.pageCount, + wordStatus.wordCount, + wordZoom + ]); function updateWord(word: WordDocument) { setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } })); diff --git a/src/features/qword/QWord.test.tsx b/src/features/qword/QWord.test.tsx new file mode 100644 index 0000000..be0f9fd --- /dev/null +++ b/src/features/qword/QWord.test.tsx @@ -0,0 +1,59 @@ +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { vi } from "vitest"; +import type { WordDocument } from "../../types"; +import { QWord, qwordCurrentPageFromViewport } from "./QWord"; + +describe("qwordCurrentPageFromViewport", () => { + it("вычисляет текущую страницу по положению листа в области просмотра", () => { + expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: 227, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(1); + expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1090, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(2); + expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -6000, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(3); + }); + + it("учитывает масштаб при расчете текущей страницы", () => { + expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1 })).toBe(2); + expect(qwordCurrentPageFromViewport({ stageTop: 34, paperTop: -1200, pageHeight: 1123, pageCount: 3, zoom: 1.2 })).toBe(1); + }); +}); + +describe("QWord", () => { + it("обновляет текущую страницу в статусе при прокрутке документа", async () => { + const originalResizeObserver = globalThis.ResizeObserver; + const originalRequestAnimationFrame = window.requestAnimationFrame; + const onStatusChange = vi.fn(); + + 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-scroll-test", + title: "Прокрутка.docx", + updatedAt: "2026-06-02T00:00:00.000Z", + html: "

Первая страница

" + }; + + try { + const view = render(); + const stage = view.getByLabelText("Редактор QWord"); + const paper = view.getByLabelText("Содержимое документа"); + + Object.defineProperty(paper, "scrollHeight", { configurable: true, value: 2400 }); + stage.getBoundingClientRect = () => ({ bottom: 884, height: 850, left: 0, right: 1008, top: 34, width: 1008, x: 0, y: 34, toJSON: () => ({}) }); + paper.getBoundingClientRect = () => ({ bottom: -77, height: 1123, left: 107, right: 901, top: -1200, width: 794, x: 107, y: -1200, toJSON: () => ({}) }); + + fireEvent.scroll(stage); + + await waitFor(() => expect(onStatusChange).toHaveBeenLastCalledWith({ currentPage: 2, pageCount: 3, wordCount: 2 })); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + window.requestAnimationFrame = originalRequestAnimationFrame; + } + }); +}); diff --git a/src/features/qword/QWord.tsx b/src/features/qword/QWord.tsx index 48da602..55e0be2 100644 --- a/src/features/qword/QWord.tsx +++ b/src/features/qword/QWord.tsx @@ -45,6 +45,7 @@ interface QWordProps { } export interface QWordStatus { + currentPage: number; pageCount: number; wordCount: number; } @@ -155,14 +156,42 @@ function pageHeightFromStyles(element: HTMLElement) { return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight; } +export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop: number; pageHeight: number; pageCount: number; zoom: number }) { + const pageCount = Math.max(1, input.pageCount); + const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1; + const visualPageHeight = Math.max(1, input.pageHeight * zoom); + const visiblePaperOffset = Math.max(0, input.stageTop - input.paperTop); + return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageHeight) + 1)); +} + export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordProps) { + const stageRef = useRef(null); const editorRef = useRef(null); const fileInputRef = useRef(null); const imageInputRef = useRef(null); - const [pageView, setPageView] = useState({ count: 1, height: defaultPageViewHeight }); + 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; + function currentPageFromStage(pageCount: number, pageHeight: number) { + const stage = stageRef.current; + const editor = editorRef.current; + if (!stage || !editor) { + return 1; + } + + const stageRect = stage.getBoundingClientRect(); + const paperRect = editor.getBoundingClientRect(); + const currentZoom = Number.parseFloat(window.getComputedStyle(stage).getPropertyValue("--qword-zoom")); + return qwordCurrentPageFromViewport({ + stageTop: stageRect.top, + paperTop: paperRect.top, + pageHeight, + pageCount, + zoom: Number.isFinite(currentZoom) ? currentZoom : 1 + }); + } + function refreshPageView() { window.requestAnimationFrame(() => { const editor = editorRef.current; @@ -172,7 +201,10 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP const height = pageHeightFromStyles(editor); const count = Math.max(1, Math.ceil(editor.scrollHeight / height)); - setPageView((current) => (current.count === count && current.height === height ? current : { count, height })); + const currentPage = currentPageFromStage(count, height); + setPageView((current) => + current.count === count && current.height === height && current.current === currentPage ? current : { count, current: currentPage, height } + ); }); } @@ -189,8 +221,8 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP }, [document.html, zoom]); useLayoutEffect(() => { - onStatusChange?.({ pageCount: pageView.count, wordCount }); - }, [onStatusChange, pageView.count, wordCount]); + onStatusChange?.({ currentPage: pageView.current, pageCount: pageView.count, wordCount }); + }, [onStatusChange, pageView.count, pageView.current, wordCount]); function runCommand(command: string, value?: string) { editorRef.current?.focus(); @@ -371,7 +403,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP return (
-
+