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')