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
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>
141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { docToMarkdown, docToPlainText, editorSchema } from '@dorfteich/shared';
|
|
import { Editor, getSchema } from '@tiptap/core';
|
|
import { Collaboration } from '@tiptap/extension-collaboration';
|
|
import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
|
|
import { describe, expect, it } from 'vitest';
|
|
import * as Y from 'yjs';
|
|
|
|
import { documentExtensions } from './document-extensions';
|
|
|
|
/**
|
|
* `editorSchema` (packages/shared, #24) is the one document schema the api
|
|
* decodes Yjs states against (`apps/api/src/pages/yjs-content.ts`, #23). The
|
|
* TipTap editor (#25) cannot reuse that `Schema` instance directly — TipTap
|
|
* always builds its own from extensions — so these tests guard the bridge in
|
|
* `spec-utils.ts`/`nodes/*`/`marks.ts` against silent drift.
|
|
*/
|
|
describe('web editor schema matches editorSchema (issue #25)', () => {
|
|
const builtSchema = getSchema(documentExtensions);
|
|
const nodeNames = Object.keys(editorSchema.spec.nodes.toObject());
|
|
const markNames = Object.keys(editorSchema.spec.marks.toObject());
|
|
|
|
it('defines exactly the same node types', () => {
|
|
expect(Object.keys(builtSchema.nodes).sort()).toEqual([...nodeNames].sort());
|
|
});
|
|
|
|
it('defines exactly the same mark types', () => {
|
|
expect(Object.keys(builtSchema.marks).sort()).toEqual([...markNames].sort());
|
|
});
|
|
|
|
it.each(nodeNames)('node "%s" has the same content expression, group, and attrs', (name) => {
|
|
const canonical = editorSchema.spec.nodes.get(name)!;
|
|
const built = builtSchema.nodes[name]!.spec;
|
|
expect(built.content ?? undefined).toBe(canonical.content ?? undefined);
|
|
expect(built.group ?? undefined).toBe(canonical.group ?? undefined);
|
|
expect(Object.keys(built.attrs ?? {}).sort()).toEqual(
|
|
Object.keys(canonical.attrs ?? {}).sort(),
|
|
);
|
|
});
|
|
|
|
it.each(markNames)('mark "%s" has the same attrs', (name) => {
|
|
const canonical = editorSchema.spec.marks.get(name)!;
|
|
const built = builtSchema.marks[name]!.spec;
|
|
expect(Object.keys(built.attrs ?? {}).sort()).toEqual(
|
|
Object.keys(canonical.attrs ?? {}).sort(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Yjs round-trip between the web editor and the api decoder (issues #23/#25)', () => {
|
|
/** Drives a real headless TipTap `Editor` (the exact client code path,
|
|
* `@tiptap/extension-collaboration` + `@tiptap/y-tiptap`) and decodes the
|
|
* resulting Yjs state the same way the api does (`y-prosemirror` against
|
|
* `editorSchema`) — proving the two independently-built bindings agree on
|
|
* the wire format for the node/mark set this schema actually uses. */
|
|
it('preserves headings, lists, task items, marks, and tables', () => {
|
|
const ydoc = new Y.Doc();
|
|
const editor = new Editor({
|
|
element: document.createElement('div'),
|
|
extensions: [
|
|
...documentExtensions,
|
|
Collaboration.configure({ document: ydoc, field: 'default' }),
|
|
],
|
|
});
|
|
|
|
editor.commands.setContent({
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] },
|
|
{
|
|
type: 'paragraph',
|
|
content: [
|
|
{ type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
|
|
{ type: 'text', text: ' and a ' },
|
|
{
|
|
type: 'text',
|
|
text: 'link',
|
|
marks: [{ type: 'link', attrs: { href: 'https://example.org' } }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'bullet_list',
|
|
content: [
|
|
{
|
|
type: 'list_item',
|
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'item one' }] }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'task_list',
|
|
content: [
|
|
{
|
|
type: 'task_item',
|
|
attrs: { checked: true },
|
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'done' }] }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'table',
|
|
content: [
|
|
{
|
|
type: 'table_row',
|
|
content: [
|
|
{
|
|
type: 'table_cell',
|
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'cell' }] }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const state = Y.encodeStateAsUpdate(ydoc);
|
|
editor.destroy();
|
|
|
|
const decodeDoc = new Y.Doc();
|
|
Y.applyUpdate(decodeDoc, state);
|
|
const decoded = yXmlFragmentToProseMirrorRootNode(
|
|
decodeDoc.getXmlFragment('default'),
|
|
editorSchema,
|
|
);
|
|
decodeDoc.destroy();
|
|
|
|
expect(decoded.textContent).toContain('Title');
|
|
expect(decoded.textContent).toContain('item one');
|
|
expect(decoded.textContent).toContain('done');
|
|
expect(decoded.textContent).toContain('cell');
|
|
|
|
const markdown = docToMarkdown(decoded);
|
|
expect(markdown).toContain('## Title');
|
|
expect(markdown).toContain('**bold**');
|
|
|
|
expect(docToPlainText(decoded)).toContain('bold and a link');
|
|
});
|
|
});
|