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
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:
parent
c8be3cd85e
commit
b5cc4c34b8
176
apps/web/e2e/link.spec.ts
Normal file
176
apps/web/e2e/link.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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",
|
||||
|
||||
171
apps/web/src/editor/LinkMenu.tsx
Normal file
171
apps/web/src/editor/LinkMenu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<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) —
|
||||
* 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>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'),
|
||||
}}
|
||||
/>
|
||||
<LinkMenu editor={editor} />
|
||||
</div>
|
||||
|
||||
<div className="editor-toolbar__group">
|
||||
|
||||
@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 `<a href="${escapeHtml(safeHref)}" rel="noopener noreferrer">`;
|
||||
return `<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener noreferrer">`;
|
||||
}
|
||||
return `<${tagNameFor(mark)}>`;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user