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;