From 4350ddb84af01ceb799a16d479e9a8b909ce6e31 Mon Sep 17 00:00:00 2001 From: lunaqiu Date: Tue, 25 Aug 2026 20:45:53 +0800 Subject: [PATCH] fix(canvas): make preview links clickable --- .../components/Milkdown/MilkdownPreview.tsx | 3 +- .../Milkdown/__tests__/blockCommands.test.ts | 23 +++++++-- .../__tests__/previewAccessibility.test.tsx | 24 +++++++++ .../src/components/Milkdown/createMilkdown.ts | 50 ++++++++++--------- docs/architecture/note-node.md | 16 +++--- 5 files changed, 80 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/Milkdown/MilkdownPreview.tsx b/apps/web/src/components/Milkdown/MilkdownPreview.tsx index e2f017bf0..ec878fa67 100644 --- a/apps/web/src/components/Milkdown/MilkdownPreview.tsx +++ b/apps/web/src/components/Milkdown/MilkdownPreview.tsx @@ -20,6 +20,7 @@ * with `MilkdownEditor`); see `blockDrag.ts` for design notes. */ +import clsx from 'clsx'; import { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -265,7 +266,7 @@ export function MilkdownPreview( return (
{ const root = document.createElement('div'); document.body.appendChild(root); @@ -491,11 +491,28 @@ describe('Milkdown block commands', () => { expect(open).not.toHaveBeenCalled(); }); - it('opens a link on modifier-click in a read-only surface', async () => { + it('opens a link on plain click in a read-only surface', async () => { const open = vi.spyOn(window, 'open').mockReturnValue(null); await mount('see [docs](https://example.com) here', { editable: false }); - clickLink({ modifier: true }); + const event = clickLink({ modifier: false }); + + expect(open).toHaveBeenCalledWith( + 'https://example.com', + '_blank', + 'noopener,noreferrer', + ); + expect(event.defaultPrevented).toBe(true); + }); + + it('opens a link on plain click in a drag-only preview', async () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null); + await mount('see [docs](https://example.com) here', { + editable: true, + previewMode: true, + }); + + clickLink({ modifier: false }); expect(open).toHaveBeenCalledWith( 'https://example.com', diff --git a/apps/web/src/components/Milkdown/__tests__/previewAccessibility.test.tsx b/apps/web/src/components/Milkdown/__tests__/previewAccessibility.test.tsx index af6a8ccc2..e660339e5 100644 --- a/apps/web/src/components/Milkdown/__tests__/previewAccessibility.test.tsx +++ b/apps/web/src/components/Milkdown/__tests__/previewAccessibility.test.tsx @@ -23,6 +23,30 @@ afterEach(() => { }); describe('MilkdownPreview accessibility', () => { + it('keeps links hit-testable when the preview host is not', async () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + , + ); + }); + + await vi.waitFor(() => { + expect( + container?.querySelector('a[href="https://example.com"]'), + ).not.toBeNull(); + }); + expect(container.firstElementChild?.classList).toContain( + '[&_a]:pointer-events-auto', + ); + }); + it('names the textbox through StrictMode replacement and updates an override', async () => { container = document.createElement('div'); document.body.appendChild(container); diff --git a/apps/web/src/components/Milkdown/createMilkdown.ts b/apps/web/src/components/Milkdown/createMilkdown.ts index 1db3ba6f3..c5e4fd6c6 100644 --- a/apps/web/src/components/Milkdown/createMilkdown.ts +++ b/apps/web/src/components/Milkdown/createMilkdown.ts @@ -1600,13 +1600,13 @@ function runBlockTypeCommand(ctx: Ctx, key: MilkdownBlockType): void { } /** - * Follow the link under the cursor on a modifier-click, and block clicks on - * links the app would never create itself. + * Follow the link under the cursor, and block clicks on links the app would + * never create itself. * - * A plain click has to keep placing the caret so link text stays editable, so - * navigation is bound to the platform's "follow" modifier instead — `Cmd` on - * macOS, where `Ctrl`-click is the secondary-click gesture, and `Ctrl` - * elsewhere. + * Editable surfaces reserve a plain click for placing the caret, so navigation + * there uses the platform's "follow" modifier — `Cmd` on macOS, where + * `Ctrl`-click is the secondary-click gesture, and `Ctrl` elsewhere. Read-only + * surfaces have no caret-editing conflict and follow a plain primary click. * * The href is validated here rather than trusted from the mark: only `setLink` * screens what the user types, while markdown parsed from an agent reply, a @@ -1615,24 +1615,27 @@ function runBlockTypeCommand(ctx: Ctx, key: MilkdownBlockType): void { * activated (a click), so an unsafe href has its default suppressed while the * event keeps flowing to ProseMirror's own selection handling. */ -function handleLinkClick(view: EditorView, event: Event): boolean { - const mouseEvent = event as MouseEvent; - const target = mouseEvent.target; - if (!(target instanceof Element)) return false; - const anchor = target.closest('a[href]'); - if (!anchor || !view.dom.contains(anchor)) return false; - - const href = normalizeSafeLinkHref(anchor.getAttribute('href')); - if (!href) { - mouseEvent.preventDefault(); - return false; - } +function createLinkClickHandler(allowPlainClick: boolean) { + return (view: EditorView, event: Event): boolean => { + const mouseEvent = event as MouseEvent; + const target = mouseEvent.target; + if (!(target instanceof Element)) return false; + const anchor = target.closest('a[href]'); + if (!anchor || !view.dom.contains(anchor)) return false; + + const href = normalizeSafeLinkHref(anchor.getAttribute('href')); + if (!href) { + mouseEvent.preventDefault(); + return false; + } - if (mouseEvent.button !== 0) return false; - if (!(isMac ? mouseEvent.metaKey : mouseEvent.ctrlKey)) return false; - mouseEvent.preventDefault(); - window.open(href, '_blank', 'noopener,noreferrer'); - return true; + if (mouseEvent.button !== 0) return false; + const hasFollowModifier = isMac ? mouseEvent.metaKey : mouseEvent.ctrlKey; + if (!allowPlainClick && !hasFollowModifier) return false; + mouseEvent.preventDefault(); + window.open(href, '_blank', 'noopener,noreferrer'); + return true; + }; } type TabContext = 'list' | 'text' | 'other'; @@ -1764,6 +1767,7 @@ export async function createMilkdown( } = options; const resolveImageSrc = options.resolveImageSrc ?? ((src: string) => src); const useReactToolbar = !previewMode && toolbarMode === 'huabu'; + const handleLinkClick = createLinkClickHandler(previewMode || !editable); let ariaLabel = initialAriaLabel; // Normalize LaTeX-style math delimiters (`\[…\]`, `\(…\)`) diff --git a/docs/architecture/note-node.md b/docs/architecture/note-node.md index 852c1aa1c..20e6b34a4 100644 --- a/docs/architecture/note-node.md +++ b/docs/architecture/note-node.md @@ -43,11 +43,11 @@ Both surfaces are built by the same [`createMilkdown`](../../apps/web/src/compon Everything from here on is what happens **inside** the document. Pointer routing up to that point — which gesture the canvas claims before the event ever reaches a note — belongs to [canvas-input-interactions.md](./canvas-input-interactions.md). -| Surface | Mount | Notes | -| ------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`MilkdownEditor`](../../apps/web/src/components/Milkdown/MilkdownEditor.tsx) | `editable: true` | Full editing. React owns the chrome, so Crepe's own Toolbar / LinkTooltip are off. | -| [`MilkdownPreview`](../../apps/web/src/components/Milkdown/MilkdownPreview.tsx) | `editable: false` | Pure display. `contenteditable=false` communicates read-only on its own. | -| `MilkdownPreview` with `enableBlockDrag` | `editable: true` + `previewMode: true` | ProseMirror must stay editable for the block-drag handle to be hit-testable, so every input verb is swallowed at the capture phase and `aria-readonly` is set instead. | +| Surface | Mount | Notes | +| ------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`MilkdownEditor`](../../apps/web/src/components/Milkdown/MilkdownEditor.tsx) | `editable: true` | Full editing. React owns the chrome, so Crepe's own Toolbar / LinkTooltip are off; links require Ctrl/Cmd-click so a plain click can place the caret. | +| [`MilkdownPreview`](../../apps/web/src/components/Milkdown/MilkdownPreview.tsx) | `editable: false` | Pure display. `contenteditable=false` communicates read-only on its own, and a plain primary click opens a link. | +| `MilkdownPreview` with `enableBlockDrag` | `editable: true` + `previewMode: true` | ProseMirror must stay editable for the block-drag handle to be hit-testable, so every input verb is swallowed at the capture phase, `aria-readonly` is set, and links open on a plain primary click. | --- @@ -84,12 +84,10 @@ In `MilkdownPreview`, `Tab` is deliberately _not_ in the swallowed-key set: its ## 6. Link activation -`Ctrl`-click (`Cmd` on macOS, where `Ctrl`-click is the secondary-click gesture) opens the link under the pointer. A plain click is reserved for placing the caret, which is what keeps link text editable — the same convention as VS Code, Word and Obsidian. Only the primary button counts. - -Because the handler lives in the shared factory, this works in the read-only preview too. +In an editable note, `Ctrl`-click (`Cmd` on macOS, where `Ctrl`-click is the secondary-click gesture) opens the link under the pointer. A plain click is reserved for placing the caret, which keeps link text editable, following the same convention as VS Code, Word and Obsidian. In either read-only preview mode, a plain primary click opens the link because there is no caret-editing conflict; only the primary button counts. ``` -Ctrl/Cmd + primary click on +read-only primary click OR editable Ctrl/Cmd + primary click on → handleLinkClick (ProseMirror handleDOMEvents: click / auxclick) → normalizeSafeLinkHref ── unsafe ─→ preventDefault, no navigation → window.open(href, '_blank', 'noopener,noreferrer')