Add QWord table structure commands

This commit is contained in:
Курнат Андрей
2026-06-02 09:01:20 +03:00
parent 26bf4b5873
commit a9f9aa6cca
3 changed files with 363 additions and 5 deletions
+126
View File
@@ -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: "<p>Перед таблицей</p>"
};
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
onChange(nextDocument);
setCurrentDocument(nextDocument);
}}
/>
);
}
try {
render(<StatefulQWord />);
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(/<tr/g)).toHaveLength(4);
expect(updatedDocument?.html.match(/<td/g)).toHaveLength(12);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
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-table-structure-test",
title: "Структура таблицы.docx",
updatedAt: "2026-06-02T00:00:00.000Z",
html: "<table><tbody><tr><td>A</td><td>B</td></tr></tbody></table>"
};
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 (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
onChange(nextDocument);
setCurrentDocument(nextDocument);
}}
/>
);
}
try {
render(<StatefulQWord />);
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(/<tr/g)).toHaveLength(2);
expect(updatedDocument?.html.match(/<td/g)).toHaveLength(4);
selectCell("A");
const columnButton = screen.getByRole("button", { name: "Столбец справа" });
expect(fireEvent.mouseDown(columnButton)).toBe(false);
fireEvent.click(columnButton);
updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined;
expect(updatedDocument?.html.match(/<tr/g)).toHaveLength(2);
expect(updatedDocument?.html.match(/<td/g)).toHaveLength(6);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("обновляет текущую страницу в статусе при прокрутке документа", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
+230 -3
View File
@@ -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 `<table><tbody>${tableRows}</tbody></table>`;
}
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<HTMLButtonElement>) {
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<HTMLElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
const activeTableCellRef = useRef<HTMLTableCellElement | null>(null);
const activeTableCellLocationRef = useRef<TableCellLocation | null>(null);
const restoreActiveTableCellAfterRenderRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("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", '<span data-qoffice-page-break="true" contenteditable="false"></span><p><br></p>');
}
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<HTMLElement>) {
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
))}
</select>
</label>
<button className="compact-command" type="button" onClick={insertTable}>
<button className="compact-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTable}>
<Table2 size={16} />
Таблица
</button>
<button className="compact-command word-table-structure-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTableRowBelow}>
<Rows3 size={16} />
Строка ниже
</button>
<button className="compact-command word-table-structure-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTableColumnRight}>
<Columns2 size={16} />
Столбец справа
</button>
</div>
<span className="word-ribbon-title">Таблица</span>
</section>
@@ -782,6 +1005,10 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
contentEditable
suppressContentEditableWarning
onInput={updateHtml}
onFocus={rememberActiveTableCell}
onKeyUp={rememberActiveTableCell}
onMouseDown={rememberActiveTableCellFromEvent}
onMouseUp={rememberActiveTableCellFromEvent}
dangerouslySetInnerHTML={{ __html: document.html }}
aria-label="Содержимое документа"
/>
+7 -2
View File
@@ -675,7 +675,7 @@ button {
}
.word-table-group {
flex: 0 0 156px;
flex: 0 0 236px;
grid-template-columns: minmax(0, 1fr);
}
@@ -701,12 +701,17 @@ button {
width: 100%;
}
.word-table-controls .compact-command {
.word-table-controls .compact-command:not(.word-table-structure-command) {
grid-column: 1 / -1;
justify-content: center;
width: 100%;
}
.word-table-structure-command {
justify-content: flex-start;
width: 100%;
}
.word-edit-group {
flex: 0 0 118px;
grid-template-columns: minmax(0, 1fr);