Preserve QWord caret during input
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user