feat: table cell splitting/merging
This commit is contained in:
1 parent
14458b3103
commit
ec678ed495
9 files changed
+603
-8
No files matched your search
+2
-2
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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"?>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user