From 00f39c00ba4c97139db71c0dab6f24e18b3e37aa Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 17:05:19 +1000 Subject: [PATCH 01/13] fix(core): resolve wikilink label clicks from the hidden source holder --- packages/core/src/extensions/mark-click.ts | 10 ++++++++-- .../src/extensions/wikilink-click.test.ts | 20 +++++++++++++------ .../core/src/extensions/wikilink-click.ts | 20 +++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/core/src/extensions/mark-click.ts b/packages/core/src/extensions/mark-click.ts index 4dbb54de..a3a1278b 100644 --- a/packages/core/src/extensions/mark-click.ts +++ b/packages/core/src/extensions/mark-click.ts @@ -1,5 +1,6 @@ import { definePlugin, type PlainExtension } from '@prosekit/core' import { Plugin, type EditorState, type PluginKey } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' export interface MarkClickConfig { key: PluginKey @@ -7,6 +8,8 @@ export interface MarkClickConfig { selector: string /** The payload for the mark covering `pos`, or `undefined` when the click misses it. */ findPayloadAt: (state: EditorState, pos: number) => Payload | undefined + /** Resolve atom mark views from their hidden content holder instead of click coordinates. */ + findPayloadForElement?: (view: EditorView, element: HTMLElement) => Payload | undefined /** Fired when a click lands on the mark. */ onClick: (payload: Payload, event: MouseEvent) => void /** Stops native handling (e.g. `` navigation) before firing. */ @@ -24,8 +27,11 @@ export function defineMarkClickHandler(config: MarkClickConfig props: { handleClick: (view, pos, event) => { const target = event.target as HTMLElement | null - if (!target?.closest?.(config.selector)) return false - const payload = config.findPayloadAt(view.state, pos) + const element = target?.closest?.(config.selector) + if (!element) return false + const payload = config.findPayloadForElement + ? config.findPayloadForElement(view, element) + : config.findPayloadAt(view.state, pos) if (payload == null) return false if (config.preventDefault) event.preventDefault() config.onClick(payload, event) diff --git a/packages/core/src/extensions/wikilink-click.test.ts b/packages/core/src/extensions/wikilink-click.test.ts index 520efe49..8d32fd0b 100644 --- a/packages/core/src/extensions/wikilink-click.test.ts +++ b/packages/core/src/extensions/wikilink-click.test.ts @@ -97,10 +97,18 @@ describe('wikilink click callback', () => { expect(onWikilinkClick).not.toHaveBeenCalled() }) - // Known limitation: clicking the non-editable label resolves the document - // position from the click coordinates, so a wide alias label can overshoot the - // source boundary and adjacent `[[a]][[b]]` labels resolve to the neighbor. - // `findWikilinkAt` itself resolves the right range per position (see the unit - // test above); a follow-up should resolve label clicks from the mark view's - // content holder via `posAtDOM`, the way `defineImageClickHandler` does. + it('resolves adjacent wide aliases from their own hidden content holders', async () => { + const onWikilinkClick = vi.fn() + using fixture = setupFixture() + applyClickable( + fixture, + '[[Alpha|An alias much wider than its source]][[Beta|Another very wide alias]]', + onWikilinkClick, + ) + const links = pmRoot.getByTestId('wikilink') + await expect.element(links.nth(0)).toBeInTheDocument() + await userEvent.click(links.nth(0)) + await userEvent.click(links.nth(1)) + expect(onWikilinkClick.mock.calls.map(([payload]) => payload.target)).toEqual(['Alpha', 'Beta']) + }) }) diff --git a/packages/core/src/extensions/wikilink-click.ts b/packages/core/src/extensions/wikilink-click.ts index 5f70df66..ac9b301a 100644 --- a/packages/core/src/extensions/wikilink-click.ts +++ b/packages/core/src/extensions/wikilink-click.ts @@ -1,5 +1,6 @@ import type { PlainExtension } from '@prosekit/core' import { PluginKey, type EditorState } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' import { getMarkRangeAt } from './get-mark-range-at.ts' import type { MdWikilinkAttrs } from './inline-marks.ts' @@ -21,6 +22,24 @@ export function findWikilinkAt(state: EditorState, pos: number): WikilinkHit | u return { from: range.from, to: range.to, target } } +/** + * Resolve the wiki link represented by a visible mark-view element. + * + * The preview label is non-editable and can be much wider than its Markdown + * source. Resolving from click coordinates therefore risks landing on the + * next adjacent mark; the hidden content holder has the exact source position. + */ +export function findWikilinkForElement( + view: EditorView, + element: HTMLElement, +): WikilinkHit | undefined { + const content = element + .closest('.md-wikilink-view') + ?.querySelector('.md-wikilink-view-content') + if (!content) return + return findWikilinkAt(view.state, view.posAtDOM(content, 0)) +} + export interface WikilinkClickPayload { target: string /** The originating click, or the `Mod-Enter` key press that followed the link. */ @@ -35,6 +54,7 @@ export function defineWikilinkClickHandler(onClick: WikilinkClickHandler): Plain selector: '.md-wikilink-view-preview', preventDefault: false, findPayloadAt: (state, pos) => findWikilinkAt(state, pos)?.target, + findPayloadForElement: (view, element) => findWikilinkForElement(view, element)?.target, onClick: (target, event) => onClick({ target, event }), }) } From a16dcbf0902ed459849f58ad86ac99db1f3940d5 Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 17:05:20 +1000 Subject: [PATCH 02/13] feat(core): revalidate mark hover hits after transactions --- .../core/src/extensions/link-hover.test.ts | 56 ++++++++++++ packages/core/src/extensions/link-hover.ts | 3 + packages/core/src/extensions/mark-hover.ts | 85 +++++++++++++++---- 3 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/extensions/link-hover.test.ts diff --git a/packages/core/src/extensions/link-hover.test.ts b/packages/core/src/extensions/link-hover.test.ts new file mode 100644 index 00000000..c7daaf8e --- /dev/null +++ b/packages/core/src/extensions/link-hover.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { page } from 'vitest/browser' + +import { setupFixture } from '../testing/index.ts' + +import { defineLinkHoverHandler, type LinkHoverHandler } from './link-hover.ts' + +const markdownLink = page.locate('.ProseMirror .md-link') + +function applyHoverable(markdown: string, onHoverChange: LinkHoverHandler) { + const fixture = setupFixture() + fixture.editor.use(defineLinkHoverHandler(onHoverChange)) + fixture.set(fixture.n.doc(fixture.n.paragraph(markdown))) + fixture.editor.commands.setMarkMode('hide') + return fixture +} + +describe('Markdown-link hover callback', () => { + it('keeps the hovered link active through an unrelated transaction', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('before [Docs](https://example.com)', onHoverChange) + + await markdownLink.hover() + fixture.view.dispatch(fixture.state.tr.insertText('new ', 1)) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.payload.href)).toEqual([ + 'https://example.com', + ]) + }) + + it('leaves when the hovered link is deleted without pointer movement', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('before [Docs](https://example.com)', onHoverChange) + + await markdownLink.hover() + fixture.set(fixture.n.doc(fixture.n.paragraph('before Docs'))) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.payload.href)).toEqual([ + 'https://example.com', + undefined, + ]) + }) + + it('leaves when the hovered link destination is replaced', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('[Docs](https://example.com)', onHoverChange) + + await markdownLink.hover() + fixture.set(fixture.n.doc(fixture.n.paragraph('[Docs](https://example.org)'))) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.payload.href)).toEqual([ + 'https://example.com', + undefined, + ]) + }) +}) diff --git a/packages/core/src/extensions/link-hover.ts b/packages/core/src/extensions/link-hover.ts index d4755147..a4c91fd6 100644 --- a/packages/core/src/extensions/link-hover.ts +++ b/packages/core/src/extensions/link-hover.ts @@ -11,6 +11,9 @@ export function defineLinkHoverHandler(onHoverChange: LinkHoverHandler): PlainEx findPayloadAt: (state, pos): LinkUnit | undefined => { return getLinkUnitAt(state, pos) }, + isSamePayload: (previous, next) => { + return previous.href === next.href && previous.title === next.title + }, onHoverChange, }) } diff --git a/packages/core/src/extensions/mark-hover.ts b/packages/core/src/extensions/mark-hover.ts index 8847c7d9..d0b88f3f 100644 --- a/packages/core/src/extensions/mark-hover.ts +++ b/packages/core/src/extensions/mark-hover.ts @@ -1,6 +1,6 @@ import { isElementLike } from '@ocavue/utils' -import { defineDOMEventHandler, type PlainExtension, union } from '@prosekit/core' -import type { EditorState } from '@prosekit/pm/state' +import { definePlugin, type PlainExtension } from '@prosekit/core' +import { Plugin, type EditorState } from '@prosekit/pm/state' import type { EditorView } from '@prosekit/pm/view' export interface MarkHoverHit { @@ -13,38 +13,93 @@ export interface MarkHoverConfig { selector: string /** The payload for the mark covering `pos`, or `undefined` on a miss. */ findPayloadAt: (state: EditorState, pos: number) => Payload | undefined + /** + * Resolve a hit from its rendered element. Atom mark views should use their + * hidden content holder rather than the event coordinates, which can land on + * an adjacent mark's document boundary. + */ + findPayloadForElement?: (view: EditorView, element: HTMLElement) => Payload | undefined + /** Whether a transaction left the hovered mark semantically unchanged. */ + isSamePayload: (previous: Payload, next: Payload) => boolean /** Fired with the hit on enter, and with `undefined` on leave. */ onHoverChange: (hit: MarkHoverHit | undefined) => void } +/** + * Delegate hover tracking for a rendered mark to the editor root. + * + * Movement within a mark is de-duplicated. The active hit is also revalidated + * after every editor update, so deleting, replacing, or rewriting a hovered + * mark emits leave even when the pointer itself never moves. Destroying the + * editor or removing the extension emits leave as well. + */ export function defineMarkHoverHandler(config: MarkHoverConfig): PlainExtension { - let current: HTMLElement | undefined + let current: MarkHoverHit | undefined + + const findPayloadForElement = (view: EditorView, element: HTMLElement): Payload | undefined => { + return config.findPayloadForElement + ? config.findPayloadForElement(view, element) + : config.findPayloadAt(view.state, view.posAtDOM(element, 0)) + } + + const leave = (): void => { + if (!current) return + current = undefined + config.onHoverChange(undefined) + } const handleOver = (view: EditorView, event: MouseEvent): void => { const target = event.target if (!target || !isElementLike(target)) return const element = target.closest(config.selector) - if (!element || element === current) return + if (!element || !view.dom.contains(element) || element === current?.element) return - const pos = view.posAtDOM(element, 0) - const payload = config.findPayloadAt(view.state, pos) + leave() + const payload = findPayloadForElement(view, element) if (payload == null) return - current = element - config.onHoverChange({ payload, element }) + current = { payload, element } + config.onHoverChange(current) } const handleOut = (event: MouseEvent): void => { if (!current) return // `mouseout` also fires when moving onto a child of the same mark; ignore it. - const related = event.relatedTarget as Node | undefined - if (related && current.contains(related)) return - current = undefined - config.onHoverChange(undefined) + const related = event.relatedTarget + if (related instanceof Node && current.element.contains(related)) return + leave() } - return union( - defineDOMEventHandler('mouseover', (view, event) => handleOver(view, event)), - defineDOMEventHandler('mouseout', (_view, event) => handleOut(event)), + return definePlugin( + new Plugin({ + props: { + handleDOMEvents: { + mouseover: (view, event) => { + handleOver(view, event) + return false + }, + mouseout: (_view, event) => { + handleOut(event) + return false + }, + }, + }, + view: () => ({ + update: (view) => { + if (!current) return + if (!current.element.isConnected || !view.dom.contains(current.element)) { + leave() + return + } + const payload = findPayloadForElement(view, current.element) + if (payload == null || !config.isSamePayload(current.payload, payload)) { + leave() + return + } + current = { ...current, payload } + }, + destroy: leave, + }), + }), ) } From 3613fe1eabc0a552992e23fdc40aa929f2370044 Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 17:05:21 +1000 Subject: [PATCH 03/13] feat(core): add `defineWikilinkHoverHandler` --- packages/core/README.md | 2 +- .../src/extensions/wikilink-hover.test.ts | 99 +++++++++++++++++++ .../core/src/extensions/wikilink-hover.ts | 32 ++++++ packages/core/src/index.ts | 5 + 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/extensions/wikilink-hover.test.ts create mode 100644 packages/core/src/extensions/wikilink-hover.ts diff --git a/packages/core/README.md b/packages/core/README.md index 2ea77847..70b38d4b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -112,7 +112,7 @@ Two things the variable list cannot show: `--meowdown-gutter` is the horizontal Tags (`#tag`) render as pills via the `.md-tag` class, tinted from `--meowdown-accent`. Wire click handling with `defineTagClickHandler(({ tag, event }) => ...)` (or `@meowdown/react`'s `onTagClick` prop); `tag` is read from the rendered text without the leading `#`. -Wikilinks (`[[target]]`/`[[target|alias]]`) render in place via a mark view as an immutable label (the alias, or the target when there is no alias), with the raw source hidden in hide and focus modes and shown dimmed in show mode. The label uses the `.md-wikilink-view-label` class, dashed-underlined and colored by `--meowdown-accent`. In every mark mode the link is a single immutable caret stop: arrowing onto it selects the whole source (ringed with `--meowdown-node-outline` in hide and focus, the native selection over the visible source in show), and Backspace/Delete remove it as a unit. Wire click navigation with `defineWikilinkClickHandler(({ target, event }) => ...)` (or `@meowdown/react`'s `onWikilinkClick` prop); `Mod-Enter` with the caret on a wikilink, tag, or Markdown link fires the same handler, with the `KeyboardEvent` as `event`. +Wikilinks (`[[target]]`/`[[target|alias]]`) render in place via a mark view as an immutable label (the alias, or the target when there is no alias), with the raw source hidden in hide and focus modes and shown dimmed in show mode. The label uses the `.md-wikilink-view-label` class, dashed-underlined and colored by `--meowdown-accent`. In every mark mode the link is a single immutable caret stop: arrowing onto it selects the whole source (ringed with `--meowdown-node-outline` in hide and focus, the native selection over the visible source in show), and Backspace/Delete remove it as a unit. Wire click navigation with `defineWikilinkClickHandler(({ target, event }) => ...)` (or `@meowdown/react`'s `onWikilinkClick` prop); `Mod-Enter` with the caret on a wikilink, tag, or Markdown link fires the same handler, with the `KeyboardEvent` as `event`. `defineWikilinkHoverHandler` reports the hovered target, source range, and visible anchor element, then reports `undefined` on leave, deletion, replacement, or editor teardown. Markdown links (`[text](url)`) render the label as an `` with the `.md-link` class, colored by `--meowdown-accent`; the `[`, `]`, and `(url)` syntax dims in show mode and hides in hide and focus modes. Wire click handling with `defineLinkClickHandler(({ href, event }) => ...)` (or `@meowdown/react`'s `onLinkClick` prop). diff --git a/packages/core/src/extensions/wikilink-hover.test.ts b/packages/core/src/extensions/wikilink-hover.test.ts new file mode 100644 index 00000000..e438c72e --- /dev/null +++ b/packages/core/src/extensions/wikilink-hover.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest' +import { page } from 'vitest/browser' + +import { setupFixture } from '../testing/index.ts' + +import { defineWikilinkHoverHandler, type WikilinkHoverHandler } from './wikilink-hover.ts' + +const pmRoot = page.locate('.ProseMirror') + +function applyHoverable(markdown: string, onHoverChange: WikilinkHoverHandler) { + const fixture = setupFixture() + fixture.editor.use(defineWikilinkHoverHandler(onHoverChange)) + fixture.set(fixture.n.doc(fixture.n.paragraph(markdown))) + fixture.editor.commands.setMarkMode('hide') + return fixture +} + +describe('wiki-link hover callback', () => { + it('emits one enter while moving among one link label and its children', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('see [[Note|Wide alias]] here', onHoverChange) + void fixture + const preview = pmRoot.getByTestId('wikilink') + const label = preview.locate('.md-wikilink-view-label') + + await preview.hover() + label.element().dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + relatedTarget: preview.element(), + }), + ) + + expect(onHoverChange).toHaveBeenCalledTimes(1) + expect(onHoverChange).toHaveBeenCalledWith( + expect.objectContaining({ target: 'Note', element: preview.element() }), + ) + }) + + it('leaves one adjacent link before entering the next', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable( + '[[Alpha|A wide alias]][[Beta|Another wide alias]]', + onHoverChange, + ) + void fixture + const links = pmRoot.getByTestId('wikilink') + + await links.nth(0).hover() + await links.nth(1).hover() + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.target)).toEqual([ + 'Alpha', + undefined, + 'Beta', + ]) + }) + + it('leaves when the hovered link is deleted without pointer movement', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('before [[Note]] after', onHoverChange) + + await pmRoot.getByTestId('wikilink').hover() + fixture.set(fixture.n.doc(fixture.n.paragraph('before after'))) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.target)).toEqual(['Note', undefined]) + }) + + it('leaves when the hovered link is replaced', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('[[Alpha]]', onHoverChange) + + await pmRoot.getByTestId('wikilink').hover() + fixture.set(fixture.n.doc(fixture.n.paragraph('[[Beta]]'))) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.target)).toEqual(['Alpha', undefined]) + }) + + it('keeps the same hovered element active through an unrelated transaction', async () => { + const onHoverChange = vi.fn() + using fixture = applyHoverable('before [[Note]]', onHoverChange) + + await pmRoot.getByTestId('wikilink').hover() + fixture.view.dispatch(fixture.state.tr.insertText('new ', 1)) + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.target)).toEqual(['Note']) + }) + + it('leaves when the editor is destroyed', async () => { + const onHoverChange = vi.fn() + { + using fixture = applyHoverable('[[Note]]', onHoverChange) + void fixture + await pmRoot.getByTestId('wikilink').hover() + } + + expect(onHoverChange.mock.calls.map(([hit]) => hit?.target)).toEqual(['Note', undefined]) + }) +}) diff --git a/packages/core/src/extensions/wikilink-hover.ts b/packages/core/src/extensions/wikilink-hover.ts new file mode 100644 index 00000000..a3fca3a2 --- /dev/null +++ b/packages/core/src/extensions/wikilink-hover.ts @@ -0,0 +1,32 @@ +import type { PlainExtension } from '@prosekit/core' + +import { defineMarkHoverHandler } from './mark-hover.ts' +import { findWikilinkAt, findWikilinkForElement, type WikilinkHit } from './wikilink-click.ts' + +/** A wiki link currently under the pointer. */ +export interface WikilinkHoverHit extends WikilinkHit { + /** The rendered wiki-link label used as the popup anchor. */ + element: HTMLElement +} + +/** Called once on wiki-link enter and with `undefined` on leave or invalidation. */ +export type WikilinkHoverHandler = (hit: WikilinkHoverHit | undefined) => void + +/** + * Track the wiki link under the pointer without attaching per-link listeners. + * + * The handler is revalidated after document transactions and receives leave + * when the hovered link is deleted, replaced, or changes target. Moving among + * descendants of one label is de-duplicated. + */ +export function defineWikilinkHoverHandler(onHoverChange: WikilinkHoverHandler): PlainExtension { + return defineMarkHoverHandler({ + selector: '.md-wikilink-view-preview', + findPayloadAt: findWikilinkAt, + findPayloadForElement: findWikilinkForElement, + isSamePayload: (previous, next) => previous.target === next.target, + onHoverChange: (hit) => { + onHoverChange(hit ? { ...hit.payload, element: hit.element } : undefined) + }, + }) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9663ac8f..2d3f9e65 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,6 +130,11 @@ export { type WikilinkClickHandler, type WikilinkClickPayload, } from './extensions/wikilink-click.ts' +export { + defineWikilinkHoverHandler, + type WikilinkHoverHandler, + type WikilinkHoverHit, +} from './extensions/wikilink-hover.ts' export { defineWikilinkTrigger } from './extensions/wikilink-trigger.ts' export type { PositionRange } from './utils/range.ts' export { getSelectedText } from './utils/selected-text.ts' From ed1590be067843a88ab70287d7ea149bf00c95ca Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 17:05:30 +1000 Subject: [PATCH 04/13] feat(react): add `interactive` prop to `MarkdownView` --- .../src/components/markdown-view.test.tsx | 44 +++++++++++++++++++ .../react/src/components/markdown-view.tsx | 30 ++++++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/markdown-view.test.tsx b/packages/react/src/components/markdown-view.test.tsx index f525a367..af6c437e 100644 --- a/packages/react/src/components/markdown-view.test.tsx +++ b/packages/react/src/components/markdown-view.test.tsx @@ -74,6 +74,50 @@ describe('MarkdownView', () => { .toHaveAttribute('src', 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ') }) + it('omits recognized embeds before resolving images when interactive is false', async () => { + const resolveImageUrl = vi.fn((src: string) => src) + await renderView('![](https://x.com/jack/status/20)\n\n![](https://youtu.be/dQw4w9WgXcQ)', { + interactive: false, + resolveImageUrl, + }) + + expect(view.element().querySelector('iframe')).toBeNull() + expect(resolveImageUrl).not.toHaveBeenCalled() + }) + + it('renders a passive tree when interactive is false', async () => { + const onWikilinkClick = vi.fn() + const onLinkClick = vi.fn() + const onImageClick = vi.fn() + const onTaskClick = vi.fn() + await renderView( + '[[Note]] [Docs](https://example.com) ![cat](https://example.com/cat.png)\n\n![](https://x.com/jack/status/20)\n\n+ [ ] task', + { + interactive: false, + onWikilinkClick, + onLinkClick, + onImageClick, + onTaskClick, + }, + ) + + const root = view.element() + expect(root.querySelector('a')).toBeNull() + expect(root.querySelector('iframe')).toBeNull() + expect( + root.querySelectorAll( + 'button, input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ).toHaveLength(0) + await wikilink.click() + await view.getByText('Docs').click() + await view.getByAltText('cat').click() + expect(onWikilinkClick).not.toHaveBeenCalled() + expect(onLinkClick).not.toHaveBeenCalled() + expect(onImageClick).not.toHaveBeenCalled() + expect(onTaskClick).not.toHaveBeenCalled() + }) + it('highlights a code block with syntax tokens', async () => { await renderView('```rust\nfn main() {}\n```') await expect diff --git a/packages/react/src/components/markdown-view.tsx b/packages/react/src/components/markdown-view.tsx index 191a0fc9..5f2da860 100644 --- a/packages/react/src/components/markdown-view.tsx +++ b/packages/react/src/components/markdown-view.tsx @@ -84,6 +84,13 @@ export interface MarkdownViewProps { markMode?: MarkMode /** Peel a leading YAML frontmatter block before rendering. Off by default. */ frontmatter?: boolean + /** + * Whether rendered links, images, and task checkboxes can be activated. + * Defaults to `true`. When `false`, callbacks are ignored, the rendered tree + * contains no anchors or focusable task controls, and recognized tweet and + * YouTube embeds are omitted before any image resolver runs. + */ + interactive?: boolean /** Map an image `src` to a displayable URL, or `undefined` to skip it. */ resolveImageUrl?: (src: string) => string | undefined /** Called when a rendered wiki link is clicked. Pass a stable function. */ @@ -99,6 +106,7 @@ export interface MarkdownViewProps { } interface RenderContext { + interactive: boolean resolveImageUrl?: (src: string) => string | undefined onWikilinkClick?: WikilinkClickHandler onLinkClick?: LinkClickHandler @@ -134,6 +142,10 @@ function outputSpecToReact( if (tag === 'input' && attrs?.['type'] === 'checkbox') { reactProps.readOnly = true + if (!context.interactive) { + reactProps.disabled = true + reactProps.tabIndex = -1 + } } const reactChildren = rest.map((child) => outputSpecToReact(child, content, context)) @@ -200,10 +212,11 @@ function ImagePreview(props: { width: number | null resolveImageUrl?: (src: string) => string | undefined onImageClick?: ImageClickHandler + interactive: boolean }): ReactElement | null { - const { src, alt, width, resolveImageUrl, onImageClick } = props + const { src, alt, width, resolveImageUrl, onImageClick, interactive } = props const embed = matchEmbed(src) - if (embed) return + if (embed) return interactive ? : null const url = (resolveImageUrl ?? defaultResolveImageUrl)(src) if (!url) return null @@ -243,6 +256,7 @@ function ImageView(props: { width={width} resolveImageUrl={context.resolveImageUrl} onImageClick={context.onImageClick} + interactive={context.interactive} /> {children} @@ -363,6 +377,7 @@ function wrapMark(mark: Mark, children: ReactNode, context: RenderContext): Reac } case 'mdLinkText': { const attrs = mark.attrs as MdLinkTextAttrs + if (!context.interactive) return {children} const handleClick = context.onLinkClick ? (event: MouseEvent) => context.onLinkClick?.({ href: attrs.href, event: event.nativeEvent }) @@ -514,6 +529,7 @@ export function MarkdownView({ markdown, markMode = 'hide', frontmatter = false, + interactive = true, resolveImageUrl, onWikilinkClick, onLinkClick, @@ -524,11 +540,12 @@ export function MarkdownView({ const content = useMemo(() => { const doc = markdownToDoc(markdown, { frontmatter }) const context: RenderContext = { + interactive, resolveImageUrl, - onWikilinkClick, - onLinkClick, - onImageClick, - onTaskClick, + onWikilinkClick: interactive ? onWikilinkClick : undefined, + onLinkClick: interactive ? onLinkClick : undefined, + onImageClick: interactive ? onImageClick : undefined, + onTaskClick: interactive ? onTaskClick : undefined, taskCounter: { value: 0 }, keyCounter: { value: 0 }, } @@ -536,6 +553,7 @@ export function MarkdownView({ }, [ markdown, frontmatter, + interactive, resolveImageUrl, onWikilinkClick, onLinkClick, From d059f0e4bc30fb77cea46cd231ab9eaaab674cc0 Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 17:05:30 +1000 Subject: [PATCH 05/13] feat(react): add `WikilinkHoverCard` --- packages/react/README.md | 23 ++++ .../components/wikilink-hover-card.module.css | 22 ++++ .../wikilink-hover-card.module.d.css.ts | 6 + .../components/wikilink-hover-card.test.tsx | 124 ++++++++++++++++++ .../src/components/wikilink-hover-card.tsx | 84 ++++++++++++ packages/react/src/index.ts | 4 + 6 files changed, 263 insertions(+) create mode 100644 packages/react/src/components/wikilink-hover-card.module.css create mode 100644 packages/react/src/components/wikilink-hover-card.module.d.css.ts create mode 100644 packages/react/src/components/wikilink-hover-card.test.tsx create mode 100644 packages/react/src/components/wikilink-hover-card.tsx diff --git a/packages/react/README.md b/packages/react/README.md index a06beba0..0948b804 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -56,6 +56,29 @@ See the full API reference [here](https://npmx.dev/package-docs/@meowdown%2Freac Slash menu host items can include `keywords` to match hidden terms without changing the displayed label. +### Wiki-link hover cards + +Mount `WikilinkHoverCard` inside `MeowdownEditor` and render host-owned preview +content from the wiki-link target. The component handles a 300ms dwell, +transaction-aware dismissal, direct element anchoring, viewport collisions, and +focus neutrality. Return `null` for a target with no preview and no card +renders; when the lookup is asynchronous, render fallback content instead. + +```tsx + + {(target) => } + +``` + +For local-only passive preview content, render Markdown with interaction +disabled: the tree contains no anchors or focusable controls, and recognized +tweet and YouTube embeds are omitted before any image resolver runs. Supply a +resolver that accepts only trusted local image sources. + +```tsx + +``` + ## Styling Import both stylesheets: `@meowdown/core/style.css` (the editor theme and variables) and `@meowdown/react/style.css` (the component layout). The core theme is documented in [`@meowdown/core`](https://www.npmjs.com/package/@meowdown/core). diff --git a/packages/react/src/components/wikilink-hover-card.module.css b/packages/react/src/components/wikilink-hover-card.module.css new file mode 100644 index 00000000..e6643095 --- /dev/null +++ b/packages/react/src/components/wikilink-hover-card.module.css @@ -0,0 +1,22 @@ +.Positioner { + z-index: 50; + max-width: calc(100vw - 1rem); + max-height: calc(100vh - 1rem); + pointer-events: none; +} + +.Popup { + box-sizing: border-box; + width: min(21.875rem, calc(100vw - 1rem)); + max-height: min(12.5rem, calc(100vh - 1rem)); + padding: 0.5rem 0.75rem; + overflow: hidden; + outline: none; + pointer-events: none; + border: 1px solid var(--meowdown-border); + border-radius: 0.75rem; + background: var(--meowdown-popover-bg); + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); +} diff --git a/packages/react/src/components/wikilink-hover-card.module.d.css.ts b/packages/react/src/components/wikilink-hover-card.module.d.css.ts new file mode 100644 index 00000000..730927d7 --- /dev/null +++ b/packages/react/src/components/wikilink-hover-card.module.d.css.ts @@ -0,0 +1,6 @@ +// @ts-nocheck +declare const styles = { + 'Positioner': '' as string, + 'Popup': '' as string, +} as const; +export default styles; diff --git a/packages/react/src/components/wikilink-hover-card.test.tsx b/packages/react/src/components/wikilink-hover-card.test.tsx new file mode 100644 index 00000000..d7d99720 --- /dev/null +++ b/packages/react/src/components/wikilink-hover-card.test.tsx @@ -0,0 +1,124 @@ +import '../testing/index.ts' + +import { createRef } from 'react' +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-react' +import { page } from 'vitest/browser' + +import { hover, unhover } from '../testing/mouse.ts' + +import { MeowdownEditor } from './editor.tsx' +import type { EditorHandle } from './types.ts' +import { WikilinkHoverCard } from './wikilink-hover-card.tsx' + +const pmRoot = page.locate('.ProseMirror') +const card = page.getByTestId('wikilink-hover-card') + +function PreviewCard() { + return ( + + {(target) =>
Preview: {target}
} +
+ ) +} + +describe('WikilinkHoverCard', () => { + it('opens after a 300ms dwell and closes immediately on leave', async () => { + await unhover() + await render( + + + , + ) + + await hover(pmRoot.getByTestId('wikilink')) + await expect.element(card).not.toBeInTheDocument() + await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Note') + + await unhover() + await expect.element(card).not.toBeInTheDocument() + }) + + it('restarts the dwell when the pointer moves to an adjacent target', async () => { + await unhover() + await render( + + + , + ) + const links = pmRoot.getByTestId('wikilink') + + await hover(links.nth(0)) + await new Promise((resolve) => setTimeout(resolve, 200)) + await hover(links.nth(1)) + await new Promise((resolve) => setTimeout(resolve, 150)) + await expect.element(card).not.toBeInTheDocument() + await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Beta') + }) + + it('renders no card when the render prop returns null for the target', async () => { + await unhover() + await render( + + + {(target) => (target === 'Known' ?
Preview: {target}
: null)} +
+
, + ) + const links = pmRoot.getByTestId('wikilink') + + await hover(links.nth(0)) + await new Promise((resolve) => setTimeout(resolve, 500)) + await expect.element(card).not.toBeInTheDocument() + + await hover(links.nth(1)) + await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Known') + }) + + it('preserves editor focus and selection and makes the popup inert', async () => { + await unhover() + const handleRef = createRef() + await render( + + + , + ) + handleRef.current?.setSelection({ type: 'text', anchor: 2, head: 2 }) + handleRef.current?.focus() + const activeElement = document.activeElement + const selection = handleRef.current?.getSelection() + + await hover(pmRoot.getByTestId('wikilink')) + await expect.element(card, { timeout: 1000 }).toBeInTheDocument() + + expect(document.activeElement).toBe(activeElement) + expect(handleRef.current?.getSelection()).toEqual(selection) + await expect.element(card).toHaveAttribute('inert') + }) + + it('keeps the popup inside an 8px viewport margin near the bottom-right edge', async () => { + await unhover() + await render( +
+ + + +
, + ) + + await hover(pmRoot.getByTestId('wikilink')) + await expect.element(card, { timeout: 1000 }).toBeInTheDocument() + const rect = card.element().getBoundingClientRect() + expect(rect.left).toBeGreaterThanOrEqual(7) + expect(rect.top).toBeGreaterThanOrEqual(7) + expect(rect.right).toBeLessThanOrEqual(window.innerWidth - 7) + expect(rect.bottom).toBeLessThanOrEqual(window.innerHeight - 7) + }) +}) diff --git a/packages/react/src/components/wikilink-hover-card.tsx b/packages/react/src/components/wikilink-hover-card.tsx new file mode 100644 index 00000000..bee29da2 --- /dev/null +++ b/packages/react/src/components/wikilink-hover-card.tsx @@ -0,0 +1,84 @@ +import { Popover } from '@base-ui/react/popover' +import { defineWikilinkHoverHandler, type WikilinkHoverHit } from '@meowdown/core' +import { useExtension } from '@prosekit/react' +import { clsx } from 'clsx/lite' +import { useEffect, useState, type ReactNode } from 'react' + +import styles from './wikilink-hover-card.module.css' + +const OPEN_DELAY = 300 + +/** Props for {@link WikilinkHoverCard}. */ +export interface WikilinkHoverCardProps { + /** + * Render the card body from the hovered wiki-link target. Returning `null` + * renders no card, so a host declines a target it has no preview for; when + * the lookup is asynchronous, render fallback content instead. + */ + readonly children: (target: string) => ReactNode + /** Optional class applied to the popup, after the default card surface. */ + readonly className?: string +} + +/** + * Show host-rendered content after a 300ms dwell over a rendered wiki link. + * + * Mount this component as a child of `MeowdownEditor`, where it can access the + * existing ProseKit context. Moving to another target restarts the dwell; + * leaving closes immediately, as does deleting or rewriting the hovered link. + * The popup portals to the document, anchors to the visible label element, + * flips and shifts within an 8px collision margin, and never takes focus or + * pointer input. + */ +export function WikilinkHoverCard({ children, className }: WikilinkHoverCardProps): ReactNode { + const [request, setRequest] = useState() + const [visibleRequest, setVisibleRequest] = useState() + + const [hoverExtension] = useState(() => { + return defineWikilinkHoverHandler((hit) => setRequest(hit)) + }) + useExtension(hoverExtension) + + useEffect(() => { + if (!request) return + const timer = setTimeout(() => setVisibleRequest(request), OPEN_DELAY) + return () => clearTimeout(timer) + }, [request]) + + if (!request || visibleRequest !== request) return null + + const body = children(request.target) + if (body == null) return null + + return ( + { + if (!open) setRequest(undefined) + }} + > + + + + {body} + + + + + ) +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7bd8413b..eea93526 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -5,6 +5,10 @@ export { type TaskClickHandler, type TaskClickPayload, } from './components/markdown-view.tsx' +export { + WikilinkHoverCard, + type WikilinkHoverCardProps, +} from './components/wikilink-hover-card.tsx' export type { TimeFormat } from './utils/date-format.ts' export type { EditorHandle, From 3f89d2a1bdba87960bfe28fd216950aab858e6dd Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 17:05:31 +1000 Subject: [PATCH 06/13] feat(website): demo wiki-link hover cards --- website/src/app.tsx | 59 +++++++++++++++++++++++++++++++++++++++++-- website/src/index.css | 10 ++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/website/src/app.tsx b/website/src/app.tsx index 09faaf37..420909af 100644 --- a/website/src/app.tsx +++ b/website/src/app.tsx @@ -1,5 +1,11 @@ import type { ExitBoundaryHandler } from '@meowdown/core' -import type { EditorHandle, TagItem, WikilinkItem } from '@meowdown/react' +import { + MarkdownView, + WikilinkHoverCard, + type EditorHandle, + type TagItem, + type WikilinkItem, +} from '@meowdown/react' import { getId } from '@ocavue/utils' import { clsx } from 'clsx/lite' import { @@ -40,6 +46,54 @@ function handleWikilinkClick({ target }: { target: string }): void { window.alert(`Clicked wikilink: ${target}`) } +// Demo note contents for the wikilink hover cards. `Travel plans` is left out +// on purpose: a target without content renders no card. +const NOTE_PREVIEWS: Record = { + 'Cat care basics': `# Cat care basics + +Feed twice a day, fresh water always, and never skip **play time**. + +- Brush long-haired cats daily +- Scratching posts save the couch`, + 'Daily journal': `# Daily journal + +Slow morning, good coffee. Sketched the outline for the #meowdown demo and moved [[Project ideas]] forward. + ++ [x] Morning pages ++ [ ] Publish the changelog`, + 'Meeting notes': `# Meeting notes + +Agreed to ship the hover card demo this week. *Everyone* liked the passive preview approach.`, + 'Project ideas': `# Project ideas + +- A cozy reading nook +- A cat-shaped bookshelf +- A tiny herb garden`, + 'Reading list': `# Reading list + +1. *The Mythical Man-Month* +2. [CommonMark spec](https://commonmark.org) +3. ~~Working in Public~~ (finished!)`, +} + +// Hover preview for wikilinks: a known note renders as a passive Markdown +// card, an unknown target renders no card at all. +function WikilinkPreviewCard() { + return ( + + {(target) => { + const markdown = NOTE_PREVIEWS[target] + if (!markdown) return null + return ( +
+ +
+ ) + }} +
+ ) +} + // Sizes for the file pills: the demo file in INITIAL_CONTENT, plus every // upload recorded by `uploadAndTrackFile`. Stands in for the stat lookup a // real host would do. @@ -81,7 +135,7 @@ Drop a [link](https://github.com/prosekit/meowdown) and keep on writing. Label your notes with tags like #meow and #markdown. Type \`#\` followed by a letter to see suggestions. -Connect notes with wikilinks like [[Daily journal]] and [[Reading list]]. Type \`[[\` to link another note. +Connect notes with wikilinks like [[Daily journal]] and [[Reading list]]. Type \`[[\` to link another note, and hover a link to peek inside it. Select some text and click the sparkle button (or press \`Mod-Shift-J\`) to run a command on it. The result streams into a preview, and nothing changes until you accept it. @@ -359,6 +413,7 @@ export function App() { onExitBoundary={handleExitBoundary} > + diff --git a/website/src/index.css b/website/src/index.css index 1952efec..c1503650 100644 --- a/website/src/index.css +++ b/website/src/index.css @@ -35,6 +35,16 @@ html { } } +/* The wikilink hover card renders a mini document: the editor gutter would + * swallow most of the card's width. */ +.note-preview { + font-size: 0.875rem; + + .meowdown-content { + padding: 0; + } +} + /* The active mode description re-animates whenever the mode changes (keyed * remount in app.tsx). */ @keyframes meowdown-fade-in { From e404fe72132120711fb86d9bd9323539d7fd94f8 Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 19:45:06 +1000 Subject: [PATCH 07/13] test: silence the benign `ResizeObserver` loop notification --- packages/vitest/src/setup-console.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/vitest/src/setup-console.ts b/packages/vitest/src/setup-console.ts index 3211d0d8..2fc00902 100644 --- a/packages/vitest/src/setup-console.ts +++ b/packages/vitest/src/setup-console.ts @@ -3,4 +3,9 @@ import failOnConsole from 'vitest-fail-on-console' failOnConsole({ shouldFailOnWarn: true, shouldFailOnError: true, + // A benign browser artifact: the skipped notifications are delivered on the + // next frame. Base UI's popup auto-resize measurements trigger it in tight + // viewports. + silenceMessage: (message) => + message.includes('ResizeObserver loop completed with undelivered notifications'), }) From 108e892061ee6529ae5f33e37686e3523b98b37c Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 19:45:07 +1000 Subject: [PATCH 08/13] feat(react): animate the wiki-link hover card across links --- packages/react/README.md | 12 +- .../components/wikilink-hover-card.module.css | 41 +++++- .../wikilink-hover-card.module.d.css.ts | 6 + .../components/wikilink-hover-card.test.tsx | 52 ++++++-- .../src/components/wikilink-hover-card.tsx | 120 ++++++++++-------- 5 files changed, 162 insertions(+), 69 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 0948b804..5d665ef5 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -59,14 +59,14 @@ Slash menu host items can include `keywords` to match hidden terms without chang ### Wiki-link hover cards Mount `WikilinkHoverCard` inside `MeowdownEditor` and render host-owned preview -content from the wiki-link target. The component handles a 300ms dwell, -transaction-aware dismissal, direct element anchoring, viewport collisions, and -focus neutrality. Return `null` for a target with no preview and no card -renders; when the lookup is asynchronous, render fallback content instead. +content from the hovered wiki link's `target`. Returning `null` renders no +card. ```tsx - {(target) => } + + {(hit) => } + ``` @@ -83,8 +83,6 @@ resolver that accepts only trusted local image sources. Import both stylesheets: `@meowdown/core/style.css` (the editor theme and variables) and `@meowdown/react/style.css` (the component layout). The core theme is documented in [`@meowdown/core`](https://www.npmjs.com/package/@meowdown/core). -Math (`$E=mc^2$` inline, `$$` blocks, and ` ```math ` fences) is compiled by KaTeX to native MathML. - ## License MIT diff --git a/packages/react/src/components/wikilink-hover-card.module.css b/packages/react/src/components/wikilink-hover-card.module.css index e6643095..b79c9ad7 100644 --- a/packages/react/src/components/wikilink-hover-card.module.css +++ b/packages/react/src/components/wikilink-hover-card.module.css @@ -3,14 +3,16 @@ max-width: calc(100vw - 1rem); max-height: calc(100vh - 1rem); pointer-events: none; + transition-property: top, left, right, bottom, transform; + transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1); + transition-duration: 350ms; } .Popup { + display: flex; box-sizing: border-box; - width: min(21.875rem, calc(100vw - 1rem)); - max-height: min(12.5rem, calc(100vh - 1rem)); - padding: 0.5rem 0.75rem; - overflow: hidden; + width: min(20rem, calc(100vw - 1rem)); + max-height: min(12rem, calc(100vh - 1rem)); outline: none; pointer-events: none; border: 1px solid var(--meowdown-border); @@ -19,4 +21,35 @@ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + transform-origin: var(--transform-origin); + transition: + opacity 150ms ease, + transform 150ms ease; +} + +.Popup[data-closed] { + transition: + opacity 100ms ease, + transform 100ms ease; +} + +.Popup[data-starting-style], +.Popup[data-ending-style] { + opacity: 0; + transform: scale(0.96); +} + +.Viewport { + position: relative; + flex: 1; + min-width: 0; + overflow: clip; + border-radius: inherit; +} + +@media (prefers-reduced-motion: reduce) { + .Positioner, + .Popup { + transition: none; + } } diff --git a/packages/react/src/components/wikilink-hover-card.module.d.css.ts b/packages/react/src/components/wikilink-hover-card.module.d.css.ts index 730927d7..c279c9a4 100644 --- a/packages/react/src/components/wikilink-hover-card.module.d.css.ts +++ b/packages/react/src/components/wikilink-hover-card.module.d.css.ts @@ -2,5 +2,11 @@ declare const styles = { 'Positioner': '' as string, 'Popup': '' as string, + 'Popup': '' as string, + 'Popup': '' as string, + 'Popup': '' as string, + 'Viewport': '' as string, + 'Positioner': '' as string, + 'Popup': '' as string, } as const; export default styles; diff --git a/packages/react/src/components/wikilink-hover-card.test.tsx b/packages/react/src/components/wikilink-hover-card.test.tsx index d7d99720..44b62658 100644 --- a/packages/react/src/components/wikilink-hover-card.test.tsx +++ b/packages/react/src/components/wikilink-hover-card.test.tsx @@ -14,20 +14,20 @@ import { WikilinkHoverCard } from './wikilink-hover-card.tsx' const pmRoot = page.locate('.ProseMirror') const card = page.getByTestId('wikilink-hover-card') -function PreviewCard() { +function HostPreviewCard() { return ( - {(target) =>
Preview: {target}
} + {(hit) =>
Preview: {hit.target}
}
) } describe('WikilinkHoverCard', () => { - it('opens after a 300ms dwell and closes immediately on leave', async () => { + it('opens after a 300ms dwell and closes on leave', async () => { await unhover() await render( - + , ) @@ -46,7 +46,7 @@ describe('WikilinkHoverCard', () => { initialMarkdown="[[Alpha|A wide alias]][[Beta|Another wide alias]]" blockHandle={false} > - + , ) const links = pmRoot.getByTestId('wikilink') @@ -59,12 +59,28 @@ describe('WikilinkHoverCard', () => { await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Beta') }) + it('moves the open card to the next hovered link', async () => { + await unhover() + await render( + + + , + ) + const links = pmRoot.getByTestId('wikilink') + + await hover(links.nth(0)) + await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Alpha') + + await hover(links.nth(1)) + await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Beta') + }) + it('renders no card when the render prop returns null for the target', async () => { await unhover() await render( - {(target) => (target === 'Known' ?
Preview: {target}
: null)} + {(hit) => (hit.target === 'Known' ?
Preview: {hit.target}
: null)}
, ) @@ -78,6 +94,26 @@ describe('WikilinkHoverCard', () => { await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Known') }) + it('removes the card when the hovered link is deleted', async () => { + await unhover() + const handleRef = createRef() + await render( + + + , + ) + + await hover(pmRoot.getByTestId('wikilink')) + await expect.element(card, { timeout: 1000 }).toBeInTheDocument() + + handleRef.current?.setMarkdown('before after') + await expect.element(card).not.toBeInTheDocument() + }) + it('preserves editor focus and selection and makes the popup inert', async () => { await unhover() const handleRef = createRef() @@ -87,7 +123,7 @@ describe('WikilinkHoverCard', () => { initialMarkdown="before [[Note]] after" blockHandle={false} > - + , ) handleRef.current?.setSelection({ type: 'text', anchor: 2, head: 2 }) @@ -108,7 +144,7 @@ describe('WikilinkHoverCard', () => { await render(
- +
, ) diff --git a/packages/react/src/components/wikilink-hover-card.tsx b/packages/react/src/components/wikilink-hover-card.tsx index bee29da2..639e2f56 100644 --- a/packages/react/src/components/wikilink-hover-card.tsx +++ b/packages/react/src/components/wikilink-hover-card.tsx @@ -1,84 +1,104 @@ -import { Popover } from '@base-ui/react/popover' -import { defineWikilinkHoverHandler, type WikilinkHoverHit } from '@meowdown/core' +import { PreviewCard } from '@base-ui/react/preview-card' +import { + defineWikilinkHoverHandler, + type VirtualElement, + type WikilinkHoverHit, +} from '@meowdown/core' import { useExtension } from '@prosekit/react' import { clsx } from 'clsx/lite' -import { useEffect, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import styles from './wikilink-hover-card.module.css' const OPEN_DELAY = 300 +const CLOSE_DELAY = 200 /** Props for {@link WikilinkHoverCard}. */ export interface WikilinkHoverCardProps { /** - * Render the card body from the hovered wiki-link target. Returning `null` - * renders no card, so a host declines a target it has no preview for; when - * the lookup is asynchronous, render fallback content instead. + * Render the card body from the hovered wiki link. Returning `null` renders + * no card. */ - readonly children: (target: string) => ReactNode + readonly children: (hit: WikilinkHoverHit) => ReactNode /** Optional class applied to the popup, after the default card surface. */ readonly className?: string } /** * Show host-rendered content after a 300ms dwell over a rendered wiki link. - * - * Mount this component as a child of `MeowdownEditor`, where it can access the - * existing ProseKit context. Moving to another target restarts the dwell; - * leaving closes immediately, as does deleting or rewriting the hovered link. - * The popup portals to the document, anchors to the visible label element, - * flips and shifts within an 8px collision margin, and never takes focus or - * pointer input. */ export function WikilinkHoverCard({ children, className }: WikilinkHoverCardProps): ReactNode { - const [request, setRequest] = useState() - const [visibleRequest, setVisibleRequest] = useState() + const [hit, setHit] = useState() + const lastRectRef = useRef(null) + const [displayed, setDisplayed] = useState() + const [open, setOpen] = useState(false) const [hoverExtension] = useState(() => { - return defineWikilinkHoverHandler((hit) => setRequest(hit)) + return defineWikilinkHoverHandler((nextHit) => setHit(nextHit)) }) useExtension(hoverExtension) + const getRect = useCallback((): DOMRect => { + const rect = hit?.element?.getBoundingClientRect() + if (rect && rect.width > 0 && rect.height > 0) { + lastRectRef.current = rect + } + return lastRectRef.current || new DOMRect(0, 0, 0, 0) + }, [hit]) + + const anchor = useMemo((): VirtualElement => { + return { getBoundingClientRect: getRect } + }, [getRect]) + + const hasDisplayed = !!displayed + useEffect(() => { - if (!request) return - const timer = setTimeout(() => setVisibleRequest(request), OPEN_DELAY) - return () => clearTimeout(timer) - }, [request]) + if (!hit) { + const timer = setTimeout(() => setOpen(false), CLOSE_DELAY) + return () => clearTimeout(timer) + } - if (!request || visibleRequest !== request) return null + // An already-open card moves to the next link without a new dwell. + const openDelay = hasDisplayed ? 0 : OPEN_DELAY + const timer = setTimeout(() => { + setDisplayed(hit) + setOpen(true) + }, openDelay) + return () => clearTimeout(timer) + }, [hit, hasDisplayed]) - const body = children(request.target) - if (body == null) return null + const body = displayed ? children(displayed) : null return ( - { - if (!open) setRequest(undefined) + { + if (!nextOpen) setOpen(false) + }} + onOpenChangeComplete={(nextOpen) => { + if (!nextOpen) setDisplayed(undefined) }} > - - - + - {body} - - - - + + {body} + + + + )} + ) } From e49f37e8976baee217b6d5eef07b04e0c79e2981 Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 19:45:08 +1000 Subject: [PATCH 09/13] refactor(website): adapt the note preview demo to the hover `hit` --- website/src/app.tsx | 4 ++-- website/src/index.css | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/website/src/app.tsx b/website/src/app.tsx index 420909af..f727eaa4 100644 --- a/website/src/app.tsx +++ b/website/src/app.tsx @@ -81,8 +81,8 @@ Agreed to ship the hover card demo this week. *Everyone* liked the passive previ function WikilinkPreviewCard() { return ( - {(target) => { - const markdown = NOTE_PREVIEWS[target] + {(hit) => { + const markdown = NOTE_PREVIEWS[hit.target] if (!markdown) return null return (
diff --git a/website/src/index.css b/website/src/index.css index c1503650..a5f7a01b 100644 --- a/website/src/index.css +++ b/website/src/index.css @@ -38,6 +38,7 @@ html { /* The wikilink hover card renders a mini document: the editor gutter would * swallow most of the card's width. */ .note-preview { + padding: 0.5rem 0.75rem; font-size: 0.875rem; .meowdown-content { From 42c49c93e3f3a7b5c5967641b3796e76e8a74f42 Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 19:55:37 +1000 Subject: [PATCH 10/13] refactor(core): key the mark hover plugins --- packages/core/src/extensions/link-hover.ts | 4 ++++ packages/core/src/extensions/mark-hover.ts | 4 +++- packages/core/src/extensions/wikilink-hover.ts | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/src/extensions/link-hover.ts b/packages/core/src/extensions/link-hover.ts index a4c91fd6..7c99e89c 100644 --- a/packages/core/src/extensions/link-hover.ts +++ b/packages/core/src/extensions/link-hover.ts @@ -1,12 +1,16 @@ import type { PlainExtension } from '@prosekit/core' +import { PluginKey } from '@prosekit/pm/state' import { getLinkUnitAt, type LinkUnit } from './get-link-unit-at.ts' import { defineMarkHoverHandler, type MarkHoverHit } from './mark-hover.ts' +const linkHoverKey = new PluginKey('meowdown-link-hover') + export type LinkHoverHandler = (hit: MarkHoverHit | undefined) => void export function defineLinkHoverHandler(onHoverChange: LinkHoverHandler): PlainExtension { return defineMarkHoverHandler({ + key: linkHoverKey, selector: '.md-link', findPayloadAt: (state, pos): LinkUnit | undefined => { return getLinkUnitAt(state, pos) diff --git a/packages/core/src/extensions/mark-hover.ts b/packages/core/src/extensions/mark-hover.ts index d0b88f3f..8fd5f980 100644 --- a/packages/core/src/extensions/mark-hover.ts +++ b/packages/core/src/extensions/mark-hover.ts @@ -1,6 +1,6 @@ import { isElementLike } from '@ocavue/utils' import { definePlugin, type PlainExtension } from '@prosekit/core' -import { Plugin, type EditorState } from '@prosekit/pm/state' +import { Plugin, type EditorState, type PluginKey } from '@prosekit/pm/state' import type { EditorView } from '@prosekit/pm/view' export interface MarkHoverHit { @@ -9,6 +9,7 @@ export interface MarkHoverHit { } export interface MarkHoverConfig { + key: PluginKey /** The hovered target must sit inside this selector, tested via `closest`. */ selector: string /** The payload for the mark covering `pos`, or `undefined` on a miss. */ @@ -72,6 +73,7 @@ export function defineMarkHoverHandler(config: MarkHoverConfig return definePlugin( new Plugin({ + key: config.key, props: { handleDOMEvents: { mouseover: (view, event) => { diff --git a/packages/core/src/extensions/wikilink-hover.ts b/packages/core/src/extensions/wikilink-hover.ts index a3fca3a2..e70471c7 100644 --- a/packages/core/src/extensions/wikilink-hover.ts +++ b/packages/core/src/extensions/wikilink-hover.ts @@ -1,8 +1,11 @@ import type { PlainExtension } from '@prosekit/core' +import { PluginKey } from '@prosekit/pm/state' import { defineMarkHoverHandler } from './mark-hover.ts' import { findWikilinkAt, findWikilinkForElement, type WikilinkHit } from './wikilink-click.ts' +const wikilinkHoverKey = new PluginKey('meowdown-wikilink-hover') + /** A wiki link currently under the pointer. */ export interface WikilinkHoverHit extends WikilinkHit { /** The rendered wiki-link label used as the popup anchor. */ @@ -21,6 +24,7 @@ export type WikilinkHoverHandler = (hit: WikilinkHoverHit | undefined) => void */ export function defineWikilinkHoverHandler(onHoverChange: WikilinkHoverHandler): PlainExtension { return defineMarkHoverHandler({ + key: wikilinkHoverKey, selector: '.md-wikilink-view-preview', findPayloadAt: findWikilinkAt, findPayloadForElement: findWikilinkForElement, From 35169585d8a1567bb804a4c7caf139252f7e84ba Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 19:55:52 +1000 Subject: [PATCH 11/13] docs: spell wikilink without a hyphen --- packages/core/src/extensions/wikilink-hover.test.ts | 2 +- packages/core/src/extensions/wikilink-hover.ts | 8 ++++---- packages/react/src/components/markdown-view.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/extensions/wikilink-hover.test.ts b/packages/core/src/extensions/wikilink-hover.test.ts index e438c72e..4f8fbe11 100644 --- a/packages/core/src/extensions/wikilink-hover.test.ts +++ b/packages/core/src/extensions/wikilink-hover.test.ts @@ -15,7 +15,7 @@ function applyHoverable(markdown: string, onHoverChange: WikilinkHoverHandler) { return fixture } -describe('wiki-link hover callback', () => { +describe('wikilink hover callback', () => { it('emits one enter while moving among one link label and its children', async () => { const onHoverChange = vi.fn() using fixture = applyHoverable('see [[Note|Wide alias]] here', onHoverChange) diff --git a/packages/core/src/extensions/wikilink-hover.ts b/packages/core/src/extensions/wikilink-hover.ts index e70471c7..6ea3db49 100644 --- a/packages/core/src/extensions/wikilink-hover.ts +++ b/packages/core/src/extensions/wikilink-hover.ts @@ -6,17 +6,17 @@ import { findWikilinkAt, findWikilinkForElement, type WikilinkHit } from './wiki const wikilinkHoverKey = new PluginKey('meowdown-wikilink-hover') -/** A wiki link currently under the pointer. */ +/** A wikilink currently under the pointer. */ export interface WikilinkHoverHit extends WikilinkHit { - /** The rendered wiki-link label used as the popup anchor. */ + /** The rendered wikilink label used as the popup anchor. */ element: HTMLElement } -/** Called once on wiki-link enter and with `undefined` on leave or invalidation. */ +/** Called once on wikilink enter and with `undefined` on leave or invalidation. */ export type WikilinkHoverHandler = (hit: WikilinkHoverHit | undefined) => void /** - * Track the wiki link under the pointer without attaching per-link listeners. + * Track the wikilink under the pointer without attaching per-link listeners. * * The handler is revalidated after document transactions and receives leave * when the hovered link is deleted, replaced, or changes target. Moving among diff --git a/packages/react/src/components/markdown-view.tsx b/packages/react/src/components/markdown-view.tsx index 5f2da860..b5dd9770 100644 --- a/packages/react/src/components/markdown-view.tsx +++ b/packages/react/src/components/markdown-view.tsx @@ -93,7 +93,7 @@ export interface MarkdownViewProps { interactive?: boolean /** Map an image `src` to a displayable URL, or `undefined` to skip it. */ resolveImageUrl?: (src: string) => string | undefined - /** Called when a rendered wiki link is clicked. Pass a stable function. */ + /** Called when a rendered wikilink is clicked. Pass a stable function. */ onWikilinkClick?: WikilinkClickHandler /** Called when a rendered Markdown link is clicked. Pass a stable function. */ onLinkClick?: LinkClickHandler @@ -516,7 +516,7 @@ function renderBlock(node: ProseMirrorNode, context: RenderContext): ReactNode { /** * Render Markdown to a read-only React tree that looks exactly like the editor - * in `hide` mark mode: inline marks, wiki-link chips, images, tweet/YouTube + * in `hide` mark mode: inline marks, wikilink chips, images, tweet/YouTube * embeds, and syntax-highlighted code. No editor, no ProseMirror view; just a * walk over `markdownToDoc`'s document reusing meowdown's own parse, mark logic, * and CSS (the root carries `ProseMirror` + `data-mark-mode` so the existing From 0256035db5da5d56370364ea007c03cf2bf83fcc Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 19:56:52 +1000 Subject: [PATCH 12/13] refactor(website): style the note preview with Tailwind --- website/src/app.tsx | 3 +-- website/src/index.css | 11 ----------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/website/src/app.tsx b/website/src/app.tsx index f727eaa4..6cd5a97e 100644 --- a/website/src/app.tsx +++ b/website/src/app.tsx @@ -85,7 +85,7 @@ function WikilinkPreviewCard() { const markdown = NOTE_PREVIEWS[hit.target] if (!markdown) return null return ( -
+
) @@ -136,7 +136,6 @@ Drop a [link](https://github.com/prosekit/meowdown) and keep on writing. Label your notes with tags like #meow and #markdown. Type \`#\` followed by a letter to see suggestions. Connect notes with wikilinks like [[Daily journal]] and [[Reading list]]. Type \`[[\` to link another note, and hover a link to peek inside it. - Select some text and click the sparkle button (or press \`Mod-Shift-J\`) to run a command on it. The result streams into a preview, and nothing changes until you accept it. Track things two ways. Type \`+ \` for a circle checkbox task, or \`[] \` for a square checkbox task: diff --git a/website/src/index.css b/website/src/index.css index a5f7a01b..1952efec 100644 --- a/website/src/index.css +++ b/website/src/index.css @@ -35,17 +35,6 @@ html { } } -/* The wikilink hover card renders a mini document: the editor gutter would - * swallow most of the card's width. */ -.note-preview { - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - - .meowdown-content { - padding: 0; - } -} - /* The active mode description re-animates whenever the mode changes (keyed * remount in app.tsx). */ @keyframes meowdown-fade-in { From cebe554fe8074ffa2e40d54ca91dcae63323576f Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 12 Jul 2026 19:56:53 +1000 Subject: [PATCH 13/13] refactor(website): drop the hover hint from the initial content --- website/src/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/app.tsx b/website/src/app.tsx index 6cd5a97e..f0699795 100644 --- a/website/src/app.tsx +++ b/website/src/app.tsx @@ -135,7 +135,7 @@ Drop a [link](https://github.com/prosekit/meowdown) and keep on writing. Label your notes with tags like #meow and #markdown. Type \`#\` followed by a letter to see suggestions. -Connect notes with wikilinks like [[Daily journal]] and [[Reading list]]. Type \`[[\` to link another note, and hover a link to peek inside it. +Connect notes with wikilinks like [[Daily journal]] and [[Reading list]]. Type \`[[\` to link another note. Select some text and click the sparkle button (or press \`Mod-Shift-J\`) to run a command on it. The result streams into a preview, and nothing changes until you accept it. Track things two ways. Type \`+ \` for a circle checkbox task, or \`[] \` for a square checkbox task: