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 ( +", '
')
+ };
+ onChange(normalizedDocument);
+ setCurrentDocument(normalizedDocument);
+ }}
+ />
+ );
+ }
+
+ try {
+ render(
${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