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: "
AB
" + }; + + 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 `${tableRows}
`; } +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 ))} - + + Таблица @@ -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="Содержимое документа" /> diff --git a/src/styles.css b/src/styles.css index 2f26c73..4bec1ce 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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);