feat: table cell splitting/merging

This commit is contained in:
Steffen Becker committed 2026-06-28 10:30:00 +02:00
1 parent 14458b3103
commit ec678ed495
9 files changed
+603 -8

No files matched your search

+2 -2
View File
@@ -33,7 +33,7 @@
- Tab stops (real tab character) and manual line breaks (Shift+Enter)
**Insert**
- Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders; a table splits cleanly across page boundaries
- Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style); a table splits cleanly across page boundaries
- Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste
- Special characters picker
- Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip
@@ -65,7 +65,7 @@
### Not yet implemented
Planned word-processor features (to match Word/LibreOffice):
- Table cells: merge / split, cell background/shading, header-row toggle
- Table cells: cell background/shading, header-row toggle
- Footnotes / endnotes (currently dropped on import)
- Clear formatting (remove all marks)
- Multi-column (newspaper) layout
+27 -2
View File
@@ -8,6 +8,7 @@
import { spellErrorAt } from '../editor/extensions/spellCheck';
import { spellController } from '../spell/controller';
import TableToolbar from './TableToolbar.svelte';
import TableSplitDialog from './TableSplitDialog.svelte';
import ImageToolbar from './ImageToolbar.svelte';
import type { WrapMode } from '../editor/extensions/image';
import { NodeSelection } from '@tiptap/pm/state';
@@ -179,6 +180,12 @@
// Shown when the selection is inside a table; positioned just above that table.
let tableUi = $state<{ visible: boolean; top: number; left: number }>({ visible: false, top: 0, left: 0 });
let tableUiRaf = 0;
// Word-style "Split Cells…" popover, opened from the table toolbar.
let splitDialogOpen = $state(false);
// Drop the dialog if the selection leaves the table (toolbar hidden).
$effect(() => {
if (!tableUi.visible && splitDialogOpen) splitDialogOpen = false;
});
// The DOM element of the table containing the current selection, or null.
// nodeDOM(before(table)) returns the wrapper div the table node view renders.
@@ -628,8 +635,26 @@
/>
</div>
</div>
{#if tableUi.visible}
<TableToolbar {editor} top={tableUi.top} left={tableUi.left} />
{#if tableUi.visible && !splitDialogOpen}
<TableToolbar
{editor}
{tick}
top={tableUi.top}
left={tableUi.left}
onSplit={() => (splitDialogOpen = true)}
/>
{/if}
{#if splitDialogOpen && tableUi.visible}
<TableSplitDialog
open={splitDialogOpen}
top={tableUi.top}
left={tableUi.left}
onApply={(cols, rows) => {
editor?.chain().focus().splitCellInto(cols, rows).run();
splitDialogOpen = false;
}}
onClose={() => (splitDialogOpen = false)}
/>
{/if}
{#if imageUi.visible}
<ImageToolbar {editor} top={imageUi.top} left={imageUi.left} wrap={imageUi.wrap} />
+152
View File
@@ -0,0 +1,152 @@
<script lang="ts">
// Word/LibreOffice "Split Cells…" popover: collects column + row counts. The parent
// owns `open` and runs the splitCellInto command against the saved cell selection
// (focus moves to the inputs, like LinkDialog/TablePicker).
let {
open,
top,
left,
onApply,
onClose,
}: {
open: boolean;
top: number;
left: number;
onApply: (cols: number, rows: number) => void;
onClose: () => void;
} = $props();
const MIN = 1;
const MAX = 20;
let colsInput = $state('2');
let rowsInput = $state('1');
let firstField = $state<HTMLInputElement | null>(null);
// Reset to the defaults each time the popover opens, then focus the columns field.
$effect(() => {
if (open) {
colsInput = '2';
rowsInput = '1';
queueMicrotask(() => firstField?.focus());
queueMicrotask(() => firstField?.select());
}
});
function clamp(v: string): number {
const n = Math.round(parseFloat(v));
if (!Number.isFinite(n)) return MIN;
return Math.max(MIN, Math.min(MAX, n));
}
function apply() {
onApply(clamp(colsInput), clamp(rowsInput));
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); apply(); }
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
}
</script>
{#if open}
<div
class="split-dialog"
style="top: {top}px; left: {left}px;"
role="dialog"
tabindex="-1"
aria-label="Split cells"
>
<div class="split-fields">
<label>
<span>Columns</span>
<input
bind:this={firstField}
bind:value={colsInput}
type="number"
min={MIN}
max={MAX}
onkeydown={onKeydown}
/>
</label>
<label>
<span>Rows</span>
<input
bind:value={rowsInput}
type="number"
min={MIN}
max={MAX}
onkeydown={onKeydown}
/>
</label>
</div>
<div class="split-actions">
<span class="split-spacer"></span>
<button class="split-cancel" onclick={onClose}>Cancel</button>
<button class="split-apply" onclick={apply}>Split</button>
</div>
</div>
{/if}
<style>
.split-dialog {
position: absolute;
/* Sit just above the table's top-left corner, like TableToolbar. */
transform: translateY(calc(-100% - 6px));
z-index: 300;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.6rem;
background: var(--color-toolbar-bg, #fff);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
.split-fields {
display: flex;
gap: 0.6rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--color-text);
}
input {
width: 4.5rem;
box-sizing: border-box;
padding: 0.35rem 0.45rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
font-size: 0.85rem;
}
.split-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.split-spacer { flex: 1; }
.split-actions button {
height: 1.8rem;
padding: 0 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
font-size: 0.8rem;
cursor: pointer;
}
.split-actions button:hover { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
.split-apply { border-color: var(--color-accent, #1a56db) !important; color: var(--color-accent, #1a56db); }
</style>
+41 -1
View File
@@ -5,10 +5,14 @@
editor,
top,
left,
tick,
onSplit,
}: {
editor: Editor | null;
top: number;
left: number;
tick: number;
onSplit: () => void;
} = $props();
// Run a table command without disturbing the cell selection: preventDefault on
@@ -18,6 +22,9 @@
if (!editor) return;
cmd(editor.chain().focus()).run();
}
// Merge needs a multi-cell selection; re-evaluated per transaction via `tick`.
const canMerge = $derived(tick >= 0 && !!editor && editor.can().mergeCells());
</script>
<div
@@ -116,6 +123,34 @@
<path d="M3.5 5h11M7 5V3.5h4V5M5 5l.7 9.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L13 5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<span class="tt-sep"></span>
<button
class="tt-btn"
title="Merge cells"
aria-label="Merge cells"
disabled={!canMerge}
onclick={() => run((c) => c.mergeCells())}
>
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="3" y="4" width="12" height="10" rx="1" stroke="currentColor" stroke-width="1.3"/>
<path d="M6 9h6M6 9l1.6-1.6M6 9l1.6 1.6M12 9l-1.6-1.6M12 9l-1.6 1.6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button
class="tt-btn"
title="Split cells…"
aria-label="Split cells"
onclick={() => onSplit()}
>
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="3" y="4" width="12" height="10" rx="1" stroke="currentColor" stroke-width="1.3"/>
<line x1="9" y1="4" x2="9" y2="14" stroke="currentColor" stroke-width="1.3"/>
<path d="M9 7.4L7.4 9 9 10.6M9 7.4L10.6 9 9 10.6" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<style>
@@ -152,10 +187,15 @@
transition: background 0.12s, color 0.12s;
}
.tt-btn:hover {
.tt-btn:hover:not(:disabled) {
background: var(--color-btn-hover);
}
.tt-btn:disabled {
opacity: 0.35;
cursor: default;
}
.tt-danger:hover {
background: #fdecea;
color: #c0392b;
+3
View File
@@ -33,6 +33,7 @@ import { SearchReplace } from './extensions/searchReplace';
import { TableView } from './extensions/tableView';
import { TableColumnResize } from './extensions/tableColumnResize';
import { TableRowResize } from './extensions/tableRowResize';
import { TableSplit } from './extensions/tableSplit';
import { TrailingNode } from './extensions/trailingNode';
import { Image } from './extensions/image';
@@ -79,6 +80,8 @@ export const extensions = [
TableCell,
TableColumnResize,
TableRowResize,
// Word-style "Split Cells…" (N×M); merge uses extension-table's built-in mergeCells.
TableSplit,
// Guarantees a paragraph after a trailing table so the cursor isn't trapped
// and the user can keep writing below a table at the document's end.
TrailingNode,
+230
View File
@@ -0,0 +1,230 @@
import { Extension } from '@tiptap/core';
import type { CommandProps } from '@tiptap/core';
import { TextSelection } from '@tiptap/pm/state';
import type { Node as PMNode } from '@tiptap/pm/model';
import { TableMap, selectedRect, isInTable } from '@tiptap/pm/tables';
import { columnWeightsFromRow } from './tableView';
// Word/LibreOffice "Split Cells…": divide the current cell (or a clean rectangular
// cell selection) into `cols` × `rows` sub-cells. prosemirror-tables only ships
// splitCell (un-merge), so this is custom. The table is a strict rectangular grid,
// so adding sub-columns/rows means inserting grid lines and letting every other
// cell that crosses a new line grow its span (bridge) — exactly what Word does.
// Implemented by reading the grid via TableMap, expanding it, partitioning the
// cell's region, and rebuilding the table node in one step.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
tableSplit: {
splitCellInto: (cols: number, rows: number) => ReturnType;
};
}
}
const MAX_SPLIT = 50;
function clampCount(n: number): number {
if (!Number.isFinite(n)) return 1;
return Math.max(1, Math.min(MAX_SPLIT, Math.round(n)));
}
// Contiguous group sizes splitting `total` into `parts` (first `rem` are one larger),
// returned as prefix offsets of length parts+1.
function groupOffsets(total: number, parts: number): number[] {
const base = Math.floor(total / parts);
const rem = total % parts;
const offsets = [0];
for (let i = 0; i < parts; i++) offsets.push(offsets[i] + base + (i < rem ? 1 : 0));
return offsets;
}
// Resolve the first row's proportional weights (nulls → average present, all-null → 1).
function resolveColWeights(table: PMNode, width: number): number[] {
const raw = columnWeightsFromRow(table.firstChild);
const present = raw.filter((w): w is number => w != null && w > 0);
const avg = present.length ? present.reduce((a, b) => a + b, 0) / present.length : 1;
const out: number[] = [];
for (let c = 0; c < width; c++) {
const w = raw[c];
out.push(w != null && w > 0 ? w : avg);
}
return out;
}
export function splitCellInto(colsArg: number, rowsArg: number) {
return ({ state, tr, dispatch }: CommandProps): boolean => {
if (!isInTable(state)) return false;
const cols = clampCount(colsArg);
const rows = clampCount(rowsArg);
const rect = selectedRect(state);
const { map, table, tableStart } = rect;
const W = map.width;
const H = map.height;
// ── Build the owner grid: owner[r][c] = index into `cells` (distinct cells). ──
const cells: { node: PMNode }[] = [];
const posToIdx = new Map<number, number>();
const owner: number[][] = [];
for (let r = 0; r < H; r++) {
const rowArr: number[] = [];
for (let c = 0; c < W; c++) {
const pos = map.map[r * W + c];
let idx = posToIdx.get(pos);
if (idx === undefined) {
idx = cells.length;
posToIdx.set(pos, idx);
cells.push({ node: table.nodeAt(pos)! });
}
rowArr.push(idx);
}
owner.push(rowArr);
}
// Selection region (single cell for a cursor; bounding box for a CellSelection).
const rTop = rect.top, rLeft = rect.left, rBottom = rect.bottom, rRight = rect.right;
// Refuse a region whose border cuts a spanning cell (mergeCells does the same):
// every distinct cell touching the region must be fully contained in it.
const cellBounds = new Map<number, { top: number; left: number; bottom: number; right: number }>();
for (let r = 0; r < H; r++) {
for (let c = 0; c < W; c++) {
const idx = owner[r][c];
const b = cellBounds.get(idx);
if (!b) cellBounds.set(idx, { top: r, left: c, bottom: r + 1, right: c + 1 });
else { b.bottom = Math.max(b.bottom, r + 1); b.right = Math.max(b.right, c + 1); }
}
}
for (let r = rTop; r < rBottom; r++) {
for (let c = rLeft; c < rRight; c++) {
const b = cellBounds.get(owner[r][c])!;
if (b.top < rTop || b.left < rLeft || b.bottom > rBottom || b.right > rRight) return false;
}
}
if (!dispatch) return true;
// Content for the first sub-cell: all cells in the region, in row-major origin
// order (a single cell normally; a multi-cell selection concatenates — merge).
const targetNode = cells[owner[rTop][rLeft]].node;
const combined: PMNode[] = [];
const seen = new Set<number>();
for (let r = rTop; r < rBottom; r++) {
for (let c = rLeft; c < rRight; c++) {
const idx = owner[r][c];
if (seen.has(idx)) continue;
seen.add(idx);
cells[idx].node.content.forEach((n) => combined.push(n));
}
}
// ── Expand the grid so the region is regionCols × regionRows. ──
const regionCols = Math.max(cols, rRight - rLeft);
const regionRows = Math.max(rows, rBottom - rTop);
const addCols = regionCols - (rRight - rLeft);
const addRows = regionRows - (rBottom - rTop);
const colWeights = resolveColWeights(table, W);
const rowSource = Array.from({ length: H }, (_, r) => r); // expanded row → original row
// Duplicate the region's right-edge column: cells crossing it grow (bridge).
if (addCols > 0) {
for (let r = 0; r < H; r++) {
const fill = Array(addCols).fill(owner[r][rRight - 1]);
owner[r].splice(rRight, 0, ...fill);
}
colWeights.splice(rRight, 0, ...Array(addCols).fill(colWeights[rRight - 1]));
}
const W2 = W + addCols;
// Duplicate the region's bottom-edge row: cells crossing it grow (bridge).
if (addRows > 0) {
const tmpl = owner[rBottom - 1];
for (let k = 0; k < addRows; k++) {
owner.splice(rBottom, 0, tmpl.slice());
rowSource.splice(rBottom, 0, rowSource[rBottom - 1]);
}
}
const H2 = H + addRows;
// ── Overwrite the region with cols×rows new sub-cells. ──
const colOff = groupOffsets(regionCols, cols);
const rowOff = groupOffsets(regionRows, rows);
let first = true;
const subContent = new Map<number, PMNode[] | null>(); // idx → content (null = empty)
for (let gj = 0; gj < rows; gj++) {
for (let gi = 0; gi < cols; gi++) {
const idx = cells.length;
cells.push({ node: targetNode }); // node only used for type/attrs of the cell
subContent.set(idx, first ? combined : null);
first = false;
const cStart = rLeft + colOff[gi], cEnd = rLeft + colOff[gi + 1];
const rStart = rTop + rowOff[gj], rEnd = rTop + rowOff[gj + 1];
for (let r = rStart; r < rEnd; r++)
for (let c = cStart; c < cEnd; c++) owner[r][c] = idx;
}
}
// Redistribute the region columns so each column-group sums to regionTotal/cols
// (equal sub-cells, table width preserved).
let regionTotal = 0;
for (let c = rLeft; c < rLeft + regionCols; c++) regionTotal += colWeights[c];
const perGroup = regionTotal / cols;
for (let gi = 0; gi < cols; gi++) {
const sz = colOff[gi + 1] - colOff[gi];
for (let c = rLeft + colOff[gi]; c < rLeft + colOff[gi + 1]; c++) colWeights[c] = perGroup / sz;
}
// Normalize to integer basis points (ratios only; matches the colwidth contract).
const wTotal = colWeights.reduce((a, b) => a + b, 0) || 1;
const intWeights = colWeights.map((w) => Math.max(1, Math.round((w / wTotal) * 10000)));
// ── Serialize the grid back into a table node. ──
const schema = state.schema;
const rowType = table.child(0).type;
const newRows: PMNode[] = [];
for (let r = 0; r < H2; r++) {
const cellNodes: PMNode[] = [];
for (let c = 0; c < W2; c++) {
const idx = owner[r][c];
const isOrigin = (r === 0 || owner[r - 1][c] !== idx) && (c === 0 || owner[r][c - 1] !== idx);
if (!isOrigin) continue;
let colspan = 1;
while (c + colspan < W2 && owner[r][c + colspan] === idx) colspan++;
let rowspan = 1;
while (r + rowspan < H2 && owner[r + rowspan][c] === idx) rowspan++;
const colwidth = intWeights.slice(c, c + colspan);
const src = cells[idx].node;
const attrs = { ...src.attrs, colspan, rowspan, colwidth };
if (subContent.has(idx)) {
const content = subContent.get(idx);
cellNodes.push(content && content.length ? src.type.create(attrs, content) : src.type.createAndFill(attrs)!);
} else {
cellNodes.push(src.type.create(attrs, src.content));
}
}
newRows.push(rowType.create(table.child(rowSource[r]).attrs, cellNodes));
}
const newTable = table.type.create(table.attrs, newRows);
const tablePos = tableStart - 1;
const tableEnd = tablePos + table.nodeSize;
tr.replaceWith(tablePos, tableEnd, newTable);
// Caret into the first sub-cell.
const newMap = TableMap.get(newTable);
const cellPos = tablePos + 1 + newMap.positionAt(rTop, rLeft, newTable);
tr.setSelection(TextSelection.near(tr.doc.resolve(cellPos + 1)));
dispatch(tr);
return true;
};
}
export const TableSplit = Extension.create({
name: 'tableSplit',
addCommands() {
return {
splitCellInto: (cols: number, rows: number) => splitCellInto(cols, rows),
};
},
});
+10 -2
View File
@@ -1,4 +1,4 @@
import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder, type TableBuilder, type RowBuilder, type CellBuilder, type HeaderFooterBuilder } from 'odf-kit';
import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder, type TableBuilder, type RowBuilder, type CellBuilder, type CellOptions, type HeaderFooterBuilder } from 'odf-kit';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
@@ -1095,9 +1095,17 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number,
// Emit the cell's runs (SEG-separated) and record its block descriptor.
// addCell runs synchronously, so the push is in document order — matching
// how applyCellBlocks later walks cells in content.xml.
// colSpan/rowSpan make odf-kit emit number-columns/rows-spanned and the
// <table:covered-table-cell> placeholders; those never match the
// applyCellBlocks cell regex, so cellBlocks stays one-per-real-cell aligned.
const opts: CellOptions = { padding: CELL_PADDING };
const colspan = (cell.attrs?.colspan as number) ?? 1;
const rowspan = (cell.attrs?.rowspan as number) ?? 1;
if (colspan > 1) opts.colSpan = colspan;
if (rowspan > 1) opts.rowSpan = rowspan;
r.addCell((c: CellBuilder) => {
cellBlocks.push(buildCellContent(cell, c));
}, { padding: CELL_PADDING });
}, opts);
}
});
}
+43 -1
View File
@@ -2,7 +2,7 @@
// plus a hand-crafted LibreOffice/Word-style .odt exercising the style resolver.
// jsdom (vitest `environment`) supplies the global DOMParser.
import { describe, it, expect } from 'vitest';
import { zipSync, strToU8 } from 'fflate';
import { zipSync, strToU8, unzipSync, strFromU8 } from 'fflate';
import { getSchema } from '@tiptap/core';
import { Node as PMNode } from '@tiptap/pm/model';
import { buildOdt } from '../src/lib/export/odt';
@@ -23,6 +23,9 @@ const H = (attrs: N, ...content: N[]): N => ({ type: 'heading', attrs, ...(conte
const LI = (...content: N[]): N => ({ type: 'listItem', content });
const CELL = (colwidth: number[] | null, ...content: N[]): N =>
({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth }, content });
const CELLM = (colspan: number, rowspan: number, colwidth: number[] | null, text: string): N =>
({ type: 'tableCell', attrs: { colspan, rowspan, colwidth }, content: [P(null, T(text))] });
const ROW = (...cells: N[]): N => ({ type: 'tableRow', content: cells });
// A tiny valid PNG; only its bytes matter for the round-trip (no image decoding).
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
@@ -236,6 +239,45 @@ describe('Leg 1: editor → buildOdt → importOdt', () => {
});
});
describe('Leg 1b: merged table cells (colspan/rowspan)', () => {
// 3×3 grid: A spans 2 cols (row 0); C spans 2 rows (col 0, rows 1–2).
// row0: [A A][B] row1: [C][D][E] row2: [C][F][G]
const mergedDoc: N = {
type: 'doc',
content: [
P(null, T('Merged cells:')),
{ type: 'table', content: [
ROW(CELLM(2, 1, [100, 100], 'A'), CELLM(1, 1, [100], 'B')),
ROW(CELLM(1, 2, [100], 'C'), CELLM(1, 1, [100], 'D'), CELLM(1, 1, [100], 'E')),
ROW(CELLM(1, 1, [100], 'F'), CELLM(1, 1, [100], 'G')),
] },
],
};
it('exports spans + covered cells and re-imports them', async () => {
const bytes = await buildOdt(mergedDoc, margins, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
check('content.xml emits number-columns-spanned=2', content.includes('table:number-columns-spanned="2"'));
check('content.xml emits number-rows-spanned=2', content.includes('table:number-rows-spanned="2"'));
check('content.xml emits a covered-table-cell', content.includes('<table:covered-table-cell'));
const res = importOdt(bytes);
check('no warnings on own export', res.warnings.length === 0, res.warnings);
const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
const rows = table?.content ?? [];
check('row 0 has 2 cells', rows[0]?.content?.length === 2, rows[0]?.content?.length);
check('row 1 has 3 cells', rows[1]?.content?.length === 3, rows[1]?.content?.length);
check('row 2 has 2 cells (covered slot skipped)', rows[2]?.content?.length === 2, rows[2]?.content?.length);
check('A has colspan 2', rows[0]?.content?.[0]?.attrs?.colspan === 2, rows[0]?.content?.[0]?.attrs);
check('C has rowspan 2', rows[1]?.content?.[0]?.attrs?.rowspan === 2, rows[1]?.content?.[0]?.attrs);
const textOf = (cell: N) => cell?.content?.[0]?.content?.[0]?.text;
check('A text preserved', textOf(rows[0]?.content?.[0]) === 'A', textOf(rows[0]?.content?.[0]));
check('F at col 1 of row 2', textOf(rows[2]?.content?.[0]) === 'F', textOf(rows[2]?.content?.[0]));
check('G at col 2 of row 2', textOf(rows[2]?.content?.[1]) === 'G', textOf(rows[2]?.content?.[1]));
});
});
describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
it('resolves named/automatic styles, repeated cells, lists, and reports degradations', () => {
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
+95
View File
@@ -0,0 +1,95 @@
// Unit test for the custom splitCellInto command: drives it against a ProseMirror
// EditorState (no DOM/Editor needed) and inspects the rebuilt table. Covers the
// two Word/LibreOffice cases: splitting a plain cell (adds grid lines + bridges
// neighbours) and splitting a merged cell (un-merge).
import { describe, it, expect } from 'vitest';
import { getSchema } from '@tiptap/core';
import { EditorState, TextSelection } from '@tiptap/pm/state';
import type { Node as PMNode } from '@tiptap/pm/model';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
import { splitCellInto } from '../../src/lib/editor/extensions/tableSplit';
type N = any;
const schema = getSchema([Document, Paragraph, Text, Table, ResizableTableRow, TableCell, TableHeader]);
const cell = (text: string, colspan = 1, rowspan = 1): N => ({
type: 'tableCell',
attrs: { colspan, rowspan, colwidth: null },
content: [{ type: 'paragraph', content: text ? [{ type: 'text', text }] : [] }],
});
const row = (...cells: N[]): N => ({ type: 'tableRow', content: cells });
const docJSON = (...rows: N[]): N => ({ type: 'doc', content: [{ type: 'table', content: rows }] });
// Run splitCellInto(cols,rows) with the cursor in the cell containing `targetText`.
function runSplit(json: N, targetText: string, cols: number, rows: number): N {
const doc = schema.nodeFromJSON(json);
let textPos = -1;
doc.descendants((node: PMNode, pos: number) => {
if (node.isText && node.text === targetText) textPos = pos;
});
if (textPos < 0) throw new Error(`target text ${targetText} not found`);
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, textPos) });
let next: EditorState | null = null;
const ok = splitCellInto(cols, rows)({
state,
tr: state.tr,
dispatch: (tr) => { next = state.apply(tr); },
} as any);
expect(ok).toBe(true);
return next!.doc.toJSON().content[0]; // the table node
}
const cellText = (c: N): string | undefined => c?.content?.[0]?.content?.[0]?.text;
describe('splitCellInto', () => {
it('splits a plain cell into 2×2, bridging the neighbouring cells', () => {
// 2×2 grid; split the top-left cell ("A") into 2 cols × 2 rows.
const table = runSplit(
docJSON(row(cell('A'), cell('B')), row(cell('C'), cell('D'))),
'A', 2, 2,
);
const rows = table.content;
expect(rows.length).toBe(3);
// row0: [A][new][B rowspan2]
expect(rows[0].content.length).toBe(3);
expect(cellText(rows[0].content[0])).toBe('A');
expect(rows[0].content[2].attrs.rowspan).toBe(2); // top-right bridges down
expect(cellText(rows[0].content[2])).toBe('B');
// row1: the 2 new sub-cells (col2 covered by B's rowspan)
expect(rows[1].content.length).toBe(2);
// row2: [C colspan2][D]
expect(rows[2].content.length).toBe(2);
expect(rows[2].content[0].attrs.colspan).toBe(2); // bottom-left bridges right
expect(cellText(rows[2].content[0])).toBe('C');
expect(cellText(rows[2].content[1])).toBe('D');
});
it('un-merges a merged cell (colspan 2 → two cells)', () => {
// Row0 is one merged cell spanning 2 cols; row1 has two cells.
const table = runSplit(
docJSON(row(cell('M', 2, 1)), row(cell('X'), cell('Y'))),
'M', 2, 1,
);
const rows = table.content;
expect(rows.length).toBe(2);
expect(rows[0].content.length).toBe(2); // M split back into two
expect(rows[0].content[0].attrs.colspan).toBe(1);
expect(rows[0].content[1].attrs.colspan).toBe(1);
expect(cellText(rows[0].content[0])).toBe('M'); // content kept in the first
});
it('splits a single cell into 3 columns', () => {
const table = runSplit(docJSON(row(cell('A'), cell('B'))), 'A', 3, 1);
const rows = table.content;
expect(rows.length).toBe(1);
// A → 3 sub-cells, plus B (now bridged): 4 cells across a 4-col grid.
expect(rows[0].content.length).toBe(4);
expect(cellText(rows[0].content[0])).toBe('A');
expect(cellText(rows[0].content[3])).toBe('B');
});
});