Track QWord current page in status bar

This commit is contained in:
Курнат Андрей
2026-06-02 07:16:15 +03:00
parent 07f7335c74
commit 7ace47dd2a
3 changed files with 110 additions and 9 deletions
+14 -4
View File
@@ -11,7 +11,7 @@ import { activeAppFromLocation } from "./appRouting";
import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types"; import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types";
const STORAGE_KEY = "qoffice-state-v1"; 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) { function clampedWordZoom(value: number) {
return Math.min(200, Math.max(50, value)); return Math.min(200, Math.max(50, value));
@@ -104,7 +104,7 @@ export function App() {
const updateWordStatus = useCallback((nextStatus: QWordStatus) => { const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
setWordStatus((current) => 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", className: "word-statusbar",
content: ( content: (
<> <>
<span>Страница 1 из {wordStatus.pageCount}</span> <span>Страница {wordStatus.currentPage} из {wordStatus.pageCount}</span>
<span>Число слов: {wordStatus.wordCount}</span> <span>Число слов: {wordStatus.wordCount}</span>
<span>русский</span> <span>русский</span>
</> </>
@@ -176,7 +176,17 @@ export function App() {
savedLabel: "Сохранено локально", savedLabel: "Сохранено локально",
updatedAt 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) { function updateWord(word: WordDocument) {
setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } })); setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } }));
+59
View File
@@ -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: "<p>Первая страница</p>"
};
try {
const view = render(<QWord document={document} onChange={vi.fn()} onStatusChange={onStatusChange} />);
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;
}
});
});
+37 -5
View File
@@ -45,6 +45,7 @@ interface QWordProps {
} }
export interface QWordStatus { export interface QWordStatus {
currentPage: number;
pageCount: number; pageCount: number;
wordCount: number; wordCount: number;
} }
@@ -155,14 +156,42 @@ function pageHeightFromStyles(element: HTMLElement) {
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight; 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) { export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordProps) {
const stageRef = useRef<HTMLElement>(null);
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 [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 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;
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() { function refreshPageView() {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
const editor = editorRef.current; const editor = editorRef.current;
@@ -172,7 +201,10 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
const height = pageHeightFromStyles(editor); const height = pageHeightFromStyles(editor);
const count = Math.max(1, Math.ceil(editor.scrollHeight / height)); 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]); }, [document.html, zoom]);
useLayoutEffect(() => { useLayoutEffect(() => {
onStatusChange?.({ pageCount: pageView.count, wordCount }); onStatusChange?.({ currentPage: pageView.current, pageCount: pageView.count, wordCount });
}, [onStatusChange, pageView.count, wordCount]); }, [onStatusChange, pageView.count, pageView.current, wordCount]);
function runCommand(command: string, value?: string) { function runCommand(command: string, value?: string) {
editorRef.current?.focus(); editorRef.current?.focus();
@@ -371,7 +403,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 className="document-stage" style={stageStyle} aria-label="Редактор QWord"> <section ref={stageRef} className="document-stage" 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}