diff --git a/apps/web/e2e/link.spec.ts b/apps/web/e2e/link.spec.ts new file mode 100644 index 0000000..ca4d106 --- /dev/null +++ b/apps/web/e2e/link.spec.ts @@ -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>, + 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 { + 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(); +}); diff --git a/apps/web/package.json b/apps/web/package.json index cb9b5a3..f161213 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "@hookform/resolvers": "^5.4.0", "@tanstack/react-query": "^5.66.0", "@tiptap/core": "^3.27.1", + "@tiptap/extension-bubble-menu": "^3.27.1", "@tiptap/extension-collaboration": "^3.27.1", "@tiptap/pm": "^3.27.1", "@tiptap/react": "^3.27.1", diff --git a/apps/web/src/editor/LinkMenu.tsx b/apps/web/src/editor/LinkMenu.tsx new file mode 100644 index 0000000..c00f47e --- /dev/null +++ b/apps/web/src/editor/LinkMenu.tsx @@ -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 ( + <> + + state.active || forcedOpen} + options={{ + onHide: () => { + setEditing(false); + setForcedOpen(false); + }, + }} + > +
+ {editing ? ( +
{ + event.preventDefault(); + apply(); + }} + > + { + setUrl(event.target.value); + setInvalid(false); + }} + /> + + + {invalid && ( + {t('toolbar.link.invalidProtocol')} + )} +
+ ) : ( +
+ + + +
+ )} +
+
+ + ); +} diff --git a/apps/web/src/editor/Toolbar.tsx b/apps/web/src/editor/Toolbar.tsx index 0eafb65..2c09334 100644 --- a/apps/web/src/editor/Toolbar.tsx +++ b/apps/web/src/editor/Toolbar.tsx @@ -1,9 +1,10 @@ -import { isAllowedLinkHref } from '@dorfteich/shared'; import type { Editor } from '@tiptap/core'; import { useEditorState } from '@tiptap/react'; -import { useRef, useState } from 'react'; +import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { LinkMenu } from './LinkMenu'; + interface ToolbarProps { 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 ( - editor.chain().focus().unsetLink().run()} - > - 🔗 - - ); - } - - if (open) { - return ( -
{ - event.preventDefault(); - if (isAllowedLinkHref(url)) editor.chain().focus().setLink(url).run(); - setOpen(false); - setUrl(''); - }} - > - setUrl(event.target.value)} - /> - - -
- ); - } - - return ( - setOpen(true)} - > - 🔗 - - ); -} - /** 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 * 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 - +
diff --git a/apps/web/src/editor/marks.ts b/apps/web/src/editor/marks.ts index db13210..50f0fe9 100644 --- a/apps/web/src/editor/marks.ts +++ b/apps/web/src/editor/marks.ts @@ -1,5 +1,6 @@ import { isAllowedLinkHref } from '@dorfteich/shared'; import { Mark, markInputRule, markPasteRule } from '@tiptap/core'; +import { Plugin } from '@tiptap/pm/state'; import { attributesFromSpec, markSpec } from './spec-utils'; @@ -14,6 +15,9 @@ declare module '@tiptap/core' { unsetLink: () => ReturnType; }; } + interface Storage { + link: { editRequestedAt: number }; + } } 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 - * a link is just a mark applicable to a selection with a validated href. */ +/** Link mark + editing UX (issue #29): a bubble menu (`LinkMenu.tsx`) offers + * "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'); export const LinkMark = Mark.create({ name: 'link', @@ -116,6 +126,9 @@ export const LinkMark = Mark.create({ }, parseHTML: () => linkSpec.parseDOM, renderHTML: ({ mark }) => linkSpec.toDOM!(mark, true), + addStorage() { + return { editRequestedAt: 0 }; + }, addCommands() { return { setLink: @@ -128,4 +141,52 @@ export const LinkMark = Mark.create({ 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; + }, + }, + }), + ]; + }, }); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 0842888..845ce02 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -510,6 +510,25 @@ button { 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 { padding: var(--space-1) var(--space-3); font-size: 0.85rem; diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index e7e1d81..b452186 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -35,10 +35,13 @@ "redo": "Wiederholen", "link": { "add": "Link hinzufügen", + "edit": "URL bearbeiten", "remove": "Link entfernen", + "openNewTab": "In neuem Tab öffnen", "urlLabel": "Link-URL", "apply": "Übernehmen", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "invalidProtocol": "Dieser Link-Typ ist nicht erlaubt." }, "table": { "insert": "Tabelle einfügen", diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index 741f78b..dc90a0a 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -35,10 +35,13 @@ "redo": "Redo", "link": { "add": "Add link", + "edit": "Edit URL", "remove": "Remove link", + "openNewTab": "Open in new tab", "urlLabel": "Link URL", "apply": "Apply", - "cancel": "Cancel" + "cancel": "Cancel", + "invalidProtocol": "This link type is not allowed." }, "table": { "insert": "Insert table", diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts index ef2d6d7..f7c4c81 100644 --- a/packages/shared/src/editor-schema/html.ts +++ b/packages/shared/src/editor-schema/html.ts @@ -30,7 +30,7 @@ function openTagFor(mark: Mark): string { if (mark.type.name === 'link') { const href = mark.attrs.href as string; const safeHref = isAllowedLinkHref(href) ? href : '#'; - return ``; + return ``; } return `<${tagNameFor(mark)}>`; } diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index e91bbc9..9613043 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -170,7 +170,17 @@ export const editorSchema = new Schema({ inclusive: false, attrs: { href: { validate: 'string' } }, 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, + ], }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3121293..a6985ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: '@tiptap/core': specifier: ^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': 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) @@ -4480,16 +4483,13 @@ snapshots: '@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 + '@floating-ui/utils@0.2.11': {} '@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))': dependencies: @@ -4988,7 +4988,6 @@ snapshots: '@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: