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 ``;
+}
+
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);