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