diff --git a/src/features/qword/QWord.test.tsx b/src/features/qword/QWord.test.tsx
index d458034..ee303b5 100644
--- a/src/features/qword/QWord.test.tsx
+++ b/src/features/qword/QWord.test.tsx
@@ -1,4 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { WordDocument } from "../../types";
@@ -141,6 +142,131 @@ describe("QWord", () => {
}
});
+ it("сохраняет активную ячейку после вставки таблицы и перерендера", () => {
+ 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-insert-table-restore-test",
+ title: "Активная таблица.docx",
+ updatedAt: "2026-06-02T00:00:00.000Z",
+ html: "
Перед таблицей
"
+ };
+
+ function StatefulQWord() {
+ const [currentDocument, setCurrentDocument] = useState(document);
+ return (
+ {
+ onChange(nextDocument);
+ setCurrentDocument(nextDocument);
+ }}
+ />
+ );
+ }
+
+ try {
+ render();
+
+ fireEvent.click(screen.getByRole("tab", { name: "Вставка" }));
+ const tableButton = screen.getByRole("button", { name: "Таблица" });
+ expect(fireEvent.mouseDown(tableButton)).toBe(false);
+ fireEvent.click(tableButton);
+
+ const rowButton = screen.getByRole("button", { name: "Строка ниже" });
+ expect(fireEvent.mouseDown(rowButton)).toBe(false);
+ fireEvent.click(rowButton);
+
+ const updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined;
+ expect(updatedDocument?.html.match(/ {
+ 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-table-structure-test",
+ title: "Структура таблицы.docx",
+ updatedAt: "2026-06-02T00:00:00.000Z",
+ html: ""
+ };
+
+ function selectCell(text: string) {
+ const cell = screen.getByText(text).closest("td");
+ expect(cell).toBeInTheDocument();
+ const range = window.document.createRange();
+ range.selectNodeContents(cell as HTMLTableCellElement);
+ window.getSelection()?.removeAllRanges();
+ window.getSelection()?.addRange(range);
+ }
+
+ function StatefulQWord() {
+ const [currentDocument, setCurrentDocument] = useState(document);
+ return (
+ {
+ onChange(nextDocument);
+ setCurrentDocument(nextDocument);
+ }}
+ />
+ );
+ }
+
+ try {
+ render();
+
+ fireEvent.click(screen.getByRole("tab", { name: "Вставка" }));
+ selectCell("A");
+ const rowButton = screen.getByRole("button", { name: "Строка ниже" });
+ expect(fireEvent.mouseDown(rowButton)).toBe(false);
+ fireEvent.click(rowButton);
+
+ let updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined;
+ expect(updatedDocument?.html.match(/ {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
diff --git a/src/features/qword/QWord.tsx b/src/features/qword/QWord.tsx
index 71aaf42..46e439d 100644
--- a/src/features/qword/QWord.tsx
+++ b/src/features/qword/QWord.tsx
@@ -1,5 +1,5 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
-import type { CSSProperties } from "react";
+import type { CSSProperties, MouseEvent } from "react";
import {
AlignCenter,
AlignJustify,
@@ -7,6 +7,7 @@ import {
AlignRight,
Bold,
ClipboardPaste,
+ Columns2,
Copy,
Download,
FileText,
@@ -28,6 +29,7 @@ import {
Superscript,
Table2,
Type,
+ Rows3,
Underline
} from "lucide-react";
import type { WordDocument } from "../../types";
@@ -45,6 +47,12 @@ interface QWordProps {
onStatusChange?: (status: QWordStatus) => void;
}
+interface TableCellLocation {
+ tableIndex: number;
+ rowIndex: number;
+ cellIndex: number;
+}
+
export interface QWordStatus {
currentPage: number;
pageCount: number;
@@ -190,6 +198,55 @@ function qwordTableHtml(rows: number, columns: number) {
return ``;
}
+function createBlankTableCell(tagName: "td" | "th" = "td") {
+ const cell = globalThis.document.createElement(tagName);
+ cell.append(globalThis.document.createElement("br"));
+ return cell;
+}
+
+function tableCellFromSelection(editor: HTMLElement): HTMLTableCellElement | undefined {
+ const node = window.getSelection()?.anchorNode;
+ const element = node instanceof Element ? node : node?.parentElement;
+ const cell = element?.closest("td,th");
+ return cell instanceof HTMLTableCellElement && editor.contains(cell) ? cell : undefined;
+}
+
+function tableCellLocation(editor: HTMLElement, cell: HTMLTableCellElement): TableCellLocation | undefined {
+ const table = cell.closest("table");
+ const row = cell.parentElement;
+ if (!(table instanceof HTMLTableElement) || !(row instanceof HTMLTableRowElement) || !editor.contains(table)) {
+ return undefined;
+ }
+
+ const tableIndex = Array.from(editor.querySelectorAll("table")).indexOf(table);
+ const rowIndex = Array.from(table.rows).indexOf(row);
+ const cellIndex = cell.cellIndex;
+ return tableIndex >= 0 && rowIndex >= 0 && cellIndex >= 0 ? { cellIndex, rowIndex, tableIndex } : undefined;
+}
+
+function tableCellFromLocation(editor: HTMLElement, location: TableCellLocation): HTMLTableCellElement | undefined {
+ const table = Array.from(editor.querySelectorAll("table"))[location.tableIndex];
+ const cell = table?.rows[location.rowIndex]?.cells[location.cellIndex];
+ return cell instanceof HTMLTableCellElement ? cell : undefined;
+}
+
+function placeCaretInTableCell(cell: HTMLTableCellElement) {
+ if (cell.childNodes.length === 0) {
+ cell.append(globalThis.document.createElement("br"));
+ }
+
+ const range = globalThis.document.createRange();
+ range.selectNodeContents(cell);
+ range.collapse(true);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+}
+
+function keepEditorSelection(event: MouseEvent) {
+ event.preventDefault();
+}
+
export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop: number; pageHeight: number; pageCount: number; zoom: number }) {
const pageCount = Math.max(1, input.pageCount);
const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1;
@@ -201,6 +258,9 @@ export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop
export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordProps) {
const stageRef = useRef(null);
const editorRef = useRef(null);
+ const activeTableCellRef = useRef(null);
+ const activeTableCellLocationRef = useRef(null);
+ const restoreActiveTableCellAfterRenderRef = useRef(false);
const fileInputRef = useRef(null);
const imageInputRef = useRef(null);
const [activeRibbonTab, setActiveRibbonTab] = useState("home");
@@ -260,6 +320,25 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
return () => observer.disconnect();
}, [document.html, zoom]);
+ useLayoutEffect(() => {
+ if (!restoreActiveTableCellAfterRenderRef.current) {
+ return;
+ }
+
+ restoreActiveTableCellAfterRenderRef.current = false;
+ const editor = editorRef.current;
+ const location = activeTableCellLocationRef.current;
+ if (!editor || !location) {
+ return;
+ }
+
+ const restoredCell = tableCellFromLocation(editor, location);
+ if (restoredCell) {
+ activeTableCellRef.current = restoredCell;
+ placeCaretInTableCell(restoredCell);
+ }
+ }, [document.html]);
+
useLayoutEffect(() => {
onStatusChange?.({ currentPage: pageView.current, pageCount: pageView.count, wordCount });
}, [onStatusChange, pageView.count, pageView.current, wordCount]);
@@ -351,6 +430,71 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
runCommand("insertHTML", '
');
}
+ function setActiveTableCell(cell: HTMLTableCellElement) {
+ const editor = editorRef.current;
+ activeTableCellRef.current = cell;
+ if (editor) {
+ activeTableCellLocationRef.current = tableCellLocation(editor, cell) ?? activeTableCellLocationRef.current;
+ }
+ }
+
+ function queueActiveTableCellRestore(cell: HTMLTableCellElement) {
+ setActiveTableCell(cell);
+ restoreActiveTableCellAfterRenderRef.current = true;
+ }
+
+ function rememberActiveTableCell(): HTMLTableCellElement | undefined {
+ const editor = editorRef.current;
+ if (!editor) {
+ return undefined;
+ }
+ const cell = tableCellFromSelection(editor);
+ if (cell) {
+ setActiveTableCell(cell);
+ }
+ return cell;
+ }
+
+ function rememberActiveTableCellFromEvent(event: MouseEvent) {
+ const editor = editorRef.current;
+ const target = event.target;
+ const cell = target instanceof Element ? target.closest("td,th") : undefined;
+ if (editor && cell instanceof HTMLTableCellElement && editor.contains(cell)) {
+ setActiveTableCell(cell);
+ return;
+ }
+
+ rememberActiveTableCell();
+ }
+
+ function activeTableCell(): HTMLTableCellElement | undefined {
+ const editor = editorRef.current;
+ if (!editor) {
+ return undefined;
+ }
+
+ const selectedCell = rememberActiveTableCell();
+ if (selectedCell) {
+ return selectedCell;
+ }
+
+ const rememberedCell = activeTableCellRef.current;
+ if (rememberedCell && editor.contains(rememberedCell)) {
+ return rememberedCell;
+ }
+
+ const rememberedLocation = activeTableCellLocationRef.current;
+ if (!rememberedLocation) {
+ return undefined;
+ }
+
+ const restoredCell = tableCellFromLocation(editor, rememberedLocation);
+ if (restoredCell) {
+ activeTableCellRef.current = restoredCell;
+ }
+ return restoredCell;
+ }
+
function insertTable() {
const editor = editorRef.current;
if (!editor) {
@@ -364,9 +508,13 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
if (range && (range.commonAncestorContainer === editor || editor.contains(range.commonAncestorContainer))) {
range.deleteContents();
const fragment = globalThis.document.createRange().createContextualFragment(html);
+ const firstCell = fragment.querySelector("td,th") as HTMLTableCellElement | null;
const lastNode = fragment.lastChild;
range.insertNode(fragment);
- if (lastNode) {
+ if (firstCell) {
+ queueActiveTableCellRestore(firstCell);
+ placeCaretInTableCell(firstCell);
+ } else if (lastNode) {
range.setStartAfter(lastNode);
range.collapse(true);
selection?.removeAllRanges();
@@ -374,11 +522,77 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
}
} else {
editor.insertAdjacentHTML("beforeend", html);
+ const tables = editor.querySelectorAll("table");
+ const firstCell = tables[tables.length - 1]?.querySelector("td,th") as HTMLTableCellElement | null;
+ if (firstCell) {
+ queueActiveTableCellRestore(firstCell);
+ placeCaretInTableCell(firstCell);
+ }
}
updateHtml();
}
+ function insertTableRowBelow() {
+ const editor = editorRef.current;
+ if (!editor) {
+ return;
+ }
+
+ const cell = activeTableCell();
+ const row = cell?.parentElement;
+ if (!cell || !(row instanceof HTMLTableRowElement)) {
+ window.alert("Поместите курсор в ячейку таблицы.");
+ return;
+ }
+
+ const newRow = globalThis.document.createElement("tr");
+ Array.from({ length: Math.max(1, row.cells.length) }, () => createBlankTableCell()).forEach((newCell) => newRow.append(newCell));
+ row.after(newRow);
+ const nextCell = newRow.cells[Math.min(cell.cellIndex, newRow.cells.length - 1)] ?? newRow.cells[0];
+ queueActiveTableCellRestore(nextCell);
+ placeCaretInTableCell(nextCell);
+ updateHtml();
+ }
+
+ function insertTableColumnRight() {
+ const editor = editorRef.current;
+ if (!editor) {
+ return;
+ }
+
+ const cell = activeTableCell();
+ const row = cell?.parentElement;
+ const table = cell?.closest("table");
+ if (!cell || !(row instanceof HTMLTableRowElement) || !(table instanceof HTMLTableElement)) {
+ window.alert("Поместите курсор в ячейку таблицы.");
+ return;
+ }
+
+ const columnIndex = cell.cellIndex;
+ let selectedNewCell: HTMLTableCellElement | undefined;
+ Array.from(table.rows).forEach((tableRow) => {
+ const referenceCell = tableRow.cells[Math.min(columnIndex, Math.max(0, tableRow.cells.length - 1))];
+ const tagName = referenceCell?.tagName.toLowerCase() === "th" ? "th" : "td";
+ const newCell = createBlankTableCell(tagName);
+ if (referenceCell?.nextSibling) {
+ tableRow.insertBefore(newCell, referenceCell.nextSibling);
+ } else {
+ tableRow.append(newCell);
+ }
+
+ if (tableRow === row) {
+ selectedNewCell = newCell;
+ }
+ });
+
+ if (selectedNewCell) {
+ queueActiveTableCellRestore(selectedNewCell);
+ placeCaretInTableCell(selectedNewCell);
+ }
+ updateHtml();
+ }
+
async function insertImage(file: File | undefined) {
if (!file) {
return;
@@ -406,6 +620,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
}
function updateHtml() {
+ rememberActiveTableCell();
if (editorRef.current) {
onChange({ ...document, html: editorRef.current.innerHTML });
refreshPageView();
@@ -717,10 +932,18 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
))}
-