From 076883a9a641e01579cb95780732c1f14a6acaee Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Mon, 6 Jul 2026 11:08:43 +0200 Subject: [PATCH] Add TipTap page editor with REST persistence (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/pages/pages.controller.ts | 12 + apps/api/src/pages/pages.e2e.db.test.ts | 24 + apps/api/src/pages/pages.service.ts | 10 + apps/web/e2e/editor.spec.ts | 97 +++ apps/web/package.json | 12 +- apps/web/src/App.tsx | 2 + apps/web/src/editor/Toolbar.tsx | 338 +++++++++++ .../src/editor/document-extensions.test.ts | 140 +++++ apps/web/src/editor/document-extensions.ts | 48 ++ apps/web/src/editor/marks.ts | 131 ++++ apps/web/src/editor/nodes/image.tsx | 58 ++ apps/web/src/editor/nodes/lists.ts | 108 ++++ apps/web/src/editor/nodes/table.ts | 142 +++++ apps/web/src/editor/nodes/task-item.tsx | 46 ++ apps/web/src/editor/nodes/text-basics.ts | 180 ++++++ apps/web/src/editor/spec-utils.ts | 61 ++ apps/web/src/editor/use-page-autosave.ts | 64 ++ apps/web/src/editor/yjs-base64.ts | 15 + apps/web/src/i18n/index.ts | 18 +- apps/web/src/layout/AppLayout.tsx | 28 +- apps/web/src/layout/sidebar-chrome.tsx | 16 + apps/web/src/lib/api.ts | 2 + apps/web/src/pages/PageEditorPage.tsx | 130 ++++ apps/web/src/styles/base.css | 204 +++++++ packages/shared/i18n/de/editor.json | 55 ++ packages/shared/i18n/en/editor.json | 55 ++ pnpm-lock.yaml | 558 +++++++++++++++++- 27 files changed, 2535 insertions(+), 19 deletions(-) create mode 100644 apps/web/e2e/editor.spec.ts create mode 100644 apps/web/src/editor/Toolbar.tsx create mode 100644 apps/web/src/editor/document-extensions.test.ts create mode 100644 apps/web/src/editor/document-extensions.ts create mode 100644 apps/web/src/editor/marks.ts create mode 100644 apps/web/src/editor/nodes/image.tsx create mode 100644 apps/web/src/editor/nodes/lists.ts create mode 100644 apps/web/src/editor/nodes/table.ts create mode 100644 apps/web/src/editor/nodes/task-item.tsx create mode 100644 apps/web/src/editor/nodes/text-basics.ts create mode 100644 apps/web/src/editor/spec-utils.ts create mode 100644 apps/web/src/editor/use-page-autosave.ts create mode 100644 apps/web/src/editor/yjs-base64.ts create mode 100644 apps/web/src/layout/sidebar-chrome.tsx create mode 100644 apps/web/src/pages/PageEditorPage.tsx create mode 100644 packages/shared/i18n/de/editor.json create mode 100644 packages/shared/i18n/en/editor.json diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index 5aace4d..464728c 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -44,6 +44,18 @@ export class PagesController { return this.pages.getState(request.user!, id); } + /** Resolves the pond-slug + page-slug pair the `/p/:pondSlug/:pageSlug` route + * navigates to (issue #25); the pond id must already be known to the caller + * (e.g. from `GET /ponds/:slug`). Listing pages by pond arrives with #26. */ + @Get('ponds/:pondId/pages/:slug') + async getStateBySlug( + @Param('pondId') pondId: string, + @Param('slug') slug: string, + @Req() request: AuthedRequest, + ): Promise { + return this.pages.getStateBySlug(request.user!, pondId, slug); + } + @Put('pages/:id/state') async saveState( @Param('id') id: string, diff --git a/apps/api/src/pages/pages.e2e.db.test.ts b/apps/api/src/pages/pages.e2e.db.test.ts index 4383d13..4b8c1ad 100644 --- a/apps/api/src/pages/pages.e2e.db.test.ts +++ b/apps/api/src/pages/pages.e2e.db.test.ts @@ -226,6 +226,30 @@ describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => { await api().get(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(404); }); + it('resolves a page by pond id + slug (issue #25 editor route)', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Slug Lookup ${suffix}` }) + .expect(201); + + const bySlug = await api() + .get(`/api/v1/ponds/${pondId}/pages/${created.body.slug}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(bySlug.body.id).toBe(created.body.id); + expect(typeof bySlug.body.state).toBe('string'); + + await api() + .get(`/api/v1/ponds/${pondId}/pages/${created.body.slug}`) + .set('Cookie', outsiderCookie) + .expect(404); + await api() + .get(`/api/v1/ponds/${pondId}/pages/does-not-exist-${suffix}`) + .set('Cookie', ownerCookie) + .expect(404); + }); + it('hides pages in foreign ponds (404, not 403)', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index c50ddc7..359db70 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -138,6 +138,16 @@ export class PagesService { return this.stateViewOf(page); } + async getStateBySlug(user: User, pondId: string, slug: string): Promise { + const page = await this.prisma.page.findFirst({ + where: { pondId, slug, deletedAt: null }, + include: { pond: true }, + }); + if (!page) throw new NotFoundException(); + this.access.assertCanSee(user, page.pond); + return this.stateViewOf(page); + } + async saveState(user: User, id: string, input: SavePageStateInput): Promise { const page = await this.findModifiablePage(user, id); const state = new Uint8Array(Buffer.from(input.state, 'base64')); diff --git a/apps/web/e2e/editor.spec.ts b/apps/web/e2e/editor.spec.ts new file mode 100644 index 0000000..01d08de --- /dev/null +++ b/apps/web/e2e/editor.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * TipTap editor pack (issue #25). Runs against the local dev stack (api + + * web); no Mailpit needed. Creates its own page per test via the api (the + * sidebar/"new page" flow is issue #26) and navigates straight to + * `/p/:pondSlug/:pageSlug`. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +async function createPage( + context: Awaited>, + title: string, +): Promise<{ pondSlug: string; pageSlug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title }, + }); + const page = await created.json(); + return { pondSlug: pond.slug, pageSlug: page.slug }; +} + +test('typing persists across reload and undo/redo work', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E Editor ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + + const content = page.locator('.ProseMirror'); + await expect(content).toHaveAttribute('contenteditable', 'true'); + await content.click(); + await page.keyboard.type('Hello editor'); + await expect(page.getByRole('status')).toHaveText(/saved|gespeichert/i, { timeout: 10000 }); + + await page.reload(); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await expect(content).toHaveAttribute('contenteditable', 'true'); + await expect(content).toContainText('Hello editor'); + + await content.click(); + await page.keyboard.type(' more'); + await expect(content).toContainText('Hello editor more'); + await page.keyboard.press('ControlOrMeta+z'); + await expect(content).toContainText('Hello editor'); + await expect(content).not.toContainText('Hello editor more'); + await page.keyboard.press('ControlOrMeta+y'); + await expect(content).toContainText('Hello editor more'); + + await context.close(); +}); + +test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + // CSS selector, not getByRole: aria-hidden removes the element from the + // accessibility tree, which would make a role-based locator "disappear" + // exactly when we need to assert that attribute. + const sidebar = page.locator('nav.sidebar'); + await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); + + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await expect(sidebar).toHaveAttribute('aria-hidden', 'true'); + + await page.getByRole('button', { name: /read|lesen/i }).click(); + await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); + + await context.close(); +}); + +test('a save failure shows a truthful error and retries once online again', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E Offline ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await page.locator('.ProseMirror').click(); + + await context.setOffline(true); + await page.keyboard.type('offline text'); + await expect(page.getByRole('status')).toHaveText(/failed|retrying|fehlgeschlagen/i, { + timeout: 10000, + }); + + await context.setOffline(false); + await expect(page.getByRole('status')).toHaveText(/saved|gespeichert/i, { timeout: 10000 }); + + await context.close(); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 88454e6..cb9b5a3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,13 +17,21 @@ "@dorfteich/shared": "workspace:*", "@hookform/resolvers": "^5.4.0", "@tanstack/react-query": "^5.66.0", + "@tiptap/core": "^3.27.1", + "@tiptap/extension-collaboration": "^3.27.1", + "@tiptap/pm": "^3.27.1", + "@tiptap/react": "^3.27.1", "i18next": "^26.3.4", "i18next-browser-languagedetector": "^8.2.1", + "prosemirror-model": "^1.25.9", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-tables": "^1.8.5", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.80.0", "react-i18next": "^17.0.8", "react-router-dom": "^7.1.0", + "yjs": "^13.6.31", "zod": "^4.4.3" }, "devDependencies": { @@ -31,8 +39,10 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "jsdom": "^26.0.0", "typescript": "^5.7.0", "vite": "^6.1.0", - "vitest": "^3.0.0" + "vitest": "^3.0.0", + "y-prosemirror": "^1.3.7" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4de8d66..759c94b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import { AppLayout } from './layout/AppLayout'; import { AdminSettingsPage } from './pages/AdminSettingsPage'; import { HomePage } from './pages/HomePage'; import { NotFoundPage } from './pages/NotFoundPage'; +import { PageEditorPage } from './pages/PageEditorPage'; import { SettingsPage } from './pages/SettingsPage'; import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage'; import { LoginPage } from './pages/auth/LoginPage'; @@ -29,6 +30,7 @@ export function App(): React.JSX.Element { }> } /> + } /> }> } /> diff --git a/apps/web/src/editor/Toolbar.tsx b/apps/web/src/editor/Toolbar.tsx new file mode 100644 index 0000000..b3760e6 --- /dev/null +++ b/apps/web/src/editor/Toolbar.tsx @@ -0,0 +1,338 @@ +import { isAllowedLinkHref } from '@dorfteich/shared'; +import type { Editor } from '@tiptap/core'; +import { useEditorState } from '@tiptap/react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface ToolbarProps { + editor: Editor; +} + +function ToolbarButton({ + label, + active, + disabled, + onClick, + children, +}: { + label: string; + active?: boolean; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; +}): React.JSX.Element { + return ( + + ); +} + +interface LinkLabels { + add: string; + remove: string; + urlLabel: string; + apply: string; + cancel: string; +} + +function LinkControl({ editor, label }: { editor: Editor; label: LinkLabels }): React.JSX.Element { + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(''); + const active = editor.isActive('link'); + + if (active) { + return ( + editor.chain().focus().unsetLink().run()} + > + 🔗 + + ); + } + + if (open) { + return ( +
{ + event.preventDefault(); + if (isAllowedLinkHref(url)) editor.chain().focus().setLink(url).run(); + setOpen(false); + setUrl(''); + }} + > + setUrl(event.target.value)} + /> + + +
+ ); + } + + return ( + setOpen(true)} + > + 🔗 + + ); +} + +/** Keyboard-accessible toolbar for the page editor (issue #25). Table row/ + * column controls stay visible but disabled outside a table, so the + * toolbar's layout and tab order never shift while typing. */ +export function Toolbar({ editor }: ToolbarProps): React.JSX.Element { + const { t } = useTranslation('editor'); + const state = useEditorState({ + editor, + selector: ({ editor: e }) => ({ + paragraph: e.isActive('paragraph'), + heading1: e.isActive('heading', { level: 1 }), + heading2: e.isActive('heading', { level: 2 }), + heading3: e.isActive('heading', { level: 3 }), + heading4: e.isActive('heading', { level: 4 }), + bold: e.isActive('bold'), + italic: e.isActive('italic'), + code: e.isActive('code'), + strikethrough: e.isActive('strikethrough'), + bulletList: e.isActive('bullet_list'), + orderedList: e.isActive('ordered_list'), + taskList: e.isActive('task_list'), + blockquote: e.isActive('blockquote'), + codeBlock: e.isActive('code_block'), + canAddRow: e.can().addRowAfter(), + canDeleteRow: e.can().deleteRow(), + canAddColumn: e.can().addColumnAfter(), + canDeleteColumn: e.can().deleteColumn(), + canDeleteTable: e.can().deleteTable(), + canToggleHeaderRow: e.can().toggleHeaderRow(), + canUndo: e.can().undo(), + canRedo: e.can().redo(), + }), + }); + + return ( +
+
+ editor.chain().focus().setParagraph().run()} + > + P + + {([1, 2, 3, 4] as const).map((level) => ( + editor.chain().focus().toggleHeading(level).run()} + > + H{level} + + ))} +
+ +
+ editor.chain().focus().toggleBold().run()} + > + B + + editor.chain().focus().toggleItalic().run()} + > + I + + editor.chain().focus().toggleCode().run()} + > + {''} + + editor.chain().focus().toggleStrikethrough().run()} + > + S + + +
+ +
+ editor.chain().focus().toggleBulletList().run()} + > + • + + editor.chain().focus().toggleOrderedList().run()} + > + 1. + + editor.chain().focus().toggleTaskList().run()} + > + ☑ + +
+ +
+ editor.chain().focus().toggleBlockquote().run()} + > + ❝ + + editor.chain().focus().toggleCodeBlock().run()} + > + {'{ }'} + + editor.chain().focus().setHorizontalRule().run()} + > + ― + + editor.chain().focus().insertImagePlaceholder().run()} + > + 🖼 + +
+ +
+ editor.chain().focus().insertTable().run()} + > + ⊞ + + editor.chain().focus().addColumnBefore().run()} + > + ⊞← + + editor.chain().focus().addColumnAfter().run()} + > + ⊞→ + + editor.chain().focus().deleteColumn().run()} + > + ⊟↕ + + editor.chain().focus().addRowBefore().run()} + > + ⊞↑ + + editor.chain().focus().addRowAfter().run()} + > + ⊞↓ + + editor.chain().focus().deleteRow().run()} + > + ⊟↔ + + editor.chain().focus().toggleHeaderRow().run()} + > + ⊤ + + editor.chain().focus().deleteTable().run()} + > + ⊠ + +
+ +
+ editor.chain().focus().undo().run()} + > + ↺ + + editor.chain().focus().redo().run()} + > + ↻ + +
+
+ ); +} diff --git a/apps/web/src/editor/document-extensions.test.ts b/apps/web/src/editor/document-extensions.test.ts new file mode 100644 index 0000000..181689d --- /dev/null +++ b/apps/web/src/editor/document-extensions.test.ts @@ -0,0 +1,140 @@ +// @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'); + }); +}); diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts new file mode 100644 index 0000000..d158195 --- /dev/null +++ b/apps/web/src/editor/document-extensions.ts @@ -0,0 +1,48 @@ +import type { AnyExtension } from '@tiptap/core'; + +import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks'; +import { Image } from './nodes/image'; +import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists'; +import { Table, TableCell, TableHeader, TableRow } from './nodes/table'; +import { TaskItem } from './nodes/task-item'; +import { + Blockquote, + CodeBlock, + Doc, + HardBreak, + Heading, + HorizontalRule, + Paragraph, + Text, +} from './nodes/text-basics'; + +/** + * The full node/mark set for `editorSchema` (packages/shared, issue #24), + * minus `Collaboration` — that extension needs a per-page `Y.Doc` and is + * added by the caller (`PageEditorPage`, issue #25). + */ +export const documentExtensions: AnyExtension[] = [ + Doc, + Text, + Paragraph, + Heading, + Blockquote, + CodeBlock, + HorizontalRule, + BulletList, + OrderedList, + ListItem, + TaskList, + TaskItem, + HardBreak, + Image, + Table, + TableRow, + TableCell, + TableHeader, + Bold, + Italic, + CodeMark, + Strikethrough, + LinkMark, +]; diff --git a/apps/web/src/editor/marks.ts b/apps/web/src/editor/marks.ts new file mode 100644 index 0000000..db13210 --- /dev/null +++ b/apps/web/src/editor/marks.ts @@ -0,0 +1,131 @@ +import { isAllowedLinkHref } from '@dorfteich/shared'; +import { Mark, markInputRule, markPasteRule } from '@tiptap/core'; + +import { attributesFromSpec, markSpec } from './spec-utils'; + +declare module '@tiptap/core' { + interface Commands { + documentMarks: { + toggleBold: () => ReturnType; + toggleItalic: () => ReturnType; + toggleCode: () => ReturnType; + toggleStrikethrough: () => ReturnType; + setLink: (href: string) => ReturnType; + unsetLink: () => ReturnType; + }; + } +} + +const boldSpec = markSpec('bold'); +export const Bold = Mark.create({ + name: 'bold', + parseHTML: () => boldSpec.parseDOM, + renderHTML: ({ mark }) => boldSpec.toDOM!(mark, true), + addCommands() { + return { + toggleBold: + () => + ({ commands }) => + commands.toggleMark(this.name), + }; + }, + addKeyboardShortcuts() { + return { 'Mod-b': () => this.editor.commands.toggleBold() }; + }, + addInputRules() { + return [markInputRule({ find: /(?:^|\s)(\*\*([^*]+)\*\*)$/, type: this.type })]; + }, + addPasteRules() { + return [markPasteRule({ find: /(?:^|\s)(\*\*([^*]+)\*\*)/g, type: this.type })]; + }, +}); + +const italicSpec = markSpec('italic'); +export const Italic = Mark.create({ + name: 'italic', + parseHTML: () => italicSpec.parseDOM, + renderHTML: ({ mark }) => italicSpec.toDOM!(mark, true), + addCommands() { + return { + toggleItalic: + () => + ({ commands }) => + commands.toggleMark(this.name), + }; + }, + addKeyboardShortcuts() { + return { 'Mod-i': () => this.editor.commands.toggleItalic() }; + }, + addInputRules() { + return [markInputRule({ find: /(?:^|\s)(_([^_]+)_)$/, type: this.type })]; + }, +}); + +const codeSpec = markSpec('code'); +export const CodeMark = Mark.create({ + name: 'code', + excludes: '_', + code: true, + parseHTML: () => codeSpec.parseDOM, + renderHTML: ({ mark }) => codeSpec.toDOM!(mark, true), + addCommands() { + return { + toggleCode: + () => + ({ commands }) => + commands.toggleMark(this.name), + }; + }, + addKeyboardShortcuts() { + return { 'Mod-e': () => this.editor.commands.toggleCode() }; + }, + addInputRules() { + return [markInputRule({ find: /(?:^|\s)(`([^`]+)`)$/, type: this.type })]; + }, +}); + +const strikethroughSpec = markSpec('strikethrough'); +export const Strikethrough = Mark.create({ + name: 'strikethrough', + parseHTML: () => strikethroughSpec.parseDOM, + renderHTML: ({ mark }) => strikethroughSpec.toDOM!(mark, true), + addCommands() { + return { + toggleStrikethrough: + () => + ({ commands }) => + commands.toggleMark(this.name), + }; + }, + addKeyboardShortcuts() { + return { 'Mod-Shift-s': () => this.editor.commands.toggleStrikethrough() }; + }, + addInputRules() { + return [markInputRule({ find: /(?:^|\s)(~~([^~]+)~~)$/, type: this.type })]; + }, +}); + +/** Full link-editing UX (bubble menu, "open in new tab") is issue #29; here + * a link is just a mark applicable to a selection with a validated href. */ +const linkSpec = markSpec('link'); +export const LinkMark = Mark.create({ + name: 'link', + inclusive: false, + addAttributes() { + return attributesFromSpec(linkSpec); + }, + parseHTML: () => linkSpec.parseDOM, + renderHTML: ({ mark }) => linkSpec.toDOM!(mark, true), + addCommands() { + return { + setLink: + (href: string) => + ({ commands }) => + isAllowedLinkHref(href) && commands.setMark(this.name, { href }), + unsetLink: + () => + ({ commands }) => + commands.unsetMark(this.name), + }; + }, +}); diff --git a/apps/web/src/editor/nodes/image.tsx b/apps/web/src/editor/nodes/image.tsx new file mode 100644 index 0000000..bbbdb55 --- /dev/null +++ b/apps/web/src/editor/nodes/image.tsx @@ -0,0 +1,58 @@ +import { Node } from '@tiptap/core'; +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import type { NodeViewProps } from '@tiptap/react'; + +import { attributesFromSpec, nodeSpec } from '../spec-utils'; + +declare module '@tiptap/core' { + interface Commands { + documentImage: { + insertImagePlaceholder: (alt?: string) => ReturnType; + }; + } +} + +/** Real uploads and clipboard paste arrive with #27/#28; for now inserting + * an image always yields a placeholder box, since there is no file to + * resolve `fileId` against yet. */ +function ImageView({ node }: NodeViewProps): React.JSX.Element { + const alt = typeof node.attrs.alt === 'string' ? node.attrs.alt : ''; + return ( + + {alt || '\u{1F5BC}'} + + ); +} + +const imageSpec = nodeSpec('image'); +export const Image = Node.create({ + name: 'image', + group: imageSpec.group, + inline: imageSpec.inline, + atom: imageSpec.atom, + addAttributes() { + return attributesFromSpec(imageSpec); + }, + parseHTML: () => imageSpec.parseDOM, + renderHTML: ({ node }) => imageSpec.toDOM!(node), + addNodeView() { + return ReactNodeViewRenderer(ImageView); + }, + addCommands() { + return { + insertImagePlaceholder: + (alt = '') => + ({ chain }) => + chain() + .insertContent({ + type: this.name, + attrs: { fileId: crypto.randomUUID(), alt }, + }) + .run(), + }; + }, +}); diff --git a/apps/web/src/editor/nodes/lists.ts b/apps/web/src/editor/nodes/lists.ts new file mode 100644 index 0000000..3ef6f3e --- /dev/null +++ b/apps/web/src/editor/nodes/lists.ts @@ -0,0 +1,108 @@ +import { Node, wrappingInputRule } from '@tiptap/core'; + +import { attributesFromSpec, nodeSpec, passthroughNodeIO } from '../spec-utils'; + +declare module '@tiptap/core' { + interface Commands { + documentLists: { + toggleBulletList: () => ReturnType; + toggleOrderedList: () => ReturnType; + toggleTaskList: () => ReturnType; + }; + } +} + +const bulletListSpec = nodeSpec('bullet_list'); +export const BulletList = Node.create({ + name: 'bullet_list', + group: bulletListSpec.group, + content: bulletListSpec.content, + ...passthroughNodeIO(bulletListSpec), + addCommands() { + return { + toggleBulletList: + () => + ({ commands }) => + commands.toggleList(this.name, 'list_item'), + }; + }, + addKeyboardShortcuts() { + return { 'Mod-Shift-8': () => this.editor.commands.toggleBulletList() }; + }, + addInputRules() { + return [wrappingInputRule({ find: /^\s*[-*]\s$/, type: this.type })]; + }, +}); + +const orderedListSpec = nodeSpec('ordered_list'); +export const OrderedList = Node.create({ + name: 'ordered_list', + group: orderedListSpec.group, + content: orderedListSpec.content, + addAttributes() { + return attributesFromSpec(orderedListSpec); + }, + ...passthroughNodeIO(orderedListSpec), + addCommands() { + return { + toggleOrderedList: + () => + ({ commands }) => + commands.toggleList(this.name, 'list_item'), + }; + }, + addKeyboardShortcuts() { + return { 'Mod-Shift-9': () => this.editor.commands.toggleOrderedList() }; + }, + addInputRules() { + return [ + wrappingInputRule({ + find: /^(\d+)\.\s$/, + type: this.type, + getAttributes: (match) => ({ order: Number(match[1]) }), + joinPredicate: (match, node) => node.attrs.order + node.childCount === Number(match[1]), + }), + ]; + }, +}); + +const listItemSpec = nodeSpec('list_item'); +export const ListItem = Node.create({ + name: 'list_item', + content: listItemSpec.content, + ...passthroughNodeIO(listItemSpec), + addKeyboardShortcuts() { + return { + Enter: () => this.editor.commands.splitListItem(this.name), + Tab: () => this.editor.commands.sinkListItem(this.name), + 'Shift-Tab': () => this.editor.commands.liftListItem(this.name), + }; + }, +}); + +const taskListSpec = nodeSpec('task_list'); +export const TaskList = Node.create({ + name: 'task_list', + group: taskListSpec.group, + content: taskListSpec.content, + ...passthroughNodeIO(taskListSpec), + addCommands() { + return { + toggleTaskList: + () => + ({ commands }) => + commands.toggleList(this.name, 'task_item'), + }; + }, + addKeyboardShortcuts() { + return { 'Mod-Shift-7': () => this.editor.commands.toggleTaskList() }; + }, + addInputRules() { + return [ + wrappingInputRule({ + find: /^\s*\[([ x])]\s$/, + type: this.type, + }), + ]; + }, +}); diff --git a/apps/web/src/editor/nodes/table.ts b/apps/web/src/editor/nodes/table.ts new file mode 100644 index 0000000..976b52e --- /dev/null +++ b/apps/web/src/editor/nodes/table.ts @@ -0,0 +1,142 @@ +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 { + 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'), +}); diff --git a/apps/web/src/editor/nodes/task-item.tsx b/apps/web/src/editor/nodes/task-item.tsx new file mode 100644 index 0000000..64d8886 --- /dev/null +++ b/apps/web/src/editor/nodes/task-item.tsx @@ -0,0 +1,46 @@ +import { Node } from '@tiptap/core'; +import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import type { NodeViewProps } from '@tiptap/react'; + +import { attributesFromSpec, nodeSpec } from '../spec-utils'; + +/** `packages/shared`'s task_item.parseDOM does not read `data-checked` back + * (issue #24) — checked state only ever comes from the node's own attrs, set + * here via the checkbox, never re-parsed from HTML. */ +function TaskItemView({ node, updateAttributes, editor }: NodeViewProps): React.JSX.Element { + const checked = Boolean(node.attrs.checked); + return ( + + + + + ); +} + +const taskItemSpec = nodeSpec('task_item'); +export const TaskItem = Node.create({ + name: 'task_item', + content: taskItemSpec.content, + addAttributes() { + return attributesFromSpec(taskItemSpec); + }, + parseHTML: () => taskItemSpec.parseDOM, + renderHTML: ({ node }) => taskItemSpec.toDOM!(node), + addNodeView() { + return ReactNodeViewRenderer(TaskItemView); + }, + addKeyboardShortcuts() { + return { + Enter: () => this.editor.commands.splitListItem(this.name), + Tab: () => this.editor.commands.sinkListItem(this.name), + 'Shift-Tab': () => this.editor.commands.liftListItem(this.name), + }; + }, +}); diff --git a/apps/web/src/editor/nodes/text-basics.ts b/apps/web/src/editor/nodes/text-basics.ts new file mode 100644 index 0000000..4db5098 --- /dev/null +++ b/apps/web/src/editor/nodes/text-basics.ts @@ -0,0 +1,180 @@ +import { Node, nodeInputRule, textblockTypeInputRule, wrappingInputRule } from '@tiptap/core'; + +import { attributesFromSpec, nodeSpec, passthroughNodeIO } from '../spec-utils'; + +declare module '@tiptap/core' { + interface Commands { + documentTextBasics: { + setParagraph: () => ReturnType; + setHeading: (level: 1 | 2 | 3 | 4) => ReturnType; + toggleHeading: (level: 1 | 2 | 3 | 4) => ReturnType; + toggleBlockquote: () => ReturnType; + toggleCodeBlock: () => ReturnType; + setHorizontalRule: () => ReturnType; + setHardBreak: () => ReturnType; + }; + } +} + +/** ProseMirror's document root; `topNode` is a schema-construction option, + * not part of the NodeSpec, so it is not derived from `nodeSpec('doc')`. */ +export const Doc = Node.create({ + name: 'doc', + topNode: true, + content: nodeSpec('doc').content, +}); + +export const Text = Node.create({ + name: 'text', + group: nodeSpec('text').group, +}); + +const paragraphSpec = nodeSpec('paragraph'); +export const Paragraph = Node.create({ + name: 'paragraph', + group: paragraphSpec.group, + content: paragraphSpec.content, + ...passthroughNodeIO(paragraphSpec), + addCommands() { + return { + setParagraph: + () => + ({ commands }) => + commands.setNode(this.name), + }; + }, + addKeyboardShortcuts() { + return { 'Mod-Alt-0': () => this.editor.commands.setParagraph() }; + }, +}); + +const headingSpec = nodeSpec('heading'); +const HEADING_LEVELS = [1, 2, 3, 4] as const; +export const Heading = Node.create({ + name: 'heading', + group: headingSpec.group, + content: headingSpec.content, + defining: headingSpec.defining, + addAttributes() { + return attributesFromSpec(headingSpec); + }, + ...passthroughNodeIO(headingSpec), + addCommands() { + return { + setHeading: + (level: 1 | 2 | 3 | 4) => + ({ commands }) => + commands.setNode(this.name, { level }), + toggleHeading: + (level: 1 | 2 | 3 | 4) => + ({ commands }) => + commands.toggleNode(this.name, 'paragraph', { level }), + }; + }, + addKeyboardShortcuts() { + return Object.fromEntries( + HEADING_LEVELS.map((level) => [ + `Mod-Alt-${level}`, + () => this.editor.commands.toggleHeading(level), + ]), + ); + }, + addInputRules() { + return HEADING_LEVELS.map((level) => + textblockTypeInputRule({ + find: new RegExp(`^(#{${level}})\\s$`), + type: this.type, + getAttributes: { level }, + }), + ); + }, +}); + +const blockquoteSpec = nodeSpec('blockquote'); +export const Blockquote = Node.create({ + name: 'blockquote', + group: blockquoteSpec.group, + content: blockquoteSpec.content, + ...passthroughNodeIO(blockquoteSpec), + addCommands() { + return { + toggleBlockquote: + () => + ({ commands }) => + commands.toggleWrap(this.name), + }; + }, + addKeyboardShortcuts() { + return { 'Mod-Shift-b': () => this.editor.commands.toggleBlockquote() }; + }, + addInputRules() { + return [wrappingInputRule({ find: /^\s*>\s$/, type: this.type })]; + }, +}); + +const codeBlockSpec = nodeSpec('code_block'); +export const CodeBlock = Node.create({ + name: 'code_block', + group: codeBlockSpec.group, + content: codeBlockSpec.content, + marks: codeBlockSpec.marks, + code: codeBlockSpec.code, + defining: codeBlockSpec.defining, + whitespace: codeBlockSpec.whitespace, + ...passthroughNodeIO(codeBlockSpec), + addCommands() { + return { + toggleCodeBlock: + () => + ({ commands }) => + commands.toggleNode(this.name, 'paragraph'), + }; + }, + addKeyboardShortcuts() { + return { 'Mod-Alt-c': () => this.editor.commands.toggleCodeBlock() }; + }, + addInputRules() { + return [textblockTypeInputRule({ find: /^```$/, type: this.type })]; + }, +}); + +const horizontalRuleSpec = nodeSpec('horizontal_rule'); +export const HorizontalRule = Node.create({ + name: 'horizontal_rule', + group: horizontalRuleSpec.group, + ...passthroughNodeIO(horizontalRuleSpec), + addCommands() { + return { + setHorizontalRule: + () => + ({ chain }) => + chain().insertContent({ type: this.name }).run(), + }; + }, + addInputRules() { + return [nodeInputRule({ find: /^---$/, type: this.type })]; + }, +}); + +const hardBreakSpec = nodeSpec('hard_break'); +export const HardBreak = Node.create({ + name: 'hard_break', + group: hardBreakSpec.group, + inline: hardBreakSpec.inline, + selectable: hardBreakSpec.selectable, + ...passthroughNodeIO(hardBreakSpec), + addCommands() { + return { + setHardBreak: + () => + ({ chain }) => + chain().insertContent({ type: this.name }).run(), + }; + }, + addKeyboardShortcuts() { + return { + 'Shift-Enter': () => this.editor.commands.setHardBreak(), + 'Mod-Enter': () => this.editor.commands.setHardBreak(), + }; + }, +}); diff --git a/apps/web/src/editor/spec-utils.ts b/apps/web/src/editor/spec-utils.ts new file mode 100644 index 0000000..fd64912 --- /dev/null +++ b/apps/web/src/editor/spec-utils.ts @@ -0,0 +1,61 @@ +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 { + 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 } : {}, + }; +} diff --git a/apps/web/src/editor/use-page-autosave.ts b/apps/web/src/editor/use-page-autosave.ts new file mode 100644 index 0000000..c6d542f --- /dev/null +++ b/apps/web/src/editor/use-page-autosave.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from 'react'; +import * as Y from 'yjs'; + +import { apiPut } from '../lib/api'; +import { encodeBase64 } from './yjs-base64'; + +export type SaveStatus = 'saved' | 'saving' | 'error'; + +const DEBOUNCE_MS = 800; +const RETRY_MS = 3000; + +/** Debounced `PUT /pages/:id/state` on every local Yjs update, with a + * truthful save-state indicator: failures (e.g. no network) surface as + * `error` and keep retrying every `RETRY_MS` until a save succeeds + * (issue #25 acceptance criterion: "saving failed / retrying"). */ +export function usePageStateAutosave(ydoc: Y.Doc | null, pageId: string): SaveStatus { + const [status, setStatus] = useState('saved'); + const timerRef = useRef>(undefined); + const inFlightRef = useRef(false); + const dirtyRef = useRef(false); + + useEffect(() => { + if (!ydoc) return; + const doc = ydoc; + + function save(): void { + if (inFlightRef.current) { + dirtyRef.current = true; + return; + } + inFlightRef.current = true; + dirtyRef.current = false; + setStatus('saving'); + apiPut(`/pages/${pageId}/state`, { state: encodeBase64(Y.encodeStateAsUpdate(doc)) }) + .then(() => { + inFlightRef.current = false; + setStatus('saved'); + if (dirtyRef.current) { + dirtyRef.current = false; + timerRef.current = setTimeout(save, DEBOUNCE_MS); + } + }) + .catch(() => { + inFlightRef.current = false; + setStatus('error'); + timerRef.current = setTimeout(save, RETRY_MS); + }); + } + + function onUpdate(): void { + setStatus((current) => (current === 'error' ? current : 'saving')); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(save, DEBOUNCE_MS); + } + + doc.on('update', onUpdate); + return () => { + doc.off('update', onUpdate); + clearTimeout(timerRef.current); + }; + }, [ydoc, pageId]); + + return status; +} diff --git a/apps/web/src/editor/yjs-base64.ts b/apps/web/src/editor/yjs-base64.ts new file mode 100644 index 0000000..92bdabc --- /dev/null +++ b/apps/web/src/editor/yjs-base64.ts @@ -0,0 +1,15 @@ +/** Browser-side base64 <-> Yjs update bytes; the api exchanges Yjs state as + * base64 over JSON (`apps/api/src/pages/pages.service.ts`, issue #23). */ + +export function decodeBase64(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export function encodeBase64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index a807fad..b9c78ac 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -1,9 +1,11 @@ import deAuth from '@dorfteich/shared/i18n/de/auth.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json'; +import deEditor from '@dorfteich/shared/i18n/de/editor.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json'; +import enEditor from '@dorfteich/shared/i18n/en/editor.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import i18n from 'i18next'; @@ -21,8 +23,20 @@ void i18n .use(initReactI18next) .init({ resources: { - en: { common: enCommon, errors: enErrors, auth: enAuth, settings: enSettings }, - de: { common: deCommon, errors: deErrors, auth: deAuth, settings: deSettings }, + en: { + common: enCommon, + errors: enErrors, + auth: enAuth, + settings: enSettings, + editor: enEditor, + }, + de: { + common: deCommon, + errors: deErrors, + auth: deAuth, + settings: deSettings, + editor: deEditor, + }, }, defaultNS: 'common', fallbackLng: 'en', diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx index 75dc1ab..7c51b3f 100644 --- a/apps/web/src/layout/AppLayout.tsx +++ b/apps/web/src/layout/AppLayout.tsx @@ -1,24 +1,30 @@ +import { useState } from 'react'; import { Outlet } from 'react-router-dom'; import { usePersistentState } from '../lib/use-persistent-state'; +import { SidebarChromeContext } from './sidebar-chrome'; import { Sidebar } from './Sidebar'; import { TopBar } from './TopBar'; export function AppLayout(): React.JSX.Element { const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false); + const [forcedHidden, setForcedHidden] = useState(false); + const collapsed = sidebarCollapsed || forcedHidden; return ( -
- setSidebarCollapsed(!sidebarCollapsed)} - /> -
- -
- -
+ +
+ setSidebarCollapsed(!sidebarCollapsed)} + /> +
+ +
+ +
+
-
+ ); } diff --git a/apps/web/src/layout/sidebar-chrome.tsx b/apps/web/src/layout/sidebar-chrome.tsx new file mode 100644 index 0000000..81c843e --- /dev/null +++ b/apps/web/src/layout/sidebar-chrome.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext, useEffect } from 'react'; + +type SetSidebarForcedHidden = (hidden: boolean) => void; + +export const SidebarChromeContext = createContext(() => {}); + +/** Lets a page (the editor's edit mode, issue #25) temporarily hide the + * sidebar regardless of the user's collapse preference, restoring it on + * unmount or once the condition turns false again. */ +export function useForceSidebarHidden(hidden: boolean): void { + const setForcedHidden = useContext(SidebarChromeContext); + useEffect(() => { + setForcedHidden(hidden); + return () => setForcedHidden(false); + }, [hidden, setForcedHidden]); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 1cf032d..b3b2bfa 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -43,6 +43,8 @@ async function requestJson(method: string, path: string, body?: unknown): Pro export const apiGet = (path: string): Promise => requestJson('GET', path); export const apiPost = (path: string, body?: unknown): Promise => requestJson('POST', path, body); +export const apiPut = (path: string, body?: unknown): Promise => + requestJson('PUT', path, body); export const apiPatch = (path: string, body?: unknown): Promise => requestJson('PATCH', path, body); export const apiDelete = (path: string): Promise => requestJson('DELETE', path); diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx new file mode 100644 index 0000000..29b849b --- /dev/null +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -0,0 +1,130 @@ +import type { PageStateView, PondView } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; +import { Collaboration } from '@tiptap/extension-collaboration'; +import { EditorContent, useEditor } from '@tiptap/react'; +import { useEffect, useLayoutEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import * as Y from 'yjs'; + +import { FormError } from '../components/forms'; +import { documentExtensions } from '../editor/document-extensions'; +import { Toolbar } from '../editor/Toolbar'; +import { usePageStateAutosave } from '../editor/use-page-autosave'; +import { decodeBase64 } from '../editor/yjs-base64'; +import { useForceSidebarHidden } from '../layout/sidebar-chrome'; +import { apiGet, apiPatch } from '../lib/api'; + +type Mode = 'view' | 'edit'; + +function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React.JSX.Element { + const { t } = useTranslation('editor'); + + // Created and destroyed within the same effect (not `useMemo` + a separate + // cleanup effect): React StrictMode's dev-only mount→cleanup→remount would + // otherwise destroy the memoized `Y.Doc` on the simulated unmount without + // ever creating a fresh one, silently breaking Yjs-internal machinery + // (e.g. the undo manager) while `Y.encodeStateAsUpdate`/`applyUpdate` + // still happen to keep working — a bug that only shows up in dev. + const [ydoc, setYdoc] = useState(null); + useEffect(() => { + const doc = new Y.Doc(); + Y.applyUpdate(doc, decodeBase64(page.state)); + setYdoc(doc); + return () => doc.destroy(); + // Deliberately not depending on `page.state`: a background refetch of + // the same page must not blow away in-progress local edits by rebuilding + // the Y.Doc from the (stale) server snapshot. + }, [page.id]); + + const editor = useEditor( + { + // `documentExtensions` alone is a valid (uncollaborated) schema, so the + // editor never gets built without its 'doc'/'paragraph'/'text' nodes + // while `ydoc` is still being created (see the effect above). + extensions: ydoc + ? [...documentExtensions, Collaboration.configure({ document: ydoc, field: 'default' })] + : documentExtensions, + editable: mode === 'edit', + immediatelyRender: false, + }, + [ydoc], + ); + + useLayoutEffect(() => { + editor?.setEditable(mode === 'edit'); + }, [editor, mode]); + + const saveStatus = usePageStateAutosave(ydoc, page.id); + + if (!editor || !ydoc) return <>; + + return ( +
+ {mode === 'edit' && } +
+ {t(`save.${saveStatus}`)} +
+ +
+ ); +} + +export function PageEditorPage(): React.JSX.Element { + const { t } = useTranslation('editor'); + const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>(); + const [mode, setMode] = useState('view'); + const [title, setTitle] = useState(''); + + useForceSidebarHidden(mode === 'edit'); + + const pond = useQuery({ + queryKey: ['pond', pondSlug], + queryFn: () => apiGet(`/ponds/${pondSlug}`), + }); + const page = useQuery({ + queryKey: ['page', pond.data?.id, pageSlug], + queryFn: () => apiGet(`/ponds/${pond.data!.id}/pages/${pageSlug}`), + enabled: Boolean(pond.data), + }); + + useEffect(() => { + if (page.data) setTitle(page.data.title); + }, [page.data?.id, page.data?.title]); + + async function saveTitle(): Promise { + if (!page.data || title === page.data.title) return; + await apiPatch(`/pages/${page.data.id}`, { title }); + } + + if (pond.error || page.error) { + return ; + } + if (!page.data) { + return <>; + } + + return ( +
+
+ setTitle(event.target.value)} + onBlur={() => void saveTitle()} + /> + +
+ +
+ ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 2e21ff6..ce8150a 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -330,3 +330,207 @@ button { display: flex; gap: var(--space-4); } + +/* Page editor (issue #25) */ +.editor-page { + max-width: 48rem; + margin: 0 auto; +} + +.editor-page__header { + display: flex; + align-items: center; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.editor-page__title { + flex: 1; + border: none; + background: none; + font-family: var(--font-heading); + font-weight: var(--font-weight-heading); + font-size: 1.6rem; + padding: var(--space-1) 0; +} + +.editor-page__title:disabled { + color: var(--color-text); + opacity: 1; +} + +.editor-page__title:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.editor-shell { + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-subtle); +} + +.editor-toolbar__group { + display: flex; + gap: var(--space-1); + padding-right: var(--space-3); + border-right: 1px solid var(--color-border); +} + +.editor-toolbar__group:last-child { + border-right: none; + padding-right: 0; +} + +.toolbar-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + height: 1.75rem; + padding: 0 var(--space-1); + border: 1px solid transparent; + border-radius: var(--radius); + background: transparent; + cursor: pointer; + color: var(--color-text); + font-size: 0.9rem; +} + +.toolbar-button:hover:not(:disabled) { + background: var(--color-bg); + border-color: var(--color-border); +} + +.toolbar-button:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +.toolbar-button--active { + background: var(--color-accent); + color: var(--color-accent-contrast); +} + +.toolbar-button:disabled { + opacity: 0.4; + cursor: default; +} + +.editor-link-form { + display: flex; + gap: var(--space-1); + align-items: center; +} + +.editor-link-form input { + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius); + font: inherit; + font-size: 0.9rem; +} + +.editor-save-indicator { + padding: var(--space-1) var(--space-3); + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.editor-content { + padding: var(--space-4) var(--space-6); + min-height: 12rem; +} + +.editor-content .ProseMirror { + outline: none; +} + +.editor-content h1, +.editor-content h2, +.editor-content h3, +.editor-content h4 { + margin-top: var(--space-6); + margin-bottom: var(--space-2); +} + +.editor-content blockquote { + margin: var(--space-4) 0; + padding-left: var(--space-4); + border-left: 3px solid var(--color-border); + color: var(--color-text-muted); +} + +.editor-content pre { + padding: var(--space-3); + border-radius: var(--radius); + background: var(--color-bg-subtle); + overflow-x: auto; +} + +.editor-content code { + background: var(--color-bg-subtle); + border-radius: 3px; + padding: 0 0.25em; +} + +.editor-content pre code { + background: none; + padding: 0; +} + +.editor-content ul[data-type='task_list'] { + list-style: none; + padding-left: var(--space-2); +} + +.editor-content ul[data-type='task_list'] li { + display: flex; + align-items: flex-start; + gap: var(--space-2); +} + +.editor-content table { + border-collapse: collapse; + margin: var(--space-4) 0; + width: 100%; +} + +.editor-content table td, +.editor-content table th { + border: 1px solid var(--color-border); + padding: var(--space-2) var(--space-3); +} + +.editor-content table th { + background: var(--color-bg-subtle); + text-align: left; +} + +.editor-content hr { + border: none; + border-top: 1px solid var(--color-border); + margin: var(--space-6) 0; +} + +.editor-image-placeholder { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3rem; + min-height: 2rem; + padding: var(--space-1) var(--space-2); + border: 1px dashed var(--color-border); + border-radius: var(--radius); + background: var(--color-bg-subtle); + color: var(--color-text-muted); + font-size: 0.85rem; +} diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json new file mode 100644 index 0000000..04846b3 --- /dev/null +++ b/packages/shared/i18n/de/editor.json @@ -0,0 +1,55 @@ +{ + "title": { + "placeholder": "Unbenannte Seite" + }, + "mode": { + "edit": "Bearbeiten", + "view": "Lesen", + "toggleToEdit": "In den Bearbeitungsmodus wechseln", + "toggleToView": "In den Lesemodus wechseln" + }, + "save": { + "saved": "Gespeichert", + "saving": "Speichert …", + "error": "Speichern fehlgeschlagen, erneuter Versuch …", + "unsavedTitle": "Titel wird gespeichert …" + }, + "toolbar": { + "paragraph": "Absatz", + "heading1": "Überschrift 1", + "heading2": "Überschrift 2", + "heading3": "Überschrift 3", + "heading4": "Überschrift 4", + "bold": "Fett", + "italic": "Kursiv", + "code": "Inline-Code", + "strikethrough": "Durchgestrichen", + "bulletList": "Aufzählungsliste", + "orderedList": "Nummerierte Liste", + "taskList": "Aufgabenliste", + "blockquote": "Zitat", + "codeBlock": "Codeblock", + "horizontalRule": "Trennlinie", + "image": "Bild-Platzhalter einfügen", + "undo": "Rückgängig", + "redo": "Wiederholen", + "link": { + "add": "Link hinzufügen", + "remove": "Link entfernen", + "urlLabel": "Link-URL", + "apply": "Übernehmen", + "cancel": "Abbrechen" + }, + "table": { + "insert": "Tabelle einfügen", + "addColumnBefore": "Spalte davor einfügen", + "addColumnAfter": "Spalte danach einfügen", + "deleteColumn": "Spalte löschen", + "addRowBefore": "Zeile davor einfügen", + "addRowAfter": "Zeile danach einfügen", + "deleteRow": "Zeile löschen", + "toggleHeaderRow": "Kopfzeile umschalten", + "deleteTable": "Tabelle löschen" + } + } +} diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json new file mode 100644 index 0000000..3cc38ab --- /dev/null +++ b/packages/shared/i18n/en/editor.json @@ -0,0 +1,55 @@ +{ + "title": { + "placeholder": "Untitled page" + }, + "mode": { + "edit": "Edit", + "view": "Read", + "toggleToEdit": "Switch to edit mode", + "toggleToView": "Switch to read mode" + }, + "save": { + "saved": "Saved", + "saving": "Saving …", + "error": "Saving failed, retrying …", + "unsavedTitle": "Saving title …" + }, + "toolbar": { + "paragraph": "Paragraph", + "heading1": "Heading 1", + "heading2": "Heading 2", + "heading3": "Heading 3", + "heading4": "Heading 4", + "bold": "Bold", + "italic": "Italic", + "code": "Inline code", + "strikethrough": "Strikethrough", + "bulletList": "Bullet list", + "orderedList": "Numbered list", + "taskList": "Task list", + "blockquote": "Quote", + "codeBlock": "Code block", + "horizontalRule": "Horizontal rule", + "image": "Insert image placeholder", + "undo": "Undo", + "redo": "Redo", + "link": { + "add": "Add link", + "remove": "Remove link", + "urlLabel": "Link URL", + "apply": "Apply", + "cancel": "Cancel" + }, + "table": { + "insert": "Insert table", + "addColumnBefore": "Add column before", + "addColumnAfter": "Add column after", + "deleteColumn": "Delete column", + "addRowBefore": "Add row before", + "addRowAfter": "Add row after", + "deleteRow": "Delete row", + "toggleHeaderRow": "Toggle header row", + "deleteTable": "Delete table" + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 488e156..2ef0976 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,13 +134,13 @@ importers: version: 1.5.9(@swc/core@1.15.43)(rollup@4.62.2) vitest: specifier: ^3.0.0 - version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0) + version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) apps/collab: devDependencies: vitest: specifier: ^3.0.0 - version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0) + version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) apps/web: dependencies: @@ -153,12 +153,33 @@ importers: '@tanstack/react-query': specifier: ^5.66.0 version: 5.101.2(react@19.2.7) + '@tiptap/core': + specifier: ^3.27.1 + version: 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/extension-collaboration': + specifier: ^3.27.1 + version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31) + '@tiptap/pm': + specifier: ^3.27.1 + version: 3.27.1 + '@tiptap/react': + specifier: ^3.27.1 + version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) i18next: specifier: ^26.3.4 version: 26.3.4(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 + prosemirror-model: + specifier: ^1.25.9 + version: 1.25.9 + prosemirror-schema-list: + specifier: ^1.5.0 + version: 1.5.1 + prosemirror-tables: + specifier: ^1.8.5 + version: 1.8.5 react: specifier: ^19.0.0 version: 19.2.7 @@ -174,6 +195,9 @@ importers: react-router-dom: specifier: ^7.1.0 version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + yjs: + specifier: ^13.6.31 + version: 13.6.31 zod: specifier: ^4.4.3 version: 4.4.3 @@ -190,6 +214,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.0 version: 4.7.0(vite@6.4.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -198,7 +225,10 @@ importers: version: 6.4.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0) vitest: specifier: ^3.0.0 - version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0) + version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) + y-prosemirror: + specifier: ^1.3.7 + version: 1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) packages/shared: dependencies: @@ -229,7 +259,7 @@ importers: version: 8.5.1(@swc/core@1.15.43)(jiti@2.7.0)(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3) vitest: specifier: ^3.0.0 - version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0) + version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) packages: @@ -264,6 +294,9 @@ packages: resolution: {integrity: sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -358,6 +391,34 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} @@ -867,6 +928,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@hookform/resolvers@5.4.0': resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} peerDependencies: @@ -1436,6 +1506,55 @@ packages: peerDependencies: react: ^18 || ^19 + '@tiptap/core@3.27.1': + resolution: {integrity: sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==} + peerDependencies: + '@tiptap/pm': 3.27.1 + + '@tiptap/extension-bubble-menu@3.27.1': + resolution: {integrity: sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw==} + peerDependencies: + '@tiptap/core': 3.27.1 + '@tiptap/pm': 3.27.1 + + '@tiptap/extension-collaboration@3.27.1': + resolution: {integrity: sha512-Da7WeKNIaLsbcHBWlgexMgm5ygoA1mhRroFND1vweLNsWIPxvyjci7jrq/uDN1tSnpqMlJsdyW0tXzbVGYTpMw==} + peerDependencies: + '@tiptap/core': 3.27.1 + '@tiptap/pm': 3.27.1 + '@tiptap/y-tiptap': ^3.0.5 + yjs: ^13 + + '@tiptap/extension-floating-menu@3.27.1': + resolution: {integrity: sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.27.1 + '@tiptap/pm': 3.27.1 + + '@tiptap/pm@3.27.1': + resolution: {integrity: sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==} + + '@tiptap/react@3.27.1': + resolution: {integrity: sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg==} + peerDependencies: + '@tiptap/core': 3.27.1 + '@tiptap/pm': 3.27.1 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/y-tiptap@3.0.6': + resolution: {integrity: sha512-kcGeVGKtq/cPGVseNKjtmtcY2WXUAEm1SqS5x0Smubj4nOCRyPiHg6kY4QuuZhmXjTK7hdo8chokkPUKWXPE9Q==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -1540,6 +1659,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript-eslint/eslint-plugin@8.62.1': resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1705,6 +1827,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -2020,9 +2146,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -2035,6 +2169,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2109,6 +2246,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2260,6 +2401,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -2415,6 +2560,10 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -2422,6 +2571,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} @@ -2433,6 +2590,10 @@ packages: typescript: optional: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -2482,6 +2643,9 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -2521,6 +2685,15 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2603,6 +2776,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -2755,6 +2931,9 @@ packages: resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} engines: {node: '>=6.0.0'} + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + nypm@0.6.8: resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==} engines: {node: '>=18'} @@ -2813,6 +2992,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2945,6 +3127,24 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + prosemirror-changeset@2.4.1: + resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + prosemirror-keymap@1.2.3: resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} @@ -2954,6 +3154,9 @@ packages: prosemirror-model@1.25.9: resolution: {integrity: sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==} + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + prosemirror-state@1.4.4: resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} @@ -3090,10 +3293,16 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} @@ -3110,6 +3319,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3276,6 +3489,9 @@ packages: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -3364,6 +3580,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -3372,6 +3595,14 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3622,6 +3853,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + watchpack@2.5.2: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} @@ -3629,6 +3864,10 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-node-externals@3.0.0: resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} engines: {node: '>=6'} @@ -3650,6 +3889,19 @@ packages: webpack-cli: optional: true + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3671,6 +3923,25 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y-prosemirror@1.3.7: resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -3768,6 +4039,14 @@ snapshots: transitivePeerDependencies: - chokidar + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3887,6 +4166,26 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@epic-web/invariant@1.0.0': {} '@esbuild/aix-ppc64@0.25.12': @@ -4169,6 +4468,20 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + optional: true + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + optional: true + + '@floating-ui/utils@0.2.11': + optional: true + '@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))': dependencies: '@standard-schema/utils': 0.3.0 @@ -4657,6 +4970,73 @@ snapshots: '@tanstack/query-core': 5.101.2 react: 19.2.7 + '@tiptap/core@3.27.1(@tiptap/pm@3.27.1)': + dependencies: + '@tiptap/pm': 3.27.1 + + '@tiptap/extension-bubble-menu@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/pm': 3.27.1 + optional: true + + '@tiptap/extension-collaboration@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31)': + dependencies: + '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/pm': 3.27.1 + '@tiptap/y-tiptap': 3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) + yjs: 13.6.31 + + '@tiptap/extension-floating-menu@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/pm': 3.27.1 + optional: true + + '@tiptap/pm@3.27.1': + dependencies: + prosemirror-changeset: 2.4.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.9 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.0 + + '@tiptap/react@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/pm': 3.27.1 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) + '@tiptap/extension-floating-menu': 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.9 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.0 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -4790,6 +5170,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.10 + '@types/use-sync-external-store@0.0.6': {} + '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5030,6 +5412,8 @@ snapshots: acorn@8.17.0: {} + agent-base@7.1.4: {} + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -5326,14 +5710,26 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + dateformat@4.6.3: {} debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -5393,6 +5789,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -5647,6 +6045,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -5817,6 +6217,10 @@ snapshots: help-me@5.0.0: {} + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -5829,6 +6233,20 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + i18next-browser-languagedetector@8.2.1: dependencies: '@babel/runtime': 7.29.7 @@ -5837,6 +6255,10 @@ snapshots: optionalDependencies: typescript: 5.9.3 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5870,6 +6292,8 @@ snapshots: is-interactive@1.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-unicode-supported@0.1.0: {} @@ -5898,6 +6322,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -5962,6 +6413,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.5.1: {} lru-cache@5.1.1: @@ -6086,6 +6539,8 @@ snapshots: nodemailer@9.0.3: {} + nwsapi@2.2.24: {} + nypm@0.6.8: dependencies: citty: 0.2.2 @@ -6154,6 +6609,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -6281,6 +6740,41 @@ snapshots: process-warning@5.0.0: {} + prosemirror-changeset@2.4.1: + dependencies: + prosemirror-transform: 1.12.0 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.9 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.0 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.9 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.0 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.0 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-keymap@1.2.3: dependencies: prosemirror-state: 1.4.4 @@ -6296,6 +6790,12 @@ snapshots: dependencies: orderedmap: 2.1.1 + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.9 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-state@1.4.4: dependencies: prosemirror-model: 1.25.9 @@ -6449,6 +6949,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -6459,6 +6961,8 @@ snapshots: transitivePeerDependencies: - supports-color + rrweb-cssom@0.8.0: {} + rxjs@7.8.1: dependencies: tslib: 2.8.1 @@ -6473,6 +6977,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} schema-utils@3.3.0: @@ -6660,6 +7168,8 @@ snapshots: symbol-observable@4.0.0: {} + symbol-tree@3.2.4: {} + tapable@2.3.3: {} terser-webpack-plugin@5.6.1(@swc/core@1.15.43)(webpack@5.106.2(@swc/core@1.15.43)): @@ -6708,6 +7218,12 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + toidentifier@1.0.1: {} token-types@6.1.2: @@ -6716,6 +7232,14 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-api-utils@2.5.0(typescript@5.9.3): @@ -6905,7 +7429,7 @@ snapshots: terser: 5.48.0 tsx: 4.23.0 - vitest@3.2.6(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0): + vitest@3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 @@ -6932,6 +7456,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.0 + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -6950,6 +7475,10 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + watchpack@2.5.2: dependencies: graceful-fs: 4.2.11 @@ -6958,6 +7487,8 @@ snapshots: dependencies: defaults: 1.0.4 + webidl-conversions@7.0.0: {} + webpack-node-externals@3.0.0: {} webpack-sources@3.5.0: {} @@ -7004,6 +7535,17 @@ snapshots: - postcss - uglify-js + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -7023,6 +7565,12 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y-prosemirror@1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31): dependencies: lib0: 0.2.117