Add QWord table insertion command

This commit is contained in:
Курнат Андрей
2026-06-02 08:32:09 +03:00
parent 06c6a99e6d
commit 26bf4b5873
3 changed files with 148 additions and 0 deletions
+39
View File
@@ -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: "<p>Перед таблицей</p>"
};
try {
render(<QWord document={document} onChange={onChange} />);
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("<table>");
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;
+76
View File
@@ -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 }, () => "<td><br></td>").join("");
const tableRows = Array.from({ length: rowCount }, () => `<tr>${cells}</tr>`).join("");
return `<table><tbody>${tableRows}</tbody></table>`;
}
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<HTMLInputElement>(null);
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
const [viewMode, setViewMode] = useState<WordViewMode>("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", '<span data-qoffice-page-break="true" contenteditable="false"></span><p><br></p>');
}
function insertTable() {
const editor = editorRef.current;
if (!editor) {
return;
}
editor.focus();
const html = `${qwordTableHtml(tableRowCount, tableColumnCount)}<p><br></p>`;
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
</section>
) : null}
{activeRibbonTab === "insert" ? (
<section className="word-ribbon-group word-table-group" aria-label="Таблица">
<div className="word-table-controls">
<label className="word-mini-field">
<span>Строки</span>
<select aria-label="Строки таблицы" value={tableRowCount} onChange={(event) => setTableRowCount(clampedTableSize(Number(event.target.value)))}>
{tableSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<label className="word-mini-field">
<span>Столбцы</span>
<select aria-label="Столбцы таблицы" value={tableColumnCount} onChange={(event) => setTableColumnCount(clampedTableSize(Number(event.target.value)))}>
{tableSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<button className="compact-command" type="button" onClick={insertTable}>
<Table2 size={16} />
Таблица
</button>
</div>
<span className="word-ribbon-title">Таблица</span>
</section>
) : null}
{activeRibbonTab === "view" ? (
<section className="word-ribbon-group word-view-group" aria-label="Режимы просмотра">
<div className="word-view-mode-row">
+33
View File
@@ -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);