Add QWord table structure commands
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user