Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/src/components/Milkdown/MilkdownPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -265,7 +266,7 @@ export function MilkdownPreview(
return (
<div
ref={containerRef}
className={className}
className={clsx('[&_a]:pointer-events-auto', className)}
// Surface the read-only nature to assistive tech. In drag mode
// we still keep the inner ProseMirror `contenteditable=true` so
// the block-drag handle remains hit-testable, but we capture &
Expand Down
23 changes: 20 additions & 3 deletions apps/web/src/components/Milkdown/__tests__/blockCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let roots: HTMLElement[] = [];

async function mount(
markdown: string,
overrides?: { editable?: boolean },
overrides?: { editable?: boolean; previewMode?: boolean },
): Promise<MilkdownInstance> {
const root = document.createElement('div');
document.body.appendChild(root);
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MilkdownPreview
markdown="[docs](https://example.com)"
className="pointer-events-none"
/>,
);
});

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);
Expand Down
50 changes: 27 additions & 23 deletions apps/web/src/components/Milkdown/createMilkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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 (`\[…\]`, `\(…\)`)
Expand Down
16 changes: 7 additions & 9 deletions docs/architecture/note-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---

Expand Down Expand Up @@ -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 <a href>
read-only primary click OR editable Ctrl/Cmd + primary click on <a href>
→ handleLinkClick (ProseMirror handleDOMEvents: click / auxclick)
→ normalizeSafeLinkHref ── unsafe ─→ preventDefault, no navigation
→ window.open(href, '_blank', 'noopener,noreferrer')
Expand Down
Loading