feat: tables

This commit is contained in:
Steffen Becker committed 2026-06-06 12:22:45 +02:00
1 parent 355f12afd7
commit e2f522854f
9 files changed
+465 -20

No files matched your search

+2 -1
View File
@@ -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
+15
View File
@@ -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",
+1
View File
@@ -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",
+292
View File
@@ -0,0 +1,292 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
let {
editor,
open = $bindable(false),
onOpen,
onInsert,
}: {
editor: Editor | null;
open?: boolean;
onOpen?: () => void;
// Mirrors ColorPicker's onApply: the parent runs the actual editor command
// against the saved selection range (focus is stolen by the popover).
onInsert: (rows: number, cols: number, range: { from: number; to: number }) => void;
} = $props();
// Quick-pick grid size (like Word/LibreOffice). Hovering selects the top-left
// rows×cols block; clicking inserts it.
const GRID_ROWS = 8;
const GRID_COLS = 10;
const ROW_INDICES = Array.from({ length: GRID_ROWS }, (_, i) => i + 1);
const COL_INDICES = Array.from({ length: GRID_COLS }, (_, i) => i + 1);
// Manual-entry bounds for the row/column number inputs.
const MAX_ROWS = 50;
const MAX_COLS = 20;
let hoverRows = $state(0);
let hoverCols = $state(0);
let rowsInput = $state('3');
let colsInput = $state('3');
let savedFrom: number | null = null;
let savedTo: number | null = null;
function clamp(n: number, min: number, max: number): number {
if (!Number.isFinite(n)) return min;
return Math.max(min, Math.min(max, Math.round(n)));
}
function openPicker() {
if (!editor) return;
onOpen?.();
savedFrom = editor.state.selection.from;
savedTo = editor.state.selection.to;
if (open) {
open = false;
} else {
hoverRows = 0;
hoverCols = 0;
open = true;
}
}
function insert(rows: number, cols: number) {
if (!editor) return;
open = false;
const from = savedFrom ?? editor.state.selection.from;
const to = savedTo ?? editor.state.selection.to;
savedFrom = null;
savedTo = null;
onInsert(rows, cols, { from, to });
}
function insertFromInputs() {
insert(clamp(parseInt(rowsInput, 10), 1, MAX_ROWS), clamp(parseInt(colsInput, 10), 1, MAX_COLS));
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
insertFromInputs();
}
}
function tablePickerClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) open = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
</script>
<div class="table-picker" use:tablePickerClickOutside>
<button class="table-trigger" onclick={openPicker} title="Insert table" aria-pressed={open}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1.5" y="1.5" width="13" height="13" rx="1" stroke="currentColor" stroke-width="1.5"/>
<line x1="1.5" y1="5.5" x2="14.5" y2="5.5" stroke="currentColor" stroke-width="1"/>
<line x1="1.5" y1="9.5" x2="14.5" y2="9.5" stroke="currentColor" stroke-width="1"/>
<line x1="5.5" y1="5.5" x2="5.5" y2="14.5" stroke="currentColor" stroke-width="1"/>
<line x1="10.5" y1="5.5" x2="10.5" y2="14.5" stroke="currentColor" stroke-width="1"/>
</svg>
</button>
{#if open}
<div class="table-dropdown">
<div class="table-dim-label">
{#if hoverRows > 0 && hoverCols > 0}
{hoverCols} × {hoverRows}
{:else}
Insert table
{/if}
</div>
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<div
class="table-grid"
role="grid"
tabindex="-1"
aria-label="Select table size"
onmouseleave={() => { hoverRows = 0; hoverCols = 0; }}
>
{#each ROW_INDICES as r}
<div class="table-grid-row" role="row">
{#each COL_INDICES as c}
<button
class="table-grid-cell"
class:hot={r <= hoverRows && c <= hoverCols}
role="gridcell"
aria-label={`${c} by ${r}`}
onmouseover={() => { hoverRows = r; hoverCols = c; }}
onfocus={() => { hoverRows = r; hoverCols = c; }}
onclick={() => insert(r, c)}
></button>
{/each}
</div>
{/each}
</div>
<div class="table-manual">
<label class="table-manual-field">
<span>Rows</span>
<input
type="text"
inputmode="numeric"
bind:value={rowsInput}
onkeydown={onInputKeydown}
aria-label="Number of rows"
/>
</label>
<label class="table-manual-field">
<span>Cols</span>
<input
type="text"
inputmode="numeric"
bind:value={colsInput}
onkeydown={onInputKeydown}
aria-label="Number of columns"
/>
</label>
<button class="table-insert-btn" onclick={insertFromInputs}>Insert</button>
</div>
</div>
{/if}
</div>
<style>
.table-picker {
position: relative;
}
.table-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.15s;
}
.table-trigger:hover {
background: var(--color-btn-hover);
}
.table-trigger[aria-pressed='true'] {
background: var(--color-primary);
color: white;
}
.table-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 200;
padding: 8px;
/* Anchor near the right edge of the toolbar — keep it from spilling off-screen. */
right: 0;
left: auto;
}
.table-dim-label {
font-size: 0.78rem;
font-family: var(--font-sans);
color: var(--color-text);
text-align: center;
margin-bottom: 6px;
user-select: none;
}
.table-grid {
display: flex;
flex-direction: column;
gap: 2px;
}
.table-grid-row {
display: flex;
gap: 2px;
}
.table-grid-cell {
width: 16px;
height: 16px;
min-width: unset;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 2px;
background: var(--color-surface);
cursor: pointer;
}
.table-grid-cell.hot {
border-color: var(--color-primary);
background: var(--color-primary);
}
.table-manual {
display: flex;
align-items: flex-end;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-border);
}
.table-manual-field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 0.7rem;
font-family: var(--font-sans);
color: var(--color-text-muted);
}
.table-manual-field input {
width: 44px;
height: 1.8rem;
padding: 0 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
font-size: 0.8rem;
font-family: var(--font-sans);
outline: none;
transition: border-color 0.15s;
}
.table-manual-field input:hover,
.table-manual-field input:focus {
border-color: var(--color-primary);
}
.table-insert-btn {
height: 1.8rem;
padding: 0 0.7rem;
min-width: unset;
border: 1px solid var(--color-primary);
border-radius: var(--radius);
background: var(--color-primary);
color: white;
font-size: 0.78rem;
font-family: var(--font-sans);
cursor: pointer;
}
.table-insert-btn:hover {
opacity: 0.9;
}
</style>
+27 -9
View File
@@ -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();
}
</script>
<div class="toolbar-expanded">
@@ -1017,15 +1038,12 @@
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button disabled title="Insert table (coming soon)">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1.5" y="1.5" width="13" height="13" rx="1" stroke="currentColor" stroke-width="1.5"/>
<line x1="1.5" y1="5.5" x2="14.5" y2="5.5" stroke="currentColor" stroke-width="1"/>
<line x1="1.5" y1="9.5" x2="14.5" y2="9.5" stroke="currentColor" stroke-width="1"/>
<line x1="5.5" y1="5.5" x2="5.5" y2="14.5" stroke="currentColor" stroke-width="1"/>
<line x1="10.5" y1="5.5" x2="10.5" y2="14.5" stroke="currentColor" stroke-width="1"/>
</svg>
</button>
<TablePicker
{editor}
bind:open={tableOpen}
onOpen={onTablePickerOpen}
onInsert={insertTable}
/>
</div>
<div class="toolbar-separator"></div>
+8
View File
@@ -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({
+11 -2
View File
@@ -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 <div class="tableWrapper"> 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();
+61 -8
View File
@@ -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 <text:p>/<text:h> 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;
+48
View File
@@ -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 <div class="tableWrapper"> 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;