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/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..7c99e89c 100644 --- a/packages/core/src/extensions/link-hover.ts +++ b/packages/core/src/extensions/link-hover.ts @@ -1,16 +1,23 @@ 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) }, + isSamePayload: (previous, next) => { + return previous.href === next.href && previous.title === next.title + }, onHoverChange, }) } 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/mark-hover.ts b/packages/core/src/extensions/mark-hover.ts index 8847c7d9..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 { 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, type PluginKey } from '@prosekit/pm/state' import type { EditorView } from '@prosekit/pm/view' export interface MarkHoverHit { @@ -9,42 +9,99 @@ 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. */ 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({ + key: config.key, + 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, + }), + }), ) } 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 }), }) } 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..4f8fbe11 --- /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('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) + 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..6ea3db49 --- /dev/null +++ b/packages/core/src/extensions/wikilink-hover.ts @@ -0,0 +1,36 @@ +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 wikilink currently under the pointer. */ +export interface WikilinkHoverHit extends WikilinkHit { + /** The rendered wikilink label used as the popup anchor. */ + element: HTMLElement +} + +/** Called once on wikilink enter and with `undefined` on leave or invalidation. */ +export type WikilinkHoverHandler = (hit: WikilinkHoverHit | undefined) => void + +/** + * 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 + * descendants of one label is de-duplicated. + */ +export function defineWikilinkHoverHandler(onHoverChange: WikilinkHoverHandler): PlainExtension { + return defineMarkHoverHandler({ + key: wikilinkHoverKey, + 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' diff --git a/packages/react/README.md b/packages/react/README.md index a06beba0..5d665ef5 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -56,12 +56,33 @@ 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 hovered wiki link's `target`. Returning `null` renders no +card. + +```tsx + + + {(hit) => } + + +``` + +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). -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/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..b5dd9770 100644 --- a/packages/react/src/components/markdown-view.tsx +++ b/packages/react/src/components/markdown-view.tsx @@ -84,9 +84,16 @@ 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. */ + /** 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 @@ -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 }) @@ -501,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 @@ -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, 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..b79c9ad7 --- /dev/null +++ b/packages/react/src/components/wikilink-hover-card.module.css @@ -0,0 +1,55 @@ +.Positioner { + z-index: 50; + 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(20rem, calc(100vw - 1rem)); + max-height: min(12rem, calc(100vh - 1rem)); + 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); + 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 new file mode 100644 index 00000000..c279c9a4 --- /dev/null +++ b/packages/react/src/components/wikilink-hover-card.module.d.css.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +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 new file mode 100644 index 00000000..44b62658 --- /dev/null +++ b/packages/react/src/components/wikilink-hover-card.test.tsx @@ -0,0 +1,160 @@ +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 HostPreviewCard() { + return ( + + {(hit) =>
Preview: {hit.target}
} +
+ ) +} + +describe('WikilinkHoverCard', () => { + it('opens after a 300ms dwell and closes 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('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( + + + {(hit) => (hit.target === 'Known' ?
Preview: {hit.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('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() + 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..639e2f56 --- /dev/null +++ b/packages/react/src/components/wikilink-hover-card.tsx @@ -0,0 +1,104 @@ +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 { 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. Returning `null` renders + * no card. + */ + 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. + */ +export function WikilinkHoverCard({ children, className }: WikilinkHoverCardProps): ReactNode { + const [hit, setHit] = useState() + const lastRectRef = useRef(null) + const [displayed, setDisplayed] = useState() + const [open, setOpen] = useState(false) + + const [hoverExtension] = useState(() => { + 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 (!hit) { + const timer = setTimeout(() => setOpen(false), CLOSE_DELAY) + return () => clearTimeout(timer) + } + + // 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 = displayed ? children(displayed) : null + + return ( + { + if (!nextOpen) setOpen(false) + }} + onOpenChangeComplete={(nextOpen) => { + if (!nextOpen) setDisplayed(undefined) + }} + > + {body != null && ( + + + + {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, 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'), }) diff --git a/website/src/app.tsx b/website/src/app.tsx index 09faaf37..f0699795 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 ( + + {(hit) => { + const markdown = NOTE_PREVIEWS[hit.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. @@ -82,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. - 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: @@ -359,6 +412,7 @@ export function App() { onExitBoundary={handleExitBoundary} > +