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 () => {
|
it("обновляет текущую страницу в статусе при прокрутке документа", async () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
Strikethrough,
|
Strikethrough,
|
||||||
Subscript,
|
Subscript,
|
||||||
Superscript,
|
Superscript,
|
||||||
|
Table2,
|
||||||
Type,
|
Type,
|
||||||
Underline
|
Underline
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -164,6 +165,7 @@ const wordViewModes: Array<{ id: WordViewMode; label: string }> = [
|
|||||||
{ id: "read", label: "Чтение" },
|
{ id: "read", label: "Чтение" },
|
||||||
{ id: "web", label: "Веб-документ" }
|
{ id: "web", label: "Веб-документ" }
|
||||||
];
|
];
|
||||||
|
const tableSizeOptions = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
|
|
||||||
function findInPage(query: string) {
|
function findInPage(query: string) {
|
||||||
return (window as PageSearchWindow).find?.(query) ?? false;
|
return (window as PageSearchWindow).find?.(query) ?? false;
|
||||||
@@ -176,6 +178,18 @@ function pageHeightFromStyles(element: HTMLElement) {
|
|||||||
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight;
|
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 }) {
|
export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop: number; pageHeight: number; pageCount: number; zoom: number }) {
|
||||||
const pageCount = Math.max(1, input.pageCount);
|
const pageCount = Math.max(1, input.pageCount);
|
||||||
const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1;
|
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 imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
||||||
const [viewMode, setViewMode] = useState<WordViewMode>("print");
|
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 [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
|
||||||
const wordCount = useMemo(() => countWords(document.html), [document.html]);
|
const wordCount = useMemo(() => countWords(document.html), [document.html]);
|
||||||
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
|
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>');
|
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) {
|
async function insertImage(file: File | undefined) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
@@ -650,6 +694,38 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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" ? (
|
{activeRibbonTab === "view" ? (
|
||||||
<section className="word-ribbon-group word-view-group" aria-label="Режимы просмотра">
|
<section className="word-ribbon-group word-view-group" aria-label="Режимы просмотра">
|
||||||
<div className="word-view-mode-row">
|
<div className="word-view-mode-row">
|
||||||
|
|||||||
@@ -674,6 +674,39 @@ button {
|
|||||||
justify-content: flex-start;
|
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 {
|
.word-edit-group {
|
||||||
flex: 0 0 118px;
|
flex: 0 0 118px;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user