From e2f522854fc4691b31902b13d603e424481d722a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 6 Jun 2026 12:22:45 +0200 Subject: [PATCH] feat: tables --- CHANGELOG.md | 3 +- package-lock.json | 15 ++ package.json | 1 + src/lib/editor/TablePicker.svelte | 292 ++++++++++++++++++++++++++ src/lib/editor/ToolbarExpanded.svelte | 36 +++- src/lib/editor/extensions.ts | 8 + src/lib/editor/pageBreaks.ts | 13 +- src/lib/export/odt.ts | 69 +++++- src/styles/editor.css | 48 +++++ 9 files changed, 465 insertions(+), 20 deletions(-) create mode 100644 src/lib/editor/TablePicker.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b6ea1..9c088ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,11 +34,12 @@ - show formatting marks - margins - page orientation +- tables ### Not yet implemented - ODT import / open existing file - undo history -- Images, tables +- Images - Custom right-click context menu (formatting, cut/copy) - Multi-document management - PWA / offline support diff --git a/package-lock.json b/package-lock.json index eac2f85..efe0d45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@tiptap/extension-ordered-list": "^3.23.6", "@tiptap/extension-paragraph": "^3.23.6", "@tiptap/extension-placeholder": "^3.23.6", + "@tiptap/extension-table": "3.23.6", "@tiptap/extension-text": "^3.23.6", "@tiptap/extension-text-align": "^3.23.6", "@tiptap/extension-text-style": "^3.23.6", @@ -627,6 +628,20 @@ "@tiptap/extensions": "3.23.6" } }, + "node_modules/@tiptap/extension-table": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.23.6.tgz", + "integrity": "sha512-XbhZXjhsS6AP7ThoZxjAnNs+NiR81YRori25l6E+ORqB7quiPkIXOAi5h4AIpkn/CYIqze6ere11lWsYpDjtaQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6", + "@tiptap/pm": "3.23.6" + } + }, "node_modules/@tiptap/extension-text": { "version": "3.23.6", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.6.tgz", diff --git a/package.json b/package.json index e7468de..5f2c328 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@tiptap/extension-ordered-list": "^3.23.6", "@tiptap/extension-paragraph": "^3.23.6", "@tiptap/extension-placeholder": "^3.23.6", + "@tiptap/extension-table": "3.23.6", "@tiptap/extension-text": "^3.23.6", "@tiptap/extension-text-align": "^3.23.6", "@tiptap/extension-text-style": "^3.23.6", diff --git a/src/lib/editor/TablePicker.svelte b/src/lib/editor/TablePicker.svelte new file mode 100644 index 0000000..adad967 --- /dev/null +++ b/src/lib/editor/TablePicker.svelte @@ -0,0 +1,292 @@ + + +
+ + + {#if open} +
+
+ {#if hoverRows > 0 && hoverCols > 0} + {hoverCols} × {hoverRows} + {:else} + Insert table + {/if} +
+ + +
{ hoverRows = 0; hoverCols = 0; }} + > + {#each ROW_INDICES as r} +
+ {#each COL_INDICES as c} + + {/each} +
+ {/each} +
+ +
+ + + +
+
+ {/if} +
+ + diff --git a/src/lib/editor/ToolbarExpanded.svelte b/src/lib/editor/ToolbarExpanded.svelte index a6c7518..534947a 100644 --- a/src/lib/editor/ToolbarExpanded.svelte +++ b/src/lib/editor/ToolbarExpanded.svelte @@ -2,6 +2,7 @@ import type { Editor } from '@tiptap/core'; import { onMount } from 'svelte'; import ColorPicker from './ColorPicker.svelte'; + import TablePicker from './TablePicker.svelte'; import { CANDIDATE_FONTS, detectAvailableFonts, @@ -624,6 +625,26 @@ if (which === 'font') highlightColorOpen = false; else fontColorOpen = false; } + + // --- Table insertion (TablePicker.svelte) --- + let tableOpen = $state(false); + + // Close the sibling dropdowns when the table picker opens (mirrors openLayout). + function onTablePickerOpen() { + fontOpen = false; + sizeOpen = false; + sizeInputFocused = false; + lineHeightOpen = false; + alignOpen = false; + fontColorOpen = false; + highlightColorOpen = false; + layoutOpen = false; + } + + function insertTable(rows: number, cols: number, range: { from: number; to: number }) { + if (!editor) return; + editor.chain().focus().setTextSelection(range).insertTable({ rows, cols, withHeaderRow: false }).run(); + }
@@ -1017,15 +1038,12 @@
- +
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index bed0d4b..993480d 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -12,6 +12,7 @@ import Heading from '@tiptap/extension-heading'; import BulletList from '@tiptap/extension-bullet-list'; import OrderedList from '@tiptap/extension-ordered-list'; import ListItem from '@tiptap/extension-list-item'; +import { Table, TableRow, TableHeader, TableCell } from '@tiptap/extension-table'; import History from '@tiptap/extension-history'; import Placeholder from '@tiptap/extension-placeholder'; import TextAlign from '@tiptap/extension-text-align'; @@ -43,6 +44,13 @@ export const extensions = [ BulletList, OrderedList, ListItem, + // resizable:false → equal-width, full-width columns. Kept non-resizable so the + // editor's column layout matches the ODT export (odf-kit distributes columns + // evenly when no column widths are emitted — see export/odt.ts CUST_TABLE path). + Table.configure({ resizable: false }), + TableRow, + TableHeader, + TableCell, History, Placeholder.configure({ placeholder: 'Start typing…' }), TextAlign.configure({ diff --git a/src/lib/editor/pageBreaks.ts b/src/lib/editor/pageBreaks.ts index 488591d..397f614 100644 --- a/src/lib/editor/pageBreaks.ts +++ b/src/lib/editor/pageBreaks.ts @@ -114,7 +114,10 @@ type Leaf = { naturalHeight: number; }; -const ATOMIC_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']); +// TABLE is atomic: a table that overflows the page is pushed whole to the next +// page (we don't split a table across pages). A table taller than one page will +// overflow visually — an accepted limitation of this version. +const ATOMIC_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TABLE']); const SPLITTABLE_TAGS = new Set(['P']); const CONTAINER_TAGS = new Set(['UL', 'OL', 'LI', 'BLOCKQUOTE']); @@ -376,7 +379,13 @@ export const PageBreaks = Extension.create({ continue; } const tag = child.tagName; - const isAtomic = ATOMIC_TAGS.has(tag); + // TipTap renders tables inside a
node + // view (see extensions.ts Table config), so the top-level child is + // the wrapper DIV, not the TABLE. Treat either as an atomic table. + const isTable = + tag === 'TABLE' || + (tag === 'DIV' && child.classList.contains('tableWrapper')); + const isAtomic = ATOMIC_TAGS.has(tag) || isTable; const isSplittable = SPLITTABLE_TAGS.has(tag); if (isAtomic || isSplittable) { const rect = child.getBoundingClientRect(); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index cc50185..52e3885 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -1,5 +1,5 @@ import type { Editor } from '@tiptap/core'; -import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder } from 'odf-kit'; +import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder, type TableBuilder, type RowBuilder, type CellBuilder } from 'odf-kit'; import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate'; import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; @@ -12,6 +12,16 @@ type AlignValue = 'left' | 'center' | 'right' | 'justify'; const CUST_P = '__cust_p__'; const CUST_H = '__cust_h__'; +// Tables are renamed to this so odf-kit routes them to unknownNodeHandler. Its +// native table path (walkTable) calls addTable with no options, emitting cells +// with no border — invisible in LibreOffice/Word. We build the table ourselves +// and pass an explicit border instead. +const CUST_TABLE = '__cust_table__'; + +// Table export styling. Values mirror the editor's table CSS (src/styles/editor.css) +// so the .odt matches the on-screen preview. +const TABLE_BORDER = '0.5pt solid #000000'; +const CELL_PADDING = '0.1cm'; // must match td/th padding in editor.css // odf-kit emits this as the document's default font (Standard style). The editor // renders the bundled, metric-identical Liberation Serif on screen. @@ -41,19 +51,30 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean { return ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify'; } -function injectCustomTypes(node: TiptapNode, inList = false): TiptapNode { - // Don't rename paragraphs inside list items — tiptapToOdt walks them by - // type === "paragraph" to build list content; renaming breaks that. - if (!inList && hasCustomAttrs(node.attrs)) { +function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode { + // Rename tables so odf-kit routes them to our unknownNodeHandler (see + // CUST_TABLE). Recurse with inContainer=true so the cell paragraphs are NOT + // renamed — our table handler walks them by type === "paragraph". + if (node.type === 'table') { + return { + ...node, + type: CUST_TABLE, + content: node.content?.map(c => injectCustomTypes(c, true)), + }; + } + // Don't rename paragraphs inside list items or table cells — tiptapToOdt's list + // builder (and our table handler) walk them by type === "paragraph"; renaming + // breaks that. + if (!inContainer && hasCustomAttrs(node.attrs)) { if (node.type === 'paragraph') return { ...node, type: CUST_P }; if (node.type === 'heading') return { ...node, type: CUST_H }; } if (node.content?.length) { - const childInList = inList + const childInContainer = inContainer || node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'listItem'; - return { ...node, content: node.content.map(c => injectCustomTypes(c, childInList)) }; + return { ...node, content: node.content.map(c => injectCustomTypes(c, childInContainer)) }; } return node; } @@ -260,7 +281,7 @@ function normalizeColor(input: string): string | undefined { return s; } -function applyRuns(p: ParagraphBuilder, content: TiptapNode[] = []) { +function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = []) { for (const node of content) { if (node.type !== 'text' || !node.text) continue; const marks = node.marks ?? []; @@ -292,6 +313,34 @@ function applyRuns(p: ParagraphBuilder, content: TiptapNode[] = []) { } } +// Build an ODF table from a CUST_TABLE node (renamed from "table" in +// injectCustomTypes). We bypass odf-kit's native walkTable so we can pass an +// explicit cell border — the native path emits none, so the table would be +// invisible in LibreOffice/Word. No columnWidths are passed, so odf-kit +// distributes columns evenly, matching the editor's equal-width fixed layout. +function exportTable(node: TiptapNode, doc: OdtDocument): void { + const rows = (node.content ?? []).filter(r => r.type === 'tableRow'); + if (rows.length === 0) return; + doc.addTable((t: TableBuilder) => { + for (const row of rows) { + t.addRow((r: RowBuilder) => { + for (const cell of row.content ?? []) { + if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue; + const paras = (cell.content ?? []).filter(c => c.type === 'paragraph'); + r.addCell((c: CellBuilder) => { + // Multiple cell paragraphs are joined with a space, matching + // odf-kit's own walkTable behavior. + paras.forEach((para, i) => { + if (i > 0) c.addText(' '); + applyRuns(c, para.content); + }); + }, { padding: CELL_PADDING }); + } + }); + } + }, { border: TABLE_BORDER }); +} + // odf-kit's XML builder serializes a multi-child element's children on separate // lines, joined by "\n" (core/xml.js). Inside a / that newline // is significant character data, so LibreOffice/Word collapse it to a space — @@ -333,6 +382,10 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT marginLeft: `${margins.left}cm`, marginRight: `${margins.right}cm`, unknownNodeHandler(node: TiptapNode, doc: OdtDocument) { + if (node.type === CUST_TABLE) { + exportTable(node, doc); + return; + } const opts: { lineHeight?: number | string; align?: AlignValue; diff --git a/src/styles/editor.css b/src/styles/editor.css index 5fe3299..6801c96 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -154,6 +154,54 @@ margin-bottom: 0; } +/* Tables. The visual border/padding must match the ODT export so the editor + and the exported .odt look identical (see export/odt.ts CUST_TABLE handler): + • border → export emits 0.5pt solid #000000; 1px solid renders crisply + on screen and is the visual equivalent of a thin 0.5pt line. + • padding → export emits fo:padding 0.1cm per cell (CELL_PADDING). + Tables are wrapped in
by TipTap's node view. */ +.paper .tiptap .tableWrapper { + margin: 0 0 0.212cm; + overflow-x: auto; +} + +.paper .tiptap table { + width: 100%; + /* fixed layout + width:100% → equal-width columns regardless of content, + matching odf-kit's even column distribution on export. */ + table-layout: fixed; + border-collapse: collapse; +} + +.paper .tiptap td, +.paper .tiptap th { + border: 1px solid #000; + padding: 0.1cm; + vertical-align: top; + /* Cells hold paragraphs; suppress their bottom margin so a single-line cell + isn't taller than its text (matches the list-item paragraph treatment). */ +} + +.paper .tiptap td > p, +.paper .tiptap th > p { + margin-bottom: 0; +} + +/* Selected-cell highlight while editing (ProseMirror tables add .selectedCell). + A translucent overlay via the ::after avoids disturbing the cell border. */ +.paper .tiptap .selectedCell { + position: relative; +} + +.paper .tiptap .selectedCell::after { + content: ''; + position: absolute; + inset: 0; + background: var(--color-primary); + opacity: 0.12; + pointer-events: none; +} + .paper .tiptap p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left;