diff --git a/src/features/qword/QWord.test.tsx b/src/features/qword/QWord.test.tsx index 305bfa5..d458034 100644 --- a/src/features/qword/QWord.test.tsx +++ b/src/features/qword/QWord.test.tsx @@ -102,6 +102,45 @@ 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-test", + title: "Таблица.docx", + updatedAt: "2026-06-02T00:00:00.000Z", + html: "

Перед таблицей

" + }; + + try { + render(); + + fireEvent.click(screen.getByRole("tab", { name: "Вставка" })); + fireEvent.change(screen.getByLabelText("Строки таблицы"), { target: { value: "2" } }); + fireEvent.change(screen.getByLabelText("Столбцы таблицы"), { target: { value: "3" } }); + fireEvent.click(screen.getByRole("button", { name: "Таблица" })); + + const updatedDocument = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as WordDocument | undefined; + expect(updatedDocument?.html).toContain(""); + 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 507a225..71aaf42 100644 --- a/src/features/qword/QWord.tsx +++ b/src/features/qword/QWord.tsx @@ -26,6 +26,7 @@ import { Strikethrough, Subscript, Superscript, + Table2, Type, Underline } from "lucide-react"; @@ -164,6 +165,7 @@ const wordViewModes: Array<{ id: WordViewMode; label: string }> = [ { id: "read", label: "Чтение" }, { id: "web", label: "Веб-документ" } ]; +const tableSizeOptions = [1, 2, 3, 4, 5, 6, 7, 8]; function findInPage(query: string) { return (window as PageSearchWindow).find?.(query) ?? false; @@ -176,6 +178,18 @@ function pageHeightFromStyles(element: HTMLElement) { return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight; } +function clampedTableSize(value: number) { + return Number.isFinite(value) ? Math.min(8, Math.max(1, Math.trunc(value))) : 3; +} + +function qwordTableHtml(rows: number, columns: number) { + const rowCount = clampedTableSize(rows); + const columnCount = clampedTableSize(columns); + const cells = Array.from({ length: columnCount }, () => "").join(""); + const tableRows = Array.from({ length: rowCount }, () => `${cells}`).join(""); + return `

${tableRows}
`; +} + 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; @@ -191,6 +205,8 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP const imageInputRef = useRef(null); const [activeRibbonTab, setActiveRibbonTab] = useState("home"); const [viewMode, setViewMode] = useState("print"); + const [tableRowCount, setTableRowCount] = useState(3); + const [tableColumnCount, setTableColumnCount] = useState(3); const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight }); const wordCount = useMemo(() => countWords(document.html), [document.html]); const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties; @@ -335,6 +351,34 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP runCommand("insertHTML", '


'); } + function insertTable() { + const editor = editorRef.current; + if (!editor) { + return; + } + + editor.focus(); + const html = `${qwordTableHtml(tableRowCount, tableColumnCount)}


`; + const selection = window.getSelection(); + const range = selection?.rangeCount ? selection.getRangeAt(0) : undefined; + if (range && (range.commonAncestorContainer === editor || editor.contains(range.commonAncestorContainer))) { + range.deleteContents(); + const fragment = globalThis.document.createRange().createContextualFragment(html); + const lastNode = fragment.lastChild; + range.insertNode(fragment); + if (lastNode) { + range.setStartAfter(lastNode); + range.collapse(true); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } else { + editor.insertAdjacentHTML("beforeend", html); + } + + updateHtml(); + } + async function insertImage(file: File | undefined) { if (!file) { return; @@ -650,6 +694,38 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP ) : null} + {activeRibbonTab === "insert" ? ( +
+
+ + + +
+ Таблица +
+ ) : null} + {activeRibbonTab === "view" ? (
diff --git a/src/styles.css b/src/styles.css index be4e689..2f26c73 100644 --- a/src/styles.css +++ b/src/styles.css @@ -674,6 +674,39 @@ button { justify-content: flex-start; } +.word-table-group { + flex: 0 0 156px; + grid-template-columns: minmax(0, 1fr); +} + +.word-table-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; + gap: 5px 7px; + min-width: 0; +} + +.word-mini-field { + display: grid; + gap: 2px; + min-width: 0; + color: #69778a; + font-size: 11px; + line-height: 1.1; +} + +.word-mini-field select { + min-width: 0; + width: 100%; +} + +.word-table-controls .compact-command { + grid-column: 1 / -1; + justify-content: center; + width: 100%; +} + .word-edit-group { flex: 0 0 118px; grid-template-columns: minmax(0, 1fr);