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("вставляет таблицу выбранного размера через вкладку Вставка", () => {
|
it("вставляет таблицу выбранного размера через вкладку Вставка", () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ interface TableCellLocation {
|
|||||||
cellIndex: number;
|
cellIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EditorSelectionBookmark {
|
||||||
|
anchorOffset: number;
|
||||||
|
anchorPath: number[];
|
||||||
|
focusOffset: number;
|
||||||
|
focusPath: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface QWordStatus {
|
export interface QWordStatus {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
@@ -267,6 +274,97 @@ function tableCellFromLocation(editor: HTMLElement, location: TableCellLocation)
|
|||||||
return cell instanceof HTMLTableCellElement ? cell : undefined;
|
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) {
|
function placeCaretInTableCell(cell: HTMLTableCellElement) {
|
||||||
if (cell.childNodes.length === 0) {
|
if (cell.childNodes.length === 0) {
|
||||||
cell.append(globalThis.document.createElement("br"));
|
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 activeTableCellRef = useRef<HTMLTableCellElement | null>(null);
|
||||||
const activeTableCellLocationRef = useRef<TableCellLocation | null>(null);
|
const activeTableCellLocationRef = useRef<TableCellLocation | null>(null);
|
||||||
const restoreActiveTableCellAfterRenderRef = useRef(false);
|
const restoreActiveTableCellAfterRenderRef = useRef(false);
|
||||||
|
const pendingEditorSelectionRestoreRef = useRef<EditorSelectionBookmark | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
||||||
@@ -354,10 +453,16 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
if (!editor || editor.innerHTML === document.html) {
|
if (!editor || editor.innerHTML === document.html) {
|
||||||
|
pendingEditorSelectionRestoreRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingSelection = pendingEditorSelectionRestoreRef.current;
|
||||||
|
pendingEditorSelectionRestoreRef.current = null;
|
||||||
editor.innerHTML = document.html;
|
editor.innerHTML = document.html;
|
||||||
|
if (pendingSelection) {
|
||||||
|
restoreEditorSelection(editor, pendingSelection);
|
||||||
|
}
|
||||||
}, [document.html]);
|
}, [document.html]);
|
||||||
|
|
||||||
function currentPageFromStage(pageCount: number, pageHeight: number) {
|
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) {
|
function runCommand(command: string, value?: string) {
|
||||||
editorRef.current?.focus();
|
editorRef.current?.focus();
|
||||||
globalThis.document.execCommand(command, false, value);
|
globalThis.document.execCommand(command, false, value);
|
||||||
if (editorRef.current) {
|
updateHtml();
|
||||||
onChange({ ...document, html: editorRef.current.innerHTML });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTextColor(color: string) {
|
function applyTextColor(color: string) {
|
||||||
@@ -710,8 +813,10 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
|
|||||||
|
|
||||||
function updateHtml() {
|
function updateHtml() {
|
||||||
rememberActiveTableCell();
|
rememberActiveTableCell();
|
||||||
if (editorRef.current) {
|
const editor = editorRef.current;
|
||||||
onChange({ ...document, html: editorRef.current.innerHTML });
|
if (editor) {
|
||||||
|
pendingEditorSelectionRestoreRef.current = editorSelectionBookmark(editor) ?? null;
|
||||||
|
onChange({ ...document, html: editor.innerHTML });
|
||||||
refreshPageView();
|
refreshPageView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user