dorfteich/apps/web/src/editor/nodes/table.ts
Claude Sonnet 5 076883a9a6
All checks were successful
CD / Build and push images (push) Successful in 2m0s
CI / Lint, typecheck, test (push) Successful in 1m42s
CI / Auth e2e pack (push) Successful in 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
Add TipTap page editor with REST persistence (#25)
TipTap is bound to the canonical ProseMirror schema (packages/shared,
#24) via a generic bridge (spec-utils.ts) that re-derives every
node/mark's attrs/parseDOM/toDOM from editorSchema instead of
duplicating them, so the editor's schema stays byte-for-byte identical
to what the api decodes Yjs states against — guarded by a schema-
fidelity + real Yjs round-trip test (@tiptap/y-tiptap client encoding
against y-prosemirror server decoding).

Route /p/:pondSlug/:pageSlug (RequireAuth) resolves the page via a new
GET /ponds/:pondId/pages/:slug endpoint, binds a local Y.Doc via
@tiptap/extension-collaboration (fragment "default"), and offers a
view/edit mode toggle (sidebar auto-hides in edit mode via a small
AppLayout context). Page state saves debounced to PUT /pages/:id/state
with a truthful saving/saved/error(retrying) indicator; title saves
separately via PATCH /pages/:id.

Toolbar covers headings, marks, lists, blockquote, code block, hr,
table (insert/row/column/header ops via prosemirror-tables), a minimal
link mark, and an image placeholder (real upload is #27/#28).

Closes #25

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 11:08:43 +02:00

143 lines
4.0 KiB
TypeScript

import { Node } from '@tiptap/core';
import type { Node as PMNode, Schema } from 'prosemirror-model';
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
deleteColumn,
deleteRow,
deleteTable,
tableEditing,
toggleHeaderRow,
} from 'prosemirror-tables';
import {
attributesFromSpec,
extendWithTableRole,
nodeSpec,
passthroughNodeIO,
} from '../spec-utils';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
documentTable: {
insertTable: (options?: {
rows?: number;
cols?: number;
withHeaderRow?: boolean;
}) => ReturnType;
addColumnBefore: () => ReturnType;
addColumnAfter: () => ReturnType;
deleteColumn: () => ReturnType;
addRowBefore: () => ReturnType;
addRowAfter: () => ReturnType;
deleteRow: () => ReturnType;
deleteTable: () => ReturnType;
toggleHeaderRow: () => ReturnType;
};
}
}
/** `type.createAndFill()` picks a default child (an empty paragraph) that
* satisfies the cell's `block+` content expression. */
function buildTableRow(schema: Schema, cols: number, header: boolean): PMNode {
const cellType = schema.nodes[header ? 'table_header' : 'table_cell']!;
const cells = Array.from({ length: cols }, () => cellType.createAndFill()!);
return schema.nodes.table_row!.create(null, cells);
}
function buildTable(schema: Schema, rows: number, cols: number, withHeaderRow: boolean): PMNode {
const rowNodes = Array.from({ length: rows }, (_, index) =>
buildTableRow(schema, cols, withHeaderRow && index === 0),
);
return schema.nodes.table!.create(null, rowNodes);
}
const tableSpec = nodeSpec('table');
export const Table = Node.create({
name: 'table',
group: tableSpec.group,
content: tableSpec.content,
isolating: tableSpec.isolating,
...passthroughNodeIO(tableSpec),
...extendWithTableRole('table', 'table'),
addProseMirrorPlugins() {
return [tableEditing()];
},
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
({ chain, editor }) =>
chain()
.insertContent(buildTable(editor.schema, rows, cols, withHeaderRow).toJSON())
.run(),
addColumnBefore:
() =>
({ state, dispatch }) =>
addColumnBefore(state, dispatch),
addColumnAfter:
() =>
({ state, dispatch }) =>
addColumnAfter(state, dispatch),
deleteColumn:
() =>
({ state, dispatch }) =>
deleteColumn(state, dispatch),
addRowBefore:
() =>
({ state, dispatch }) =>
addRowBefore(state, dispatch),
addRowAfter:
() =>
({ state, dispatch }) =>
addRowAfter(state, dispatch),
deleteRow:
() =>
({ state, dispatch }) =>
deleteRow(state, dispatch),
deleteTable:
() =>
({ state, dispatch }) =>
deleteTable(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) =>
toggleHeaderRow(state, dispatch),
};
},
});
const tableRowSpec = nodeSpec('table_row');
export const TableRow = Node.create({
name: 'table_row',
content: tableRowSpec.content,
...passthroughNodeIO(tableRowSpec),
...extendWithTableRole('table_row', 'row'),
});
const tableCellSpec = nodeSpec('table_cell');
export const TableCell = Node.create({
name: 'table_cell',
content: tableCellSpec.content,
isolating: tableCellSpec.isolating,
addAttributes() {
return attributesFromSpec(tableCellSpec);
},
...passthroughNodeIO(tableCellSpec),
...extendWithTableRole('table_cell', 'cell'),
});
const tableHeaderSpec = nodeSpec('table_header');
export const TableHeader = Node.create({
name: 'table_header',
content: tableHeaderSpec.content,
isolating: tableHeaderSpec.isolating,
addAttributes() {
return attributesFromSpec(tableHeaderSpec);
},
...passthroughNodeIO(tableHeaderSpec),
...extendWithTableRole('table_header', 'header_cell'),
});