Add TipTap page editor with REST persistence (#25)
All checks were successful
CD / Build and push images (push) Successful in 2m0s
CI / Lint, typecheck, test (push) Successful in 1m42s
CI / Auth e2e pack (push) Successful in 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s

TipTap is bound to the canonical ProseMirror schema (packages/shared,
#24) via a generic bridge (spec-utils.ts) that re-derives every
node/mark's attrs/parseDOM/toDOM from editorSchema instead of
duplicating them, so the editor's schema stays byte-for-byte identical
to what the api decodes Yjs states against — guarded by a schema-
fidelity + real Yjs round-trip test (@tiptap/y-tiptap client encoding
against y-prosemirror server decoding).

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

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

Closes #25

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Claude Sonnet 5 2026-07-06 11:08:43 +02:00
parent 98e159ab50
commit 076883a9a6
27 changed files with 2535 additions and 19 deletions

View File

@ -44,6 +44,18 @@ export class PagesController {
return this.pages.getState(request.user!, id); 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<PageStateView> {
return this.pages.getStateBySlug(request.user!, pondId, slug);
}
@Put('pages/:id/state') @Put('pages/:id/state')
async saveState( async saveState(
@Param('id') id: string, @Param('id') id: string,

View File

@ -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); 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 () => { it('hides pages in foreign ponds (404, not 403)', async () => {
const created = await api() const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`) .post(`/api/v1/ponds/${pondId}/pages`)

View File

@ -138,6 +138,16 @@ export class PagesService {
return this.stateViewOf(page); return this.stateViewOf(page);
} }
async getStateBySlug(user: User, pondId: string, slug: string): Promise<PageStateView> {
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<PageStateView> { async saveState(user: User, id: string, input: SavePageStateInput): Promise<PageStateView> {
const page = await this.findModifiablePage(user, id); const page = await this.findModifiablePage(user, id);
const state = new Uint8Array(Buffer.from(input.state, 'base64')); const state = new Uint8Array(Buffer.from(input.state, 'base64'));

View File

@ -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<ReturnType<typeof contextForUser>>,
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();
});

View File

@ -17,13 +17,21 @@
"@dorfteich/shared": "workspace:*", "@dorfteich/shared": "workspace:*",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.66.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": "^26.3.4",
"i18next-browser-languagedetector": "^8.2.1", "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": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-router-dom": "^7.1.0", "react-router-dom": "^7.1.0",
"yjs": "^13.6.31",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@ -31,8 +39,10 @@
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"jsdom": "^26.0.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.1.0", "vite": "^6.1.0",
"vitest": "^3.0.0" "vitest": "^3.0.0",
"y-prosemirror": "^1.3.7"
} }
} }

View File

@ -5,6 +5,7 @@ import { AppLayout } from './layout/AppLayout';
import { AdminSettingsPage } from './pages/AdminSettingsPage'; import { AdminSettingsPage } from './pages/AdminSettingsPage';
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { NotFoundPage } from './pages/NotFoundPage'; import { NotFoundPage } from './pages/NotFoundPage';
import { PageEditorPage } from './pages/PageEditorPage';
import { SettingsPage } from './pages/SettingsPage'; import { SettingsPage } from './pages/SettingsPage';
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage'; import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
import { LoginPage } from './pages/auth/LoginPage'; import { LoginPage } from './pages/auth/LoginPage';
@ -29,6 +30,7 @@ export function App(): React.JSX.Element {
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="p/:pondSlug/:pageSlug" element={<PageEditorPage />} />
</Route> </Route>
<Route element={<RequireSiteAdmin />}> <Route element={<RequireSiteAdmin />}>
<Route path="admin" element={<AdminSettingsPage />} /> <Route path="admin" element={<AdminSettingsPage />} />

View File

@ -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 (
<button
type="button"
className={active ? 'toolbar-button toolbar-button--active' : 'toolbar-button'}
title={label}
aria-label={label}
aria-pressed={active ?? false}
disabled={disabled}
// Toolbar clicks must not steal focus/selection from the editor.
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
);
}
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 (
<ToolbarButton
label={label.remove}
active
onClick={() => editor.chain().focus().unsetLink().run()}
>
🔗
</ToolbarButton>
);
}
if (open) {
return (
<form
className="editor-link-form"
onSubmit={(event) => {
event.preventDefault();
if (isAllowedLinkHref(url)) editor.chain().focus().setLink(url).run();
setOpen(false);
setUrl('');
}}
>
<input
type="url"
aria-label={label.urlLabel}
placeholder="https://…"
value={url}
autoFocus
onChange={(event) => setUrl(event.target.value)}
/>
<button type="submit" className="toolbar-button">
{label.apply}
</button>
<button type="button" className="toolbar-button" onClick={() => setOpen(false)}>
{label.cancel}
</button>
</form>
);
}
return (
<ToolbarButton
label={label.add}
disabled={editor.state.selection.empty}
onClick={() => setOpen(true)}
>
🔗
</ToolbarButton>
);
}
/** 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 (
<div className="editor-toolbar" role="toolbar" aria-label={t('toolbar.paragraph')}>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.paragraph')}
active={state.paragraph}
onClick={() => editor.chain().focus().setParagraph().run()}
>
P
</ToolbarButton>
{([1, 2, 3, 4] as const).map((level) => (
<ToolbarButton
key={level}
label={t(`toolbar.heading${level}` as const)}
active={state[`heading${level}` as const]}
onClick={() => editor.chain().focus().toggleHeading(level).run()}
>
H{level}
</ToolbarButton>
))}
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.bold')}
active={state.bold}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<strong>B</strong>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.italic')}
active={state.italic}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<em>I</em>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.code')}
active={state.code}
onClick={() => editor.chain().focus().toggleCode().run()}
>
{'</>'}
</ToolbarButton>
<ToolbarButton
label={t('toolbar.strikethrough')}
active={state.strikethrough}
onClick={() => editor.chain().focus().toggleStrikethrough().run()}
>
<s>S</s>
</ToolbarButton>
<LinkControl
editor={editor}
label={{
add: t('toolbar.link.add'),
remove: t('toolbar.link.remove'),
urlLabel: t('toolbar.link.urlLabel'),
apply: t('toolbar.link.apply'),
cancel: t('toolbar.link.cancel'),
}}
/>
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.bulletList')}
active={state.bulletList}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.orderedList')}
active={state.orderedList}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
1.
</ToolbarButton>
<ToolbarButton
label={t('toolbar.taskList')}
active={state.taskList}
onClick={() => editor.chain().focus().toggleTaskList().run()}
>
</ToolbarButton>
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.blockquote')}
active={state.blockquote}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.codeBlock')}
active={state.codeBlock}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
>
{'{ }'}
</ToolbarButton>
<ToolbarButton
label={t('toolbar.horizontalRule')}
onClick={() => editor.chain().focus().setHorizontalRule().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.image')}
onClick={() => editor.chain().focus().insertImagePlaceholder().run()}
>
🖼
</ToolbarButton>
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.table.insert')}
onClick={() => editor.chain().focus().insertTable().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addColumnBefore')}
disabled={!state.canAddColumn}
onClick={() => editor.chain().focus().addColumnBefore().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addColumnAfter')}
disabled={!state.canAddColumn}
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.deleteColumn')}
disabled={!state.canDeleteColumn}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addRowBefore')}
disabled={!state.canAddRow}
onClick={() => editor.chain().focus().addRowBefore().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addRowAfter')}
disabled={!state.canAddRow}
onClick={() => editor.chain().focus().addRowAfter().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.deleteRow')}
disabled={!state.canDeleteRow}
onClick={() => editor.chain().focus().deleteRow().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.toggleHeaderRow')}
disabled={!state.canToggleHeaderRow}
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.deleteTable')}
disabled={!state.canDeleteTable}
onClick={() => editor.chain().focus().deleteTable().run()}
>
</ToolbarButton>
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.undo')}
disabled={!state.canUndo}
onClick={() => editor.chain().focus().undo().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.redo')}
disabled={!state.canRedo}
onClick={() => editor.chain().focus().redo().run()}
>
</ToolbarButton>
</div>
</div>
);
}

View File

@ -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');
});
});

View File

@ -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,
];

View File

@ -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<ReturnType> {
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),
};
},
});

View File

@ -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<ReturnType> {
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 (
<NodeViewWrapper
as="span"
className="editor-image-placeholder"
data-file-id={node.attrs.fileId}
>
{alt || '\u{1F5BC}'}
</NodeViewWrapper>
);
}
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(),
};
},
});

View File

@ -0,0 +1,108 @@
import { Node, wrappingInputRule } from '@tiptap/core';
import { attributesFromSpec, nodeSpec, passthroughNodeIO } from '../spec-utils';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
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,
}),
];
},
});

View File

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

View File

@ -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 (
<NodeViewWrapper as="li" data-type="task_item" data-checked={String(checked)}>
<label contentEditable={false}>
<input
type="checkbox"
checked={checked}
disabled={!editor.isEditable}
onChange={(event) => updateAttributes({ checked: event.target.checked })}
/>
</label>
<NodeViewContent as="div" />
</NodeViewWrapper>
);
}
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),
};
},
});

View File

@ -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<ReturnType> {
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(),
};
},
});

View File

@ -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<NodeConfig, 'parseHTML' | 'renderHTML'> {
return {
parseHTML: spec.parseDOM ? () => spec.parseDOM : undefined,
renderHTML: spec.toDOM ? ({ node }) => spec.toDOM!(node) : undefined,
};
}
/** Marks a table node's built schema with the `tableRole` prosemirror-tables
* needs (selection/keymap/commands key off this, not the node name), without
* having to teach TipTap's schema builder a new field for every node. */
export function extendWithTableRole(name: string, tableRole: string) {
return {
extendNodeSchema: (extension: { name: string }) =>
extension.name === name ? { tableRole } : {},
};
}

View File

@ -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<SaveStatus>('saved');
const timerRef = useRef<ReturnType<typeof setTimeout>>(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;
}

View File

@ -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);
}

View File

@ -1,9 +1,11 @@
import deAuth from '@dorfteich/shared/i18n/de/auth.json'; import deAuth from '@dorfteich/shared/i18n/de/auth.json';
import deCommon from '@dorfteich/shared/i18n/de/common.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 deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAuth from '@dorfteich/shared/i18n/en/auth.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json';
import enCommon from '@dorfteich/shared/i18n/en/common.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 enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json';
import i18n from 'i18next'; import i18n from 'i18next';
@ -21,8 +23,20 @@ void i18n
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
resources: { resources: {
en: { common: enCommon, errors: enErrors, auth: enAuth, settings: enSettings }, en: {
de: { common: deCommon, errors: deErrors, auth: deAuth, settings: deSettings }, common: enCommon,
errors: enErrors,
auth: enAuth,
settings: enSettings,
editor: enEditor,
},
de: {
common: deCommon,
errors: deErrors,
auth: deAuth,
settings: deSettings,
editor: deEditor,
},
}, },
defaultNS: 'common', defaultNS: 'common',
fallbackLng: 'en', fallbackLng: 'en',

View File

@ -1,24 +1,30 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { usePersistentState } from '../lib/use-persistent-state'; import { usePersistentState } from '../lib/use-persistent-state';
import { SidebarChromeContext } from './sidebar-chrome';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar'; import { TopBar } from './TopBar';
export function AppLayout(): React.JSX.Element { export function AppLayout(): React.JSX.Element {
const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false); const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false);
const [forcedHidden, setForcedHidden] = useState(false);
const collapsed = sidebarCollapsed || forcedHidden;
return ( return (
<SidebarChromeContext.Provider value={setForcedHidden}>
<div className="app"> <div className="app">
<TopBar <TopBar
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={collapsed}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
/> />
<div className="app-body"> <div className="app-body">
<Sidebar collapsed={sidebarCollapsed} /> <Sidebar collapsed={collapsed} />
<main className="main"> <main className="main">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
</div> </div>
</SidebarChromeContext.Provider>
); );
} }

View File

@ -0,0 +1,16 @@
import { createContext, useContext, useEffect } from 'react';
type SetSidebarForcedHidden = (hidden: boolean) => void;
export const SidebarChromeContext = createContext<SetSidebarForcedHidden>(() => {});
/** 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]);
}

View File

@ -43,6 +43,8 @@ async function requestJson<T>(method: string, path: string, body?: unknown): Pro
export const apiGet = <T>(path: string): Promise<T> => requestJson<T>('GET', path); export const apiGet = <T>(path: string): Promise<T> => requestJson<T>('GET', path);
export const apiPost = <T>(path: string, body?: unknown): Promise<T> => export const apiPost = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('POST', path, body); requestJson<T>('POST', path, body);
export const apiPut = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('PUT', path, body);
export const apiPatch = <T>(path: string, body?: unknown): Promise<T> => export const apiPatch = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('PATCH', path, body); requestJson<T>('PATCH', path, body);
export const apiDelete = <T>(path: string): Promise<T> => requestJson<T>('DELETE', path); export const apiDelete = <T>(path: string): Promise<T> => requestJson<T>('DELETE', path);

View File

@ -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<Y.Doc | null>(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 (
<div className="editor-shell">
{mode === 'edit' && <Toolbar editor={editor} />}
<div className="editor-save-indicator" role="status">
{t(`save.${saveStatus}`)}
</div>
<EditorContent editor={editor} className="editor-content" />
</div>
);
}
export function PageEditorPage(): React.JSX.Element {
const { t } = useTranslation('editor');
const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>();
const [mode, setMode] = useState<Mode>('view');
const [title, setTitle] = useState('');
useForceSidebarHidden(mode === 'edit');
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
const page = useQuery({
queryKey: ['page', pond.data?.id, pageSlug],
queryFn: () => apiGet<PageStateView>(`/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<void> {
if (!page.data || title === page.data.title) return;
await apiPatch(`/pages/${page.data.id}`, { title });
}
if (pond.error || page.error) {
return <FormError error={pond.error ?? page.error} />;
}
if (!page.data) {
return <></>;
}
return (
<div className="editor-page">
<div className="editor-page__header">
<input
type="text"
className="editor-page__title"
value={title}
placeholder={t('title.placeholder')}
disabled={mode !== 'edit'}
onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()}
/>
<button
type="button"
className="button"
onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')}
>
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
</button>
</div>
<PageEditor page={page.data} mode={mode} />
</div>
);
}

View File

@ -330,3 +330,207 @@ button {
display: flex; display: flex;
gap: var(--space-4); 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;
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

558
pnpm-lock.yaml generated
View File

@ -134,13 +134,13 @@ importers:
version: 1.5.9(@swc/core@1.15.43)(rollup@4.62.2) version: 1.5.9(@swc/core@1.15.43)(rollup@4.62.2)
vitest: vitest:
specifier: ^3.0.0 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: apps/collab:
devDependencies: devDependencies:
vitest: vitest:
specifier: ^3.0.0 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: apps/web:
dependencies: dependencies:
@ -153,12 +153,33 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.66.0 specifier: ^5.66.0
version: 5.101.2(react@19.2.7) 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: i18next:
specifier: ^26.3.4 specifier: ^26.3.4
version: 26.3.4(typescript@5.9.3) version: 26.3.4(typescript@5.9.3)
i18next-browser-languagedetector: i18next-browser-languagedetector:
specifier: ^8.2.1 specifier: ^8.2.1
version: 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: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.7 version: 19.2.7
@ -174,6 +195,9 @@ importers:
react-router-dom: react-router-dom:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 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: zod:
specifier: ^4.4.3 specifier: ^4.4.3
version: 4.4.3 version: 4.4.3
@ -190,6 +214,9 @@ importers:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.3.0 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)) 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: typescript:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 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) version: 6.4.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)
vitest: vitest:
specifier: ^3.0.0 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: packages/shared:
dependencies: 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) 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: vitest:
specifier: ^3.0.0 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: packages:
@ -264,6 +294,9 @@ packages:
resolution: {integrity: sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==} 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'} 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': '@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -358,6 +391,34 @@ packages:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'} 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': '@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
@ -867,6 +928,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@hookform/resolvers@5.4.0':
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
peerDependencies: peerDependencies:
@ -1436,6 +1506,55 @@ packages:
peerDependencies: peerDependencies:
react: ^18 || ^19 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': '@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1540,6 +1659,9 @@ packages:
'@types/supertest@6.0.3': '@types/supertest@6.0.3':
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} 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': '@typescript-eslint/eslint-plugin@8.62.1':
resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1705,6 +1827,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ajv-formats@2.1.1: ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies: peerDependencies:
@ -2020,9 +2146,17 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
cssstyle@4.6.0:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 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: dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
@ -2035,6 +2169,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-eql@5.0.2: deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -2109,6 +2246,10 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
error-ex@1.3.4: error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@ -2260,6 +2401,10 @@ packages:
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 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: fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@ -2415,6 +2560,10 @@ packages:
help-me@5.0.0: help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} 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: html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
@ -2422,6 +2571,14 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} 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: i18next-browser-languagedetector@8.2.1:
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
@ -2433,6 +2590,10 @@ packages:
typescript: typescript:
optional: true optional: true
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.2: iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2482,6 +2643,9 @@ packages:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'} engines: {node: '>=8'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-promise@4.0.0: is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@ -2521,6 +2685,15 @@ packages:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true 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: jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -2603,6 +2776,9 @@ packages:
loupe@3.2.1: loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.5.1: lru-cache@11.5.1:
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@ -2755,6 +2931,9 @@ packages:
resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
nwsapi@2.2.24:
resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==}
nypm@0.6.8: nypm@0.6.8:
resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==} resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2813,6 +2992,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'} engines: {node: '>=8'}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parseurl@1.3.3: parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -2945,6 +3127,24 @@ packages:
process-warning@5.0.0: process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} 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: prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
@ -2954,6 +3154,9 @@ packages:
prosemirror-model@1.25.9: prosemirror-model@1.25.9:
resolution: {integrity: sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==} resolution: {integrity: sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.4: prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
@ -3090,10 +3293,16 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
router@2.2.0: router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
rxjs@7.8.1: rxjs@7.8.1:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
@ -3110,6 +3319,10 @@ packages:
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@ -3276,6 +3489,9 @@ packages:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tapable@2.3.3: tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -3364,6 +3580,13 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'} 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: toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@ -3372,6 +3595,14 @@ packages:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'} 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: tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true hasBin: true
@ -3622,6 +3853,10 @@ packages:
w3c-keyname@2.2.8: w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} 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: watchpack@2.5.2:
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -3629,6 +3864,10 @@ packages:
wcwidth@1.0.1: wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} 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: webpack-node-externals@3.0.0:
resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -3650,6 +3889,19 @@ packages:
webpack-cli: webpack-cli:
optional: true 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: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -3671,6 +3923,25 @@ packages:
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 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: y-prosemirror@1.3.7:
resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'} engines: {node: '>=16.0.0', npm: '>=8.0.0'}
@ -3768,6 +4039,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - 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': '@babel/code-frame@7.29.7':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
@ -3887,6 +4166,26 @@ snapshots:
'@colors/colors@1.5.0': '@colors/colors@1.5.0':
optional: true 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': {} '@epic-web/invariant@1.0.0': {}
'@esbuild/aix-ppc64@0.25.12': '@esbuild/aix-ppc64@0.25.12':
@ -4169,6 +4468,20 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 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))': '@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))':
dependencies: dependencies:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
@ -4657,6 +4970,73 @@ snapshots:
'@tanstack/query-core': 5.101.2 '@tanstack/query-core': 5.101.2
react: 19.2.7 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': '@tokenizer/inflate@0.4.1':
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@ -4790,6 +5170,8 @@ snapshots:
'@types/methods': 1.1.4 '@types/methods': 1.1.4
'@types/superagent': 8.1.10 '@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)': '@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: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@ -5030,6 +5412,8 @@ snapshots:
acorn@8.17.0: {} acorn@8.17.0: {}
agent-base@7.1.4: {}
ajv-formats@2.1.1(ajv@8.20.0): ajv-formats@2.1.1(ajv@8.20.0):
optionalDependencies: optionalDependencies:
ajv: 8.20.0 ajv: 8.20.0
@ -5326,14 +5710,26 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
cssstyle@4.6.0:
dependencies:
'@asamuzakjp/css-color': 3.2.0
rrweb-cssom: 0.8.0
csstype@3.2.3: {} csstype@3.2.3: {}
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
dateformat@4.6.3: {} dateformat@4.6.3: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js@10.6.0: {}
deep-eql@5.0.2: {} deep-eql@5.0.2: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
@ -5393,6 +5789,8 @@ snapshots:
entities@4.5.0: {} entities@4.5.0: {}
entities@6.0.1: {}
error-ex@1.3.4: error-ex@1.3.4:
dependencies: dependencies:
is-arrayish: 0.2.1 is-arrayish: 0.2.1
@ -5647,6 +6045,8 @@ snapshots:
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-equals@5.4.0: {}
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {} fast-levenshtein@2.0.6: {}
@ -5817,6 +6217,10 @@ snapshots:
help-me@5.0.0: {} help-me@5.0.0: {}
html-encoding-sniffer@4.0.0:
dependencies:
whatwg-encoding: 3.1.1
html-parse-stringify@3.0.1: html-parse-stringify@3.0.1:
dependencies: dependencies:
void-elements: 3.1.0 void-elements: 3.1.0
@ -5829,6 +6233,20 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 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: i18next-browser-languagedetector@8.2.1:
dependencies: dependencies:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
@ -5837,6 +6255,10 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.2: iconv-lite@0.7.2:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@ -5870,6 +6292,8 @@ snapshots:
is-interactive@1.0.0: {} is-interactive@1.0.0: {}
is-potential-custom-element-name@1.0.1: {}
is-promise@4.0.0: {} is-promise@4.0.0: {}
is-unicode-supported@0.1.0: {} is-unicode-supported@0.1.0: {}
@ -5898,6 +6322,33 @@ snapshots:
dependencies: dependencies:
argparse: 2.0.1 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: {} jsesc@3.1.0: {}
json-buffer@3.0.1: {} json-buffer@3.0.1: {}
@ -5962,6 +6413,8 @@ snapshots:
loupe@3.2.1: {} loupe@3.2.1: {}
lru-cache@10.4.3: {}
lru-cache@11.5.1: {} lru-cache@11.5.1: {}
lru-cache@5.1.1: lru-cache@5.1.1:
@ -6086,6 +6539,8 @@ snapshots:
nodemailer@9.0.3: {} nodemailer@9.0.3: {}
nwsapi@2.2.24: {}
nypm@0.6.8: nypm@0.6.8:
dependencies: dependencies:
citty: 0.2.2 citty: 0.2.2
@ -6154,6 +6609,10 @@ snapshots:
json-parse-even-better-errors: 2.3.1 json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4 lines-and-columns: 1.2.4
parse5@7.3.0:
dependencies:
entities: 6.0.1
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
@ -6281,6 +6740,41 @@ snapshots:
process-warning@5.0.0: {} 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: prosemirror-keymap@1.2.3:
dependencies: dependencies:
prosemirror-state: 1.4.4 prosemirror-state: 1.4.4
@ -6296,6 +6790,12 @@ snapshots:
dependencies: dependencies:
orderedmap: 2.1.1 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: prosemirror-state@1.4.4:
dependencies: dependencies:
prosemirror-model: 1.25.9 prosemirror-model: 1.25.9
@ -6449,6 +6949,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.62.2 '@rollup/rollup-win32-x64-msvc': 4.62.2
fsevents: 2.3.3 fsevents: 2.3.3
rope-sequence@1.3.4: {}
router@2.2.0: router@2.2.0:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@ -6459,6 +6961,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
rrweb-cssom@0.8.0: {}
rxjs@7.8.1: rxjs@7.8.1:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -6473,6 +6977,10 @@ snapshots:
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {} scheduler@0.27.0: {}
schema-utils@3.3.0: schema-utils@3.3.0:
@ -6660,6 +7168,8 @@ snapshots:
symbol-observable@4.0.0: {} symbol-observable@4.0.0: {}
symbol-tree@3.2.4: {}
tapable@2.3.3: {} tapable@2.3.3: {}
terser-webpack-plugin@5.6.1(@swc/core@1.15.43)(webpack@5.106.2(@swc/core@1.15.43)): 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: {} tinyspy@4.0.4: {}
tldts-core@6.1.86: {}
tldts@6.1.86:
dependencies:
tldts-core: 6.1.86
toidentifier@1.0.1: {} toidentifier@1.0.1: {}
token-types@6.1.2: token-types@6.1.2:
@ -6716,6 +7232,14 @@ snapshots:
'@tokenizer/token': 0.3.0 '@tokenizer/token': 0.3.0
ieee754: 1.2.1 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: {} tree-kill@1.2.2: {}
ts-api-utils@2.5.0(typescript@5.9.3): ts-api-utils@2.5.0(typescript@5.9.3):
@ -6905,7 +7429,7 @@ snapshots:
terser: 5.48.0 terser: 5.48.0
tsx: 4.23.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: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/expect': 3.2.6 '@vitest/expect': 3.2.6
@ -6932,6 +7456,7 @@ snapshots:
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 26.1.0 '@types/node': 26.1.0
jsdom: 26.1.0
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti
- less - less
@ -6950,6 +7475,10 @@ snapshots:
w3c-keyname@2.2.8: {} w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
watchpack@2.5.2: watchpack@2.5.2:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@ -6958,6 +7487,8 @@ snapshots:
dependencies: dependencies:
defaults: 1.0.4 defaults: 1.0.4
webidl-conversions@7.0.0: {}
webpack-node-externals@3.0.0: {} webpack-node-externals@3.0.0: {}
webpack-sources@3.5.0: {} webpack-sources@3.5.0: {}
@ -7004,6 +7535,17 @@ snapshots:
- postcss - postcss
- uglify-js - 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: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
@ -7023,6 +7565,12 @@ snapshots:
wrappy@1.0.2: {} 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): 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: dependencies:
lib0: 0.2.117 lib0: 0.2.117