dorfteich/apps/web/src/editor/spec-utils.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

62 lines
2.6 KiB
TypeScript

import { editorSchema } from '@dorfteich/shared';
import type { Attributes, NodeConfig } from '@tiptap/core';
import type { MarkSpec, NodeSpec } from 'prosemirror-model';
/**
* Bridges the canonical ProseMirror schema (`packages/shared/editor-schema`,
* issue #24) into TipTap node/mark extensions (issue #25). TipTap always
* builds its own `Schema` instance from the extensions handed to `useEditor`
* — it cannot take a premade `Schema` — so this module re-derives every
* node/mark spec from `editorSchema` instead of duplicating attrs/parseDOM/
* toDOM by hand. That keeps the editor's schema byte-for-byte identical to
* what the api decodes Yjs states against (`apps/api/src/pages/yjs-content.ts`);
* see the schema-fidelity test in `schema-extensions.test.ts`.
*/
export function nodeSpec(name: string): NodeSpec {
const spec = editorSchema.spec.nodes.get(name);
if (!spec) throw new Error(`editorSchema has no node "${name}"`);
return spec;
}
export function markSpec(name: string): MarkSpec {
const spec = editorSchema.spec.marks.get(name);
if (!spec) throw new Error(`editorSchema has no mark "${name}"`);
return spec;
}
/** Converts a ProseMirror `attrs` map into TipTap's `addAttributes()` shape,
* preserving "no default" (required attribute) instead of defaulting to
* `undefined`, so e.g. `image.fileId` stays a required attribute. */
export function attributesFromSpec(spec: NodeSpec | MarkSpec): Attributes {
const attrs = spec.attrs ?? {};
const result: Attributes = {};
for (const name of Object.keys(attrs)) {
const attr = attrs[name]!;
result[name] =
'default' in attr
? { default: attr.default, validate: attr.validate }
: { isRequired: true, validate: attr.validate };
}
return result;
}
/** `parseHTML()`/`renderHTML()` for nodes that need no extra TipTap-side
* behavior (no custom commands, NodeView, or attrs beyond the spec). */
export function passthroughNodeIO(spec: NodeSpec): Pick<NodeConfig, 'parseHTML' | 'renderHTML'> {
return {
parseHTML: spec.parseDOM ? () => spec.parseDOM : undefined,
renderHTML: spec.toDOM ? ({ node }) => spec.toDOM!(node) : undefined,
};
}
/** Marks a table node's built schema with the `tableRole` prosemirror-tables
* needs (selection/keymap/commands key off this, not the node name), without
* having to teach TipTap's schema builder a new field for every node. */
export function extendWithTableRole(name: string, tableRole: string) {
return {
extendNodeSchema: (extension: { name: string }) =>
extension.name === name ? { tableRole } : {},
};
}