From 217dafb760fab73c8b9d39ace4c1cf95fc50ecb3 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: Thu, 4 Jun 2026 21:53:31 +0300 Subject: [PATCH] Preserve QWord caret during input --- src/features/qword/QWord.test.tsx | 69 ++++++++++++++++++ src/features/qword/QWord.tsx | 115 ++++++++++++++++++++++++++++-- 2 files changed, 179 insertions(+), 5 deletions(-) diff --git a/src/features/qword/QWord.test.tsx b/src/features/qword/QWord.test.tsx index 4daa929..b93a525 100644 --- a/src/features/qword/QWord.test.tsx +++ b/src/features/qword/QWord.test.tsx @@ -390,6 +390,75 @@ describe("QWord", () => { } }); + it("сохраняет позицию курсора после нормализации HTML родительским состоянием", async () => { + const originalResizeObserver = globalThis.ResizeObserver; + const originalRequestAnimationFrame = window.requestAnimationFrame; + const onChange = 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-caret-normalized-input-test", + title: "Курсор.docx", + updatedAt: "2026-06-04T00:00:00.000Z", + html: "

Начало

" + }; + + function StatefulQWord() { + const [currentDocument, setCurrentDocument] = useState(document); + return ( + { + const normalizedDocument = { + ...nextDocument, + html: nextDocument.html.replace("

", '

') + }; + onChange(normalizedDocument); + setCurrentDocument(normalizedDocument); + }} + /> + ); + } + + try { + render(); + const paper = screen.getByLabelText("Содержимое документа"); + const textNode = paper.querySelector("p")?.firstChild; + expect(textNode).toBeInstanceOf(Text); + + const typedText = "Начало текст"; + textNode!.textContent = typedText; + const range = window.document.createRange(); + range.setStart(textNode!, typedText.length); + range.collapse(true); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + + fireEvent.input(paper); + + await waitFor(() => + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ html: `

${typedText}

` })) + ); + await waitFor(() => { + const normalizedTextNode = paper.querySelector("p")?.firstChild; + const selection = window.getSelection(); + expect(selection?.anchorNode).toBe(normalizedTextNode); + expect(selection?.anchorOffset).toBe(typedText.length); + }); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + window.requestAnimationFrame = originalRequestAnimationFrame; + } + }); + it("вставляет таблицу выбранного размера через вкладку Вставка", () => { const originalResizeObserver = globalThis.ResizeObserver; const originalRequestAnimationFrame = window.requestAnimationFrame; diff --git a/src/features/qword/QWord.tsx b/src/features/qword/QWord.tsx index c1b099b..3d24cd1 100644 --- a/src/features/qword/QWord.tsx +++ b/src/features/qword/QWord.tsx @@ -55,6 +55,13 @@ interface TableCellLocation { cellIndex: number; } +interface EditorSelectionBookmark { + anchorOffset: number; + anchorPath: number[]; + focusOffset: number; + focusPath: number[]; +} + export interface QWordStatus { currentPage: number; pageCount: number; @@ -267,6 +274,97 @@ function tableCellFromLocation(editor: HTMLElement, location: TableCellLocation) return cell instanceof HTMLTableCellElement ? cell : undefined; } +function editorContainsNode(editor: HTMLElement, node: Node) { + return node === editor || editor.contains(node); +} + +function editorNodePath(editor: HTMLElement, node: Node): number[] | undefined { + if (node === editor) { + return []; + } + + const path: number[] = []; + let current: Node | null = node; + while (current && current !== editor) { + const parent: Node | null = current.parentNode; + if (!parent) { + return undefined; + } + + const siblings = Array.from(parent.childNodes) as Node[]; + const index = siblings.indexOf(current); + if (index < 0) { + return undefined; + } + + path.unshift(index); + current = parent; + } + + return current === editor ? path : undefined; +} + +function editorNodeFromPath(editor: HTMLElement, path: number[]): Node | undefined { + let current: Node = editor; + for (const index of path) { + const child = current.childNodes[index]; + if (!child) { + return undefined; + } + current = child; + } + return current; +} + +function clampedSelectionOffset(node: Node, offset: number) { + const maxOffset = node.nodeType === Node.TEXT_NODE ? (node.textContent ?? "").length : node.childNodes.length; + return Math.min(Math.max(0, offset), maxOffset); +} + +function editorSelectionBookmark(editor: HTMLElement): EditorSelectionBookmark | undefined { + const selection = window.getSelection(); + const anchorNode = selection?.anchorNode; + const focusNode = selection?.focusNode; + if (!selection || selection.rangeCount === 0 || !anchorNode || !focusNode || !editorContainsNode(editor, anchorNode) || !editorContainsNode(editor, focusNode)) { + return undefined; + } + + const anchorPath = editorNodePath(editor, anchorNode); + const focusPath = editorNodePath(editor, focusNode); + if (!anchorPath || !focusPath) { + return undefined; + } + + return { + anchorOffset: selection.anchorOffset, + anchorPath, + focusOffset: selection.focusOffset, + focusPath + }; +} + +function restoreEditorSelection(editor: HTMLElement, bookmark: EditorSelectionBookmark) { + const anchorNode = editorNodeFromPath(editor, bookmark.anchorPath); + const focusNode = editorNodeFromPath(editor, bookmark.focusPath); + const selection = window.getSelection(); + if (!anchorNode || !focusNode || !selection) { + return; + } + + const anchorOffset = clampedSelectionOffset(anchorNode, bookmark.anchorOffset); + const focusOffset = clampedSelectionOffset(focusNode, bookmark.focusOffset); + selection.removeAllRanges(); + if (typeof selection.setBaseAndExtent === "function") { + selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); + return; + } + + const range = globalThis.document.createRange(); + range.setStart(anchorNode, anchorOffset); + range.setEnd(focusNode, focusOffset); + selection.addRange(range); +} + function placeCaretInTableCell(cell: HTMLTableCellElement) { if (cell.childNodes.length === 0) { cell.append(globalThis.document.createElement("br")); @@ -307,6 +405,7 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange const activeTableCellRef = useRef(null); const activeTableCellLocationRef = useRef(null); const restoreActiveTableCellAfterRenderRef = useRef(false); + const pendingEditorSelectionRestoreRef = useRef(null); const fileInputRef = useRef(null); const imageInputRef = useRef(null); const [activeRibbonTab, setActiveRibbonTab] = useState("home"); @@ -354,10 +453,16 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange useLayoutEffect(() => { const editor = editorRef.current; if (!editor || editor.innerHTML === document.html) { + pendingEditorSelectionRestoreRef.current = null; return; } + const pendingSelection = pendingEditorSelectionRestoreRef.current; + pendingEditorSelectionRestoreRef.current = null; editor.innerHTML = document.html; + if (pendingSelection) { + restoreEditorSelection(editor, pendingSelection); + } }, [document.html]); function currentPageFromStage(pageCount: number, pageHeight: number) { @@ -435,9 +540,7 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange function runCommand(command: string, value?: string) { editorRef.current?.focus(); globalThis.document.execCommand(command, false, value); - if (editorRef.current) { - onChange({ ...document, html: editorRef.current.innerHTML }); - } + updateHtml(); } function applyTextColor(color: string) { @@ -710,8 +813,10 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange function updateHtml() { rememberActiveTableCell(); - if (editorRef.current) { - onChange({ ...document, html: editorRef.current.innerHTML }); + const editor = editorRef.current; + if (editor) { + pendingEditorSelectionRestoreRef.current = editorSelectionBookmark(editor) ?? null; + onChange({ ...document, html: editor.innerHTML }); refreshPageView(); } }