Add link UX: edit URL and open in new tab (#29)
All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m41s
CI / Auth e2e pack (push) Successful in 1m49s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 9s

A bubble menu on link selection offers "edit URL", "open in new tab",
and "remove link"; Mod-k opens the same editor for the current
selection (creating a link if there isn't one yet), and the toolbar
button does the same. Invalid protocols (e.g. javascript:) show a
localized inline error instead of silently no-oping. Pasting a URL
over selected text links it instead of replacing the text.

Links always render with target="_blank" so read mode opens them in a
new tab by default; edit mode suppresses the resulting navigate-on-
click (Mod-click still follows it), since a plain click there should
place the cursor instead.

Closes #29
This commit is contained in:
Claude Sonnet 5 2026-07-08 11:50:34 +02:00
parent c8be3cd85e
commit b5cc4c34b8
11 changed files with 458 additions and 88 deletions

176
apps/web/e2e/link.spec.ts Normal file
View File

@ -0,0 +1,176 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Link editing pack (issue #29). Runs against the local dev stack (api +
* web); no Mailpit needed.
*/
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 };
}
async function enterEditModeWithText(page: Page, text: string): Promise<void> {
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(text);
await page.keyboard.press('ControlOrMeta+a');
}
test('toolbar link button links the selection', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Link Toolbar ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditModeWithText(page, 'dorfteich docs');
await page.getByRole('button', { name: /add link|link hinzufügen/i }).click();
await page.getByLabel(/link url|link-url/i).fill('https://example.org');
await page.getByRole('button', { name: /apply|übernehmen/i }).click();
await expect(page.locator('.ProseMirror a[href="https://example.org"]')).toHaveText(
'dorfteich docs',
);
await context.close();
});
test('rejects invalid link protocols with a localized hint', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Link Invalid ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditModeWithText(page, 'danger zone');
await page.getByRole('button', { name: /add link|link hinzufügen/i }).click();
await page.getByLabel(/link url|link-url/i).fill('javascript:alert(1)');
await page.getByRole('button', { name: /apply|übernehmen/i }).click();
await expect(page.locator('.editor-link-form__error')).toBeVisible();
await expect(page.locator('.ProseMirror a')).toHaveCount(0);
await context.close();
});
test('Mod-k edits the URL of an existing link', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Link ModK ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditModeWithText(page, 'edit me');
await page.getByRole('button', { name: /add link|link hinzufügen/i }).click();
await page.getByLabel(/link url|link-url/i).fill('https://example.org/old');
await page.getByRole('button', { name: /apply|übernehmen/i }).click();
await expect(page.locator('.ProseMirror a[href="https://example.org/old"]')).toBeVisible();
// Select the link text again, then edit it via the keyboard shortcut.
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('ControlOrMeta+k');
const urlInput = page.getByLabel(/link url|link-url/i);
await expect(urlInput).toHaveValue('https://example.org/old');
await urlInput.fill('https://example.org/new');
await page.getByRole('button', { name: /apply|übernehmen/i }).click();
await expect(page.locator('.ProseMirror a[href="https://example.org/new"]')).toBeVisible();
await context.close();
});
test('bubble menu removes a link', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Link Remove ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditModeWithText(page, 'remove me');
await page.getByRole('button', { name: /add link|link hinzufügen/i }).click();
await page.getByLabel(/link url|link-url/i).fill('https://example.org');
await page.getByRole('button', { name: /apply|übernehmen/i }).click();
await expect(page.locator('.ProseMirror a')).toBeVisible();
await page.locator('.ProseMirror a').click();
await page.getByRole('button', { name: /remove link|link entfernen/i }).click();
await expect(page.locator('.ProseMirror a')).toHaveCount(0);
await context.close();
});
test('bubble menu "open in new tab" opens the link href', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Link OpenTab ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditModeWithText(page, 'open me');
await page.getByRole('button', { name: /add link|link hinzufügen/i }).click();
await page.getByLabel(/link url|link-url/i).fill('https://example.org/open-me');
await page.getByRole('button', { name: /apply|übernehmen/i }).click();
await page.locator('.ProseMirror a').click();
const [popup] = await Promise.all([
context.waitForEvent('page'),
page.getByRole('button', { name: /open in new tab|in neuem tab öffnen/i }).click(),
]);
await popup.waitForLoadState();
expect(popup.url()).toBe('https://example.org/open-me');
await context.close();
});
test('read mode links open in a new tab by default', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Link ReadMode ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditModeWithText(page, 'read mode link');
await page.getByRole('button', { name: /add link|link hinzufügen/i }).click();
await page.getByLabel(/link url|link-url/i).fill('https://example.org');
await page.getByRole('button', { name: /apply|übernehmen/i }).click();
await page.getByRole('button', { name: /read|lesen/i }).click();
await expect(page.locator('.ProseMirror a')).toHaveAttribute('target', '_blank');
await context.close();
});
test('pasting a URL over a selection links the selected text', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Link Paste ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditModeWithText(page, 'wrap this');
await page.evaluate(() => {
const el = document.querySelector('.ProseMirror');
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', 'https://example.org/pasted');
el!.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
);
});
await expect(page.locator('.ProseMirror a[href="https://example.org/pasted"]')).toHaveText(
'wrap this',
);
await context.close();
});

View File

@ -18,6 +18,7 @@
"@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/core": "^3.27.1",
"@tiptap/extension-bubble-menu": "^3.27.1",
"@tiptap/extension-collaboration": "^3.27.1", "@tiptap/extension-collaboration": "^3.27.1",
"@tiptap/pm": "^3.27.1", "@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1", "@tiptap/react": "^3.27.1",

View File

@ -0,0 +1,171 @@
import { isAllowedLinkHref } from '@dorfteich/shared';
import type { Editor } from '@tiptap/core';
import { BubbleMenu } from '@tiptap/react/menus';
import { useEditorState } from '@tiptap/react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
/**
* Link editing UX (issue #29): a bubble menu on link selection offering
* "edit URL" / "open in new tab" / "remove link", plus a toolbar-accessible
* trigger and `Mod-k` for creating/editing a link on the current selection.
* Read mode needs none of this links there always open in a new tab via
* `target="_blank"` from the schema itself (marks.ts).
*/
export function LinkMenu({ editor }: { editor: Editor }): React.JSX.Element | null {
const { t } = useTranslation('editor');
const [editing, setEditing] = useState(false);
const [forcedOpen, setForcedOpen] = useState(false);
const [url, setUrl] = useState('');
const [invalid, setInvalid] = useState(false);
const state = useEditorState({
editor,
selector: ({ editor: e }) => ({
active: e.isActive('link'),
href: (e.getAttributes('link').href as string | undefined) ?? '',
editRequestedAt: e.storage.link.editRequestedAt,
selectionEmpty: e.state.selection.empty,
}),
});
// `Mod-k` (marks.ts keyboard shortcut) pings `editRequestedAt`; open the
// editing form for the current selection whenever it changes. `state.href`
// is read fresh from `state` inside the effect (not a dependency) so a
// selection change alone never reopens an already-closed editor.
useEffect(() => {
if (state.editRequestedAt === 0) return;
setUrl(state.href);
setInvalid(false);
setEditing(true);
setForcedOpen(true);
}, [state.editRequestedAt]);
function openForSelection(): void {
setUrl(state.href);
setInvalid(false);
setEditing(true);
setForcedOpen(true);
}
function apply(): void {
if (!isAllowedLinkHref(url)) {
setInvalid(true);
return;
}
// `extendMarkRange` first: a click just places a collapsed cursor
// inside the link, and `setMark`/`unsetMark` on an empty selection
// apply to future typing only, not the existing linked text.
editor.chain().focus().extendMarkRange('link').setLink(url).run();
setEditing(false);
setForcedOpen(false);
}
if (!editor.isEditable) return null;
return (
<>
<button
type="button"
className="toolbar-button"
title={t('toolbar.link.add')}
aria-label={t('toolbar.link.add')}
disabled={state.selectionEmpty && !state.active}
onMouseDown={(event) => event.preventDefault()}
onClick={openForSelection}
>
🔗
</button>
<BubbleMenu
editor={editor}
pluginKey="linkMenu"
// Deliberately not requiring a non-empty selection: placing a plain
// cursor inside an existing link (the natural "click a link to edit
// it" gesture) is a collapsed selection, and should still show the
// menu. `forcedOpen` (Mod-k / toolbar button) already only fires
// from a non-empty-selection context, so this can't show spuriously.
shouldShow={() => state.active || forcedOpen}
options={{
onHide: () => {
setEditing(false);
setForcedOpen(false);
},
}}
>
<div className="editor-link-menu">
{editing ? (
<form
className="editor-link-form"
onSubmit={(event) => {
event.preventDefault();
apply();
}}
>
<input
type="url"
aria-label={t('toolbar.link.urlLabel')}
placeholder="https://…"
value={url}
autoFocus
onChange={(event) => {
setUrl(event.target.value);
setInvalid(false);
}}
/>
<button type="submit" className="toolbar-button">
{t('toolbar.link.apply')}
</button>
<button
type="button"
className="toolbar-button"
onClick={() => {
setEditing(false);
setForcedOpen(false);
}}
>
{t('toolbar.link.cancel')}
</button>
{invalid && (
<span className="editor-link-form__error">{t('toolbar.link.invalidProtocol')}</span>
)}
</form>
) : (
<div className="editor-link-menu__actions">
<button
type="button"
className="toolbar-button"
title={t('toolbar.link.edit')}
onClick={() => {
setUrl(state.href);
setInvalid(false);
setEditing(true);
}}
>
{t('toolbar.link.edit')}
</button>
<button
type="button"
className="toolbar-button"
title={t('toolbar.link.openNewTab')}
onClick={() => window.open(state.href, '_blank', 'noopener,noreferrer')}
>
{t('toolbar.link.openNewTab')}
</button>
<button
type="button"
className="toolbar-button"
title={t('toolbar.link.remove')}
onClick={() => {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
setForcedOpen(false);
}}
>
{t('toolbar.link.remove')}
</button>
</div>
)}
</div>
</BubbleMenu>
</>
);
}

View File

@ -1,9 +1,10 @@
import { isAllowedLinkHref } from '@dorfteich/shared';
import type { Editor } from '@tiptap/core'; import type { Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react'; import { useEditorState } from '@tiptap/react';
import { useRef, useState } from 'react'; import { useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LinkMenu } from './LinkMenu';
interface ToolbarProps { interface ToolbarProps {
editor: Editor; editor: Editor;
} }
@ -38,71 +39,6 @@ function ToolbarButton({
); );
} }
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>
);
}
/** File-picker path into the same async upload as paste/drop (issue #28) /** File-picker path into the same async upload as paste/drop (issue #28)
* a hidden native file input triggered by a normal toolbar button, since * a hidden native file input triggered by a normal toolbar button, since
* that is the only way to open the OS file dialog from a click handler. */ * that is the only way to open the OS file dialog from a click handler. */
@ -218,16 +154,7 @@ export function Toolbar({ editor }: ToolbarProps): React.JSX.Element {
> >
<s>S</s> <s>S</s>
</ToolbarButton> </ToolbarButton>
<LinkControl <LinkMenu editor={editor} />
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>
<div className="editor-toolbar__group"> <div className="editor-toolbar__group">

View File

@ -1,5 +1,6 @@
import { isAllowedLinkHref } from '@dorfteich/shared'; import { isAllowedLinkHref } from '@dorfteich/shared';
import { Mark, markInputRule, markPasteRule } from '@tiptap/core'; import { Mark, markInputRule, markPasteRule } from '@tiptap/core';
import { Plugin } from '@tiptap/pm/state';
import { attributesFromSpec, markSpec } from './spec-utils'; import { attributesFromSpec, markSpec } from './spec-utils';
@ -14,6 +15,9 @@ declare module '@tiptap/core' {
unsetLink: () => ReturnType; unsetLink: () => ReturnType;
}; };
} }
interface Storage {
link: { editRequestedAt: number };
}
} }
const boldSpec = markSpec('bold'); const boldSpec = markSpec('bold');
@ -105,8 +109,14 @@ export const Strikethrough = Mark.create({
}, },
}); });
/** Full link-editing UX (bubble menu, "open in new tab") is issue #29; here /** Link mark + editing UX (issue #29): a bubble menu (`LinkMenu.tsx`) offers
* a link is just a mark applicable to a selection with a validated href. */ * "edit URL"/"open in new tab"/"remove link" on selection; `Mod-k` opens the
* same menu for the current selection via the `editRequestedAt` storage
* ping (`LinkMenu` reacts to it through `useEditorState`, since a keyboard
* shortcut here has no direct handle on that component's React state).
* Reading (non-editable) documents always render links with
* `target="_blank"` (schema.ts `toDOM`), so "open in new tab" there is just
* default anchor behavior no menu needed outside edit mode. */
const linkSpec = markSpec('link'); const linkSpec = markSpec('link');
export const LinkMark = Mark.create({ export const LinkMark = Mark.create({
name: 'link', name: 'link',
@ -116,6 +126,9 @@ export const LinkMark = Mark.create({
}, },
parseHTML: () => linkSpec.parseDOM, parseHTML: () => linkSpec.parseDOM,
renderHTML: ({ mark }) => linkSpec.toDOM!(mark, true), renderHTML: ({ mark }) => linkSpec.toDOM!(mark, true),
addStorage() {
return { editRequestedAt: 0 };
},
addCommands() { addCommands() {
return { return {
setLink: setLink:
@ -128,4 +141,52 @@ export const LinkMark = Mark.create({
commands.unsetMark(this.name), commands.unsetMark(this.name),
}; };
}, },
addKeyboardShortcuts() {
return {
'Mod-k': () => {
if (this.editor.state.selection.empty) return false;
this.storage.editRequestedAt = Date.now();
// An empty transaction still fires the editor's 'transaction' event,
// which is what `useEditorState` (LinkMenu.tsx) reacts to — plain
// storage mutations alone would otherwise go unnoticed by React.
this.editor.view.dispatch(this.editor.state.tr);
return true;
},
};
},
addProseMirrorPlugins() {
const linkType = this.type;
return [
new Plugin({
props: {
handleDOMEvents: {
// Links always carry target="_blank" (schema.ts, so read mode
// opens them in a new tab by default) — but a plain click on
// one while editing would then actually navigate instead of
// placing the cursor. Suppress that; Mod-click still follows
// the link, mirroring the bubble menu's "open in new tab".
click(view, event) {
if (!view.editable || event.metaKey || event.ctrlKey) return false;
if (!(event.target instanceof HTMLElement) || !event.target.closest('a'))
return false;
event.preventDefault();
return true;
},
},
// Pasting a URL over a text selection links the selected text
// instead of replacing it (issue #29 acceptance criterion).
handlePaste(view, event) {
const { selection } = view.state;
if (selection.empty) return false;
const text = event.clipboardData?.getData('text/plain')?.trim();
if (!text || !isAllowedLinkHref(text)) return false;
view.dispatch(
view.state.tr.addMark(selection.from, selection.to, linkType.create({ href: text })),
);
return true;
},
},
}),
];
},
}); });

View File

@ -510,6 +510,25 @@ button {
font-size: 0.9rem; font-size: 0.9rem;
} }
.editor-link-form__error {
flex-basis: 100%;
font-size: 0.8rem;
color: var(--color-danger);
}
.editor-link-menu {
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
}
.editor-link-menu__actions {
display: flex;
gap: var(--space-1);
}
.editor-save-indicator { .editor-save-indicator {
padding: var(--space-1) var(--space-3); padding: var(--space-1) var(--space-3);
font-size: 0.85rem; font-size: 0.85rem;

View File

@ -35,10 +35,13 @@
"redo": "Wiederholen", "redo": "Wiederholen",
"link": { "link": {
"add": "Link hinzufügen", "add": "Link hinzufügen",
"edit": "URL bearbeiten",
"remove": "Link entfernen", "remove": "Link entfernen",
"openNewTab": "In neuem Tab öffnen",
"urlLabel": "Link-URL", "urlLabel": "Link-URL",
"apply": "Übernehmen", "apply": "Übernehmen",
"cancel": "Abbrechen" "cancel": "Abbrechen",
"invalidProtocol": "Dieser Link-Typ ist nicht erlaubt."
}, },
"table": { "table": {
"insert": "Tabelle einfügen", "insert": "Tabelle einfügen",

View File

@ -35,10 +35,13 @@
"redo": "Redo", "redo": "Redo",
"link": { "link": {
"add": "Add link", "add": "Add link",
"edit": "Edit URL",
"remove": "Remove link", "remove": "Remove link",
"openNewTab": "Open in new tab",
"urlLabel": "Link URL", "urlLabel": "Link URL",
"apply": "Apply", "apply": "Apply",
"cancel": "Cancel" "cancel": "Cancel",
"invalidProtocol": "This link type is not allowed."
}, },
"table": { "table": {
"insert": "Insert table", "insert": "Insert table",

View File

@ -30,7 +30,7 @@ function openTagFor(mark: Mark): string {
if (mark.type.name === 'link') { if (mark.type.name === 'link') {
const href = mark.attrs.href as string; const href = mark.attrs.href as string;
const safeHref = isAllowedLinkHref(href) ? href : '#'; const safeHref = isAllowedLinkHref(href) ? href : '#';
return `<a href="${escapeHtml(safeHref)}" rel="noopener noreferrer">`; return `<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener noreferrer">`;
} }
return `<${tagNameFor(mark)}>`; return `<${tagNameFor(mark)}>`;
} }

View File

@ -170,7 +170,17 @@ export const editorSchema = new Schema({
inclusive: false, inclusive: false,
attrs: { href: { validate: 'string' } }, attrs: { href: { validate: 'string' } },
parseDOM: [{ tag: 'a[href]' }], parseDOM: [{ tag: 'a[href]' }],
toDOM: (mark) => ['a', { href: mark.attrs.href as string }, 0], // `target=_blank` unconditionally: inside the editor (contentEditable)
// clicking never navigates anyway, and it is what makes "open in new
// tab" the default behavior in read mode without any extra UI there
// (issue #29 acceptance criterion) — edit mode gets an explicit
// bubble-menu action instead (LinkMenu.tsx), since a plain click there
// only moves the cursor.
toDOM: (mark) => [
'a',
{ href: mark.attrs.href as string, target: '_blank', rel: 'noopener noreferrer' },
0,
],
}, },
}, },
}); });

9
pnpm-lock.yaml generated
View File

@ -162,6 +162,9 @@ importers:
'@tiptap/core': '@tiptap/core':
specifier: ^3.27.1 specifier: ^3.27.1
version: 3.27.1(@tiptap/pm@3.27.1) version: 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/extension-bubble-menu':
specifier: ^3.27.1
version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
'@tiptap/extension-collaboration': '@tiptap/extension-collaboration':
specifier: ^3.27.1 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) 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)
@ -4480,16 +4483,13 @@ snapshots:
'@floating-ui/core@1.7.5': '@floating-ui/core@1.7.5':
dependencies: dependencies:
'@floating-ui/utils': 0.2.11 '@floating-ui/utils': 0.2.11
optional: true
'@floating-ui/dom@1.7.6': '@floating-ui/dom@1.7.6':
dependencies: dependencies:
'@floating-ui/core': 1.7.5 '@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11 '@floating-ui/utils': 0.2.11
optional: true
'@floating-ui/utils@0.2.11': '@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:
@ -4988,7 +4988,6 @@ snapshots:
'@floating-ui/dom': 1.7.6 '@floating-ui/dom': 1.7.6
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) '@tiptap/core': 3.27.1(@tiptap/pm@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)': '@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: dependencies: