Preserve QWord caret during input

This commit is contained in:
Курнат Андрей
2026-06-04 21:53:31 +03:00
parent 3719cb7ec8
commit 217dafb760
2 changed files with 179 additions and 5 deletions
+69
View File
@@ -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: "<p>Начало</p>"
};
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
const normalizedDocument = {
...nextDocument,
html: nextDocument.html.replace("<p>", '<p data-normalized="true">')
};
onChange(normalizedDocument);
setCurrentDocument(normalizedDocument);
}}
/>
);
}
try {
render(<StatefulQWord />);
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: `<p data-normalized="true">${typedText}</p>` }))
);
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;
+110 -5
View File
@@ -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<HTMLTableCellElement | null>(null);
const activeTableCellLocationRef = useRef<TableCellLocation | null>(null);
const restoreActiveTableCellAfterRenderRef = useRef(false);
const pendingEditorSelectionRestoreRef = useRef<EditorSelectionBookmark | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("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();
}
}