From e2cd1c7ad2e82fdeb6494c4713dc3ee369e193b3 Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 14 Jun 2026 02:03:56 +1000 Subject: [PATCH 1/4] feat: add link and wikilink hover cards and Mod+click handlers --- packages/core/README.md | 10 + packages/core/src/extensions/link-click.ts | 74 +++++ packages/core/src/extensions/link-hit.test.ts | 105 +++++++ packages/core/src/extensions/link-hit.ts | 126 ++++++++ packages/core/src/index.ts | 9 + packages/core/src/style.css | 8 + packages/react/README.md | 4 + packages/react/src/components/editor.tsx | 44 +++ .../src/components/link-card-default.tsx | 51 +++ .../src/components/link-hover-card.module.css | 57 ++++ .../link-hover-card.module.css.d.ts | 10 + .../src/components/link-hover-card.test.tsx | 89 ++++++ .../react/src/components/link-hover-card.tsx | 290 ++++++++++++++++++ .../react/src/components/prosekit-editor.tsx | 43 +++ packages/react/src/components/types.ts | 38 +++ packages/react/src/index.ts | 9 + 16 files changed, 967 insertions(+) create mode 100644 packages/core/src/extensions/link-click.ts create mode 100644 packages/core/src/extensions/link-hit.test.ts create mode 100644 packages/core/src/extensions/link-hit.ts create mode 100644 packages/react/src/components/link-card-default.tsx create mode 100644 packages/react/src/components/link-hover-card.module.css create mode 100644 packages/react/src/components/link-hover-card.module.css.d.ts create mode 100644 packages/react/src/components/link-hover-card.test.tsx create mode 100644 packages/react/src/components/link-hover-card.tsx diff --git a/packages/core/README.md b/packages/core/README.md index cc9861f..f454cf9 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -13,6 +13,14 @@ The editor extension binds inline-format toggles (`Mod` = Cmd on macOS, Ctrl els | `Mod-E` | `editor.commands.toggleCode()` | `` `code` `` | | `Mod-Shift-X` | `editor.commands.toggleDel()` | `~~strikethrough~~` | +## Link and wikilink clicks + +`defineLinkClickHandler(handler)` and `defineWikilinkClickHandler(handler)` are optional extensions that route a Mod+click (Cmd/Ctrl) on a rendered Markdown link or wikilink to `handler`. A plain click is left alone so the caret lands in the link for editing. The handler receives a context with the resolved link (`href` / `text` for links, `target` / `display` / `raw` for wikilinks), its document range, and the originating `MouseEvent`. + +`wikilinkAt(state, pos)` and `linkAt(state, pos)` are the underlying pure hit-test helpers (using the same Lezer parser that applies the marks), exported for hosts that build their own affordances. + +While a click handler is active the editor carries a `data-link-click` or `data-wikilink-click` attribute, used to scope the pointer cursor (see Styling). + ## Styling `@meowdown/core/style.css` ships a default editor theme. Colors use `light-dark()`, so they follow the page's `color-scheme` (set `color-scheme: light dark` on `:root` for automatic dark mode). Customize by overriding these variables on `:root` or any ancestor: @@ -37,6 +45,8 @@ Tags (`#tag`) render as pills via the `.md-tag` class, tinted from `--meowdown-a Wikilinks (`[[target]]`) render with a dashed underline via the `.md-wikilink` class, colored by `--meowdown-accent`. Their `[[` `]]` brackets behave like other syntax characters: dimmed in show mode, hidden in hide and focus modes. +Links and wikilinks show a pointer cursor only while a click handler is active, scoped by the `[data-link-click] a` and `[data-wikilink-click] .md-wikilink` selectors. + ## License MIT diff --git a/packages/core/src/extensions/link-click.ts b/packages/core/src/extensions/link-click.ts new file mode 100644 index 0000000..b4e5013 --- /dev/null +++ b/packages/core/src/extensions/link-click.ts @@ -0,0 +1,74 @@ +import { definePlugin, Priority, withPriority, type PlainExtension } from '@prosekit/core' +import { Plugin } from '@prosekit/pm/state' + +import { linkAt, wikilinkAt, type LinkHit, type WikilinkHit } from './link-hit.ts' + +/** Context passed to an {@link LinkClickHandler}. */ +export interface LinkClickContext extends LinkHit { + /** The originating click, for new-tab / button / modifier inspection. */ + event: MouseEvent +} + +/** Context passed to an {@link WikilinkClickHandler}. */ +export interface WikilinkClickContext extends WikilinkHit { + /** The originating click, for new-tab / button / modifier inspection. */ + event: MouseEvent + /** Whether the target resolves to an existing note, when the host knows. */ + resolved?: boolean +} + +export type LinkClickHandler = (context: LinkClickContext) => void +export type WikilinkClickHandler = (context: WikilinkClickContext) => void + +function isModClick(event: MouseEvent): boolean { + return event.metaKey || event.ctrlKey +} + +/** + * Fires `handler` on Mod+click (Cmd/Ctrl) of a rendered Markdown link. A plain + * click is left alone so the caret lands in the link for editing. Adds a + * `data-link-click` attribute so CSS can scope a pointer cursor to "active". + */ +export function defineLinkClickHandler(handler: LinkClickHandler): PlainExtension { + const plugin = new Plugin({ + props: { + attributes: { 'data-link-click': '' }, + handleClick(view, pos, event) { + const target = event.target as Element | null + if (!target?.closest('a') || target.closest('pre, code')) return false + if (!isModClick(event)) return false + const hit = linkAt(view.state, pos) + if (!hit) return false + handler({ ...hit, event }) + return true + }, + }, + }) + // Win the `handleClick` ordering against `defineModClickPrevention`, which + // returns `true` for any Mod+click. ProseMirror runs handlers in plugin + // order and the first `true` wins. + return withPriority(definePlugin(plugin), Priority.high) +} + +/** + * Fires `handler` on Mod+click (Cmd/Ctrl) of a rendered wikilink. A plain click + * is left alone so the caret lands in the link for editing. Adds a + * `data-wikilink-click` attribute so CSS can scope a pointer cursor to "active". + */ +export function defineWikilinkClickHandler(handler: WikilinkClickHandler): PlainExtension { + const plugin = new Plugin({ + props: { + attributes: { 'data-wikilink-click': '' }, + handleClick(view, pos, event) { + const target = event.target as Element | null + if (!target?.closest('.md-wikilink')) return false + if (!isModClick(event)) return false + const hit = wikilinkAt(view.state, pos) + if (!hit) return false + handler({ ...hit, event }) + return true + }, + }, + }) + return withPriority(definePlugin(plugin), Priority.high) +} diff --git a/packages/core/src/extensions/link-hit.test.ts b/packages/core/src/extensions/link-hit.test.ts new file mode 100644 index 0000000..f21b3d7 --- /dev/null +++ b/packages/core/src/extensions/link-hit.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' + +import { findText } from '../testing/find-text.ts' +import { setupFixture } from '../testing/index.ts' + +import { linkAt, wikilinkAt } from './link-hit.ts' + +describe('wikilinkAt', () => { + it('spans the whole [[target]] and reports the target', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('See [[Charlotte]] here.'))) + + const pos = findText(fixture.doc, 'Charlotte') + 1 + const hit = wikilinkAt(fixture.state, pos) + expect(hit).toBeTruthy() + expect(hit!.target).toBe('Charlotte') + expect(hit!.display).toBe('Charlotte') + expect(fixture.doc.textBetween(hit!.from, hit!.to)).toBe('[[Charlotte]]') + }) + + it('splits target and display on the first pipe', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('go [[Project X|the project]] now'))) + + const pos = findText(fixture.doc, 'the project') + 1 + const hit = wikilinkAt(fixture.state, pos) + expect(hit!.target).toBe('Project X') + expect(hit!.display).toBe('the project') + expect(hit!.raw).toBe('Project X|the project') + }) + + it('resolves adjacent links independently', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('x [[aaa]][[bbb]] y'))) + + expect(wikilinkAt(fixture.state, findText(fixture.doc, 'aaa') + 1)!.target).toBe('aaa') + expect(wikilinkAt(fixture.state, findText(fixture.doc, 'bbb') + 1)!.target).toBe('bbb') + }) + + it('returns null outside any wikilink', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('See [[Charlotte]] here.'))) + + expect(wikilinkAt(fixture.state, findText(fixture.doc, 'here') + 1)).toBe(null) + }) + + it('returns null inside a code block', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.codeBlock({ language: '' }, '[[Charlotte]]'))) + + expect(wikilinkAt(fixture.state, findText(fixture.doc, 'Charlotte') + 1)).toBe(null) + }) + + it('returns null inside a code span', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('pre `[[Charlotte]]` post'))) + + expect(wikilinkAt(fixture.state, findText(fixture.doc, 'Charlotte') + 1)).toBe(null) + }) + + it('returns null for an empty target', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('a [[ |alias]] b'))) + + expect(wikilinkAt(fixture.state, findText(fixture.doc, 'alias') + 1)).toBe(null) + }) +}) + +describe('linkAt', () => { + it('reports the href and text of a Markdown link', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('see [docs](http://x.test) end'))) + + const pos = findText(fixture.doc, 'docs') + 1 + const hit = linkAt(fixture.state, pos) + expect(hit).toBeTruthy() + expect(hit!.href).toBe('http://x.test') + expect(hit!.text).toBe('docs') + expect(fixture.doc.textBetween(hit!.from, hit!.to)).toBe('docs') + }) + + it('returns null off any link', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('see [docs](http://x.test) end'))) + + expect(linkAt(fixture.state, findText(fixture.doc, 'see') + 1)).toBe(null) + }) + + it('returns null inside a code block', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.codeBlock({ language: '' }, '[docs](http://x.test)'))) + + expect(linkAt(fixture.state, findText(fixture.doc, 'docs') + 1)).toBe(null) + }) +}) diff --git a/packages/core/src/extensions/link-hit.ts b/packages/core/src/extensions/link-hit.ts new file mode 100644 index 0000000..994f3c0 --- /dev/null +++ b/packages/core/src/extensions/link-hit.ts @@ -0,0 +1,126 @@ +import type { EditorState } from '@prosekit/pm/state' + +import { collectInlineElements, parseInline } from '../lezer/inline.ts' +import { LEZER_NODE_IDS } from '../lezer/node-ids.ts' + +/** A resolved wikilink under a document position. */ +export interface WikilinkHit { + /** Document position of the opening `[[`. */ + from: number + /** Document position just after the closing `]]`. */ + to: number + /** Target before the first `|`, trimmed. Empty links never produce a hit. */ + target: string + /** Display text after the first `|`, trimmed; equals `target` when no `|`. */ + display: string + /** Raw text between the brackets (`target|display`). */ + raw: string +} + +/** A resolved Markdown link under a document position. */ +export interface LinkHit { + /** Document position of the start of the link text. */ + from: number + /** Document position of the end of the link text. */ + to: number + /** The link URL (the `mdLinkText` href attribute). */ + href: string + /** The visible link text. */ + text: string +} + +/** + * Resolves the wikilink whose `[[...]]` spans `pos`, or `null`. Uses the same + * Lezer inline parser that applies the `mdWikilink` mark, so hit detection can + * never disagree with rendering. Returns `null` inside code blocks, inside + * blocks with non-text inline content (the offset mapping assumes all text), + * outside any wikilink, or when the target is empty after trimming. + */ +export function wikilinkAt(state: EditorState, pos: number): WikilinkHit | null { + const $pos = state.doc.resolve(pos) + const block = $pos.parent + if (!block.isTextblock || block.type.spec.code) return null + + let allText = true + block.forEach((child) => { + if (!child.isText) allText = false + }) + if (!allText) return null + + const blockStart = $pos.start() + const offset = pos - blockStart + const text = block.textContent + + const elements = collectInlineElements( + parseInline(text), + (node) => node.type === LEZER_NODE_IDS.Wikilink, + ) + const hit = elements.find((element) => offset >= element.from && offset < element.to) + if (!hit) return null + + const raw = text.slice(hit.from + 2, hit.to - 2) + const pipe = raw.indexOf('|') + const target = (pipe >= 0 ? raw.slice(0, pipe) : raw).trim() + const display = (pipe >= 0 ? raw.slice(pipe + 1) : raw).trim() + if (!target) return null + + return { from: blockStart + hit.from, to: blockStart + hit.to, target, display, raw } +} + +/** + * Resolves the Markdown link whose `[text](url)` spans `pos`, or `null`. Uses + * the same Lezer inline parser that applies the `mdLinkText` mark. The returned + * `from`/`to` span only the link's visible label (not the brackets or URL). + * Returns `null` inside code blocks, inside blocks with non-text inline + * content, off any link, or when the URL is empty. Images (`![alt](url)`) are + * not links and never match. + */ +export function linkAt(state: EditorState, pos: number): LinkHit | null { + const $pos = state.doc.resolve(pos) + const block = $pos.parent + if (!block.isTextblock || block.type.spec.code) return null + + let allText = true + block.forEach((child) => { + if (!child.isText) allText = false + }) + if (!allText) return null + + const blockStart = $pos.start() + const offset = pos - blockStart + const text = block.textContent + + const links = collectInlineElements( + parseInline(text), + (node) => node.type === LEZER_NODE_IDS.Link, + ) + for (const link of links) { + if (offset < link.from || offset >= link.to) continue + + let labelStart = -1 + let labelEnd = -1 + let bracketCount = 0 + let url: { from: number; to: number } | null = null + for (const child of link.children) { + if (child.type === LEZER_NODE_IDS.LinkMark) { + bracketCount++ + if (bracketCount === 1) labelStart = child.to + else if (bracketCount === 2) labelEnd = child.from + } else if (child.type === LEZER_NODE_IDS.URL && !url) { + url = child + } + } + if (labelStart < 0 || labelEnd < 0) continue + + const href = url ? text.slice(url.from, url.to) : '' + if (!href) return null + + return { + from: blockStart + labelStart, + to: blockStart + labelEnd, + href, + text: text.slice(labelStart, labelEnd), + } + } + return null +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 49efa81..b648695 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,3 +6,12 @@ export { export { type MarkMode, defineMarkMode } from './extensions/mark-mode.ts' export { docToMarkdown } from './converters/pm-to-md.ts' export { markdownToDoc } from './converters/md-to-pm.ts' +export { + defineLinkClickHandler, + defineWikilinkClickHandler, + type LinkClickHandler, + type WikilinkClickHandler, + type LinkClickContext, + type WikilinkClickContext, +} from './extensions/link-click.ts' +export { linkAt, wikilinkAt, type LinkHit, type WikilinkHit } from './extensions/link-hit.ts' diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 0f915db..f959af8 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -227,6 +227,14 @@ text-underline-offset: 2px; } +/* A pointer cursor only while a click handler is active (Mod+click to follow). */ +.ProseMirror[data-link-click] a { + cursor: pointer; +} +.ProseMirror[data-wikilink-click] .md-wikilink { + cursor: pointer; +} + /* --------------------------------------------------------------------------- * Markdown syntax characters (`md-mark`) and link URIs * ------------------------------------------------------------------------- */ diff --git a/packages/react/README.md b/packages/react/README.md index af151c0..18a5887 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -34,6 +34,10 @@ The Markdown editor component. Renders inside a `div.meowdown` wrapper that fill - `onDocChange?: VoidFunction`: called on every document change. - `onTagSearch?: (query: string) => string[] | Promise`: enables the tag menu, which opens when typing `#` followed by text in a rich mode; returns the tags to show for a query (lowercased, punctuation stripped). Omit to disable. - `onWikilinkSearch?: (query: string) => string[] | Promise`: enables the wikilink menu, which opens as soon as `[[` is typed in a rich mode; returns the note names to show for a query (lowercased, punctuation stripped, may be empty). Selecting a note inserts `[[Note Name]]`. Omit to disable. +- `onLinkHover?: (context) => ReactNode | Promise | null | false`: builds the floating hover card shown when the editor is focused and the pointer dwells on a rendered Markdown link. `context` carries the link (`href`, `text`, `from`, `to`) and an `AbortSignal` that aborts when the pointer leaves. Return a node (or a promise of one) to fill the card, nothing for the built-in card (the URL plus Edit / Copy / Open), or `false` for no card. Pass a stable function. Ignored in source mode. +- `onLinkClick?: (context) => void`: called on Mod+click (Cmd/Ctrl) of a rendered Markdown link, or the built-in card's Open action. A plain click edits the link text instead. `context` carries the link and the originating `MouseEvent`; the host decides what to do (open in a browser, an in-app webview, etc.). Pass a stable function. Ignored in source mode. +- `onWikilinkHover?: (context) => ReactNode | Promise | null | false`: builds the floating hover card for wikilinks. Same contract as `onLinkHover`, with `context` carrying the wikilink `target`, `display`, `raw`, and positions. Ignored in source mode. +- `onWikilinkClick?: (context) => void`: called on Mod+click (Cmd/Ctrl) of a rendered wikilink, or the built-in card's Open action. A plain click edits the link text instead. `context` carries the wikilink and the originating `MouseEvent`. Pass a stable function. Ignored in source mode. - `spellCheck?: boolean`: toggles the browser's native spell checking in the rich modes. Defaults to the browser's behavior. Ignored in source mode. - `ref?: Ref` diff --git a/packages/react/src/components/editor.tsx b/packages/react/src/components/editor.tsx index d6ef855..4a375a8 100644 --- a/packages/react/src/components/editor.tsx +++ b/packages/react/src/components/editor.tsx @@ -7,8 +7,12 @@ import { ProseKitEditor } from './prosekit-editor.tsx' import type { EditorHandle, EditorStateSnapshot, + LinkClickHandler, + LinkHoverHandler, SelectionHint, TagSearchHandler, + WikilinkClickHandler, + WikilinkHoverHandler, WikilinkSearchHandler, } from './types.ts' @@ -51,6 +55,38 @@ export interface EditorProps { */ onWikilinkSearch?: WikilinkSearchHandler + /** + * Builds the floating hover card shown when the editor is focused and the + * pointer dwells on a rendered Markdown link. Receives the link (href, text, + * positions) and an `AbortSignal`, and returns the React node to render, + * synchronously or as a promise. Return nothing to show the built-in card + * (the URL plus Edit / Copy / Open); return `false` to show no card. Pass a + * stable function. Ignored in source mode. + */ + onLinkHover?: LinkHoverHandler + + /** + * Called on Mod+click (Cmd/Ctrl) of a rendered Markdown link, or the built-in + * card's Open action. A plain click edits the link text instead. The host + * decides what to do (open in a browser, an in-app webview, etc.). Pass a + * stable function. Ignored in source mode. + */ + onLinkClick?: LinkClickHandler + + /** + * Builds the floating hover card for wikilinks. Same contract as + * `onLinkHover`, with the wikilink target and display text. Ignored in source + * mode. + */ + onWikilinkHover?: WikilinkHoverHandler + + /** + * Called on Mod+click (Cmd/Ctrl) of a rendered wikilink, or the built-in + * card's Open action. A plain click edits the link text instead. Pass a + * stable function. Ignored in source mode. + */ + onWikilinkClick?: WikilinkClickHandler + /** * Enables the browser's native spell checking in the rich modes. Defaults * to the browser's behavior. Ignored in source mode. @@ -67,6 +103,10 @@ export function Editor({ onDocChange, onTagSearch, onWikilinkSearch, + onLinkHover, + onLinkClick, + onWikilinkHover, + onWikilinkClick, spellCheck, ref, }: EditorProps) { @@ -126,6 +166,10 @@ export function Editor({ onDocChange={onDocChange} onTagSearch={onTagSearch} onWikilinkSearch={onWikilinkSearch} + onLinkHover={onLinkHover} + onLinkClick={onLinkClick} + onWikilinkHover={onWikilinkHover} + onWikilinkClick={onWikilinkClick} spellCheck={spellCheck} /> )} diff --git a/packages/react/src/components/link-card-default.tsx b/packages/react/src/components/link-card-default.tsx new file mode 100644 index 0000000..04b6259 --- /dev/null +++ b/packages/react/src/components/link-card-default.tsx @@ -0,0 +1,51 @@ +import type { MouseEvent } from 'react' + +import styles from './link-hover-card.module.css' + +export interface LinkCardDefaultProps { + kind: 'link' | 'wikilink' + /** The URL, shown for Markdown links. */ + href?: string + /** Place the caret in the link to edit its text. */ + onEdit: () => void + /** Copy the URL. Markdown links only. */ + onCopy?: () => void + /** Follow the link. Omitted when there is nothing to open. */ + onOpen?: (event: MouseEvent) => void +} + +/** The built-in hover card shown when a host hover handler returns nothing. */ +export function LinkCardDefault({ kind, href, onEdit, onCopy, onOpen }: LinkCardDefaultProps) { + return ( +
+ {kind === 'link' && href ? ( + + {href} + + ) : null} + + {onCopy ? ( + + ) : null} + {onOpen ? ( + + ) : null} +
+ ) +} diff --git a/packages/react/src/components/link-hover-card.module.css b/packages/react/src/components/link-hover-card.module.css new file mode 100644 index 0000000..f6dae45 --- /dev/null +++ b/packages/react/src/components/link-hover-card.module.css @@ -0,0 +1,57 @@ +.Positioner { + display: block; + z-index: 50; + width: min-content; + height: min-content; + overflow: visible; +} + +.Popup { + display: flex; + box-sizing: border-box; + max-width: 22rem; + padding: 0.25rem; + overflow: hidden; + white-space: nowrap; + border: 1px solid var(--meowdown-border); + border-radius: 0.75rem; + background: light-dark(#fff, #18181b); + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +.Default { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.Url { + max-width: 12rem; + padding: 0 0.5rem; + overflow: hidden; + font-size: 0.8125rem; + color: var(--meowdown-muted); + text-overflow: ellipsis; +} + +.Action { + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; + color: var(--meowdown-text); + cursor: pointer; + background: transparent; + border: none; + border-radius: 0.5rem; + + &:hover { + background: light-dark(#f4f4f5, #27272a); + } +} + +.Loading { + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + color: var(--meowdown-muted); +} diff --git a/packages/react/src/components/link-hover-card.module.css.d.ts b/packages/react/src/components/link-hover-card.module.css.d.ts new file mode 100644 index 0000000..3d166db --- /dev/null +++ b/packages/react/src/components/link-hover-card.module.css.d.ts @@ -0,0 +1,10 @@ +// @ts-nocheck +declare const styles = { + 'Positioner': '' as string, + 'Popup': '' as string, + 'Default': '' as string, + 'Url': '' as string, + 'Action': '' as string, + 'Loading': '' as string, +} as const; +export default styles; diff --git a/packages/react/src/components/link-hover-card.test.tsx b/packages/react/src/components/link-hover-card.test.tsx new file mode 100644 index 0000000..a5ebcdd --- /dev/null +++ b/packages/react/src/components/link-hover-card.test.tsx @@ -0,0 +1,89 @@ +import '../testing/index.ts' + +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-react' +import { page } from 'vitest/browser' + +import { hover } from '../testing/mouse.ts' + +import { Editor } from './editor.tsx' +import type { LinkClickHandler, WikilinkClickHandler } from './types.ts' + +const pmRoot = page.locate('.ProseMirror') +const wikilink = page.locate('.ProseMirror .md-wikilink').first() +const link = page.locate('.ProseMirror a').first() +const card = page.getByTestId('link-hover-card') + +describe('LinkHoverCard', () => { + it('opens a custom wikilink hover card after dwell', async () => { + const screen = await render( +
{context.target}
} + />, + ) + await pmRoot.click() + await hover(wikilink) + + await expect.element(screen.getByTestId('note-preview')).toBeVisible() + await expect.element(screen.getByTestId('note-preview')).toHaveTextContent('Charlotte') + }) + + it('resolves an async wikilink hover card', async () => { + const screen = await render( + + Promise.resolve(
{context.target}
) + } + />, + ) + await pmRoot.click() + await hover(wikilink) + + await expect.element(screen.getByTestId('note-preview')).toHaveTextContent('Charlotte') + }) + + it('navigates a wikilink on Mod+click but not on a plain click', async () => { + const onWikilinkClick = vi.fn() + await render( + , + ) + await pmRoot.click() + + await wikilink.click() + expect(onWikilinkClick).not.toHaveBeenCalled() + + await wikilink.click({ modifiers: ['ControlOrMeta'] }) + expect(onWikilinkClick).toHaveBeenCalledTimes(1) + expect(onWikilinkClick.mock.calls[0][0].target).toBe('Charlotte') + }) + + it('shows the default link card and its Open action fires onLinkClick', async () => { + const onLinkClick = vi.fn() + await render( + , + ) + await pmRoot.click() + await hover(link) + + await expect.element(card).toBeVisible() + await page.getByTestId('link-card-open').click() + expect(onLinkClick).toHaveBeenCalledTimes(1) + expect(onLinkClick.mock.calls[0][0].href).toBe('http://x.test') + }) + + it('ignores the hover/click props in source mode', async () => { + const onWikilinkClick = vi.fn() + await render( +
x
} + onWikilinkClick={onWikilinkClick} + />, + ) + await expect.element(page.locate('.cm-editor')).toBeInTheDocument() + await expect.element(card).not.toBeInTheDocument() + }) +}) diff --git a/packages/react/src/components/link-hover-card.tsx b/packages/react/src/components/link-hover-card.tsx new file mode 100644 index 0000000..e0f874a --- /dev/null +++ b/packages/react/src/components/link-hover-card.tsx @@ -0,0 +1,290 @@ +import { + type EditorExtension, + linkAt, + type LinkHit, + wikilinkAt, + type WikilinkHit, +} from '@meowdown/core' +import { defineDOMEventHandler, union } from '@prosekit/core' +import { TextSelection } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' +import { useEditor, useExtension } from '@prosekit/react' +import { + InlinePopoverPopup, + InlinePopoverPositioner, + InlinePopoverRoot, +} from '@prosekit/react/inline-popover' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' + +import { LinkCardDefault } from './link-card-default.tsx' +import styles from './link-hover-card.module.css' +import type { + LinkClickHandler, + LinkHoverHandler, + WikilinkClickHandler, + WikilinkHoverHandler, +} from './types.ts' + +/** Pointer dwell before the card opens, in milliseconds. */ +const DWELL_MS = 350 +/** Grace period after the pointer leaves before the card closes, in milliseconds. */ +const CLOSE_GRACE_MS = 200 + +type Hovered = + | { kind: 'link'; element: Element; hit: LinkHit } + | { kind: 'wikilink'; element: Element; hit: WikilinkHit } + +export interface LinkHoverCardProps { + onLinkHover?: LinkHoverHandler + onWikilinkHover?: WikilinkHoverHandler + onLinkClick?: LinkClickHandler + onWikilinkClick?: WikilinkClickHandler +} + +function isPromise(value: unknown): value is Promise { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ) +} + +export function LinkHoverCard({ + onLinkHover, + onWikilinkHover, + onLinkClick, + onWikilinkClick, +}: LinkHoverCardProps) { + const editor = useEditor() + + const [open, setOpen] = useState(false) + const [anchor, setAnchor] = useState(null) + const [content, setContent] = useState(null) + + const openRef = useRef(false) + const currentElementRef = useRef(null) + const dwellTimerRef = useRef>(undefined) + const closeTimerRef = useRef>(undefined) + const abortRef = useRef(null) + + const linkEnabled = !!(onLinkHover || onLinkClick) + const wikilinkEnabled = !!(onWikilinkHover || onWikilinkClick) + + // Hover detection runs through stable extension handlers that always call the + // latest closure via these refs, so they see the current props. + const onMoveRef = useRef<(view: EditorView, event: PointerEvent) => void>(() => {}) + const onLeaveRef = useRef<() => void>(() => {}) + const onDownRef = useRef<() => void>(() => {}) + + const cancelClose = () => { + if (closeTimerRef.current != null) { + clearTimeout(closeTimerRef.current) + closeTimerRef.current = undefined + } + } + + const closeCard = () => { + openRef.current = false + currentElementRef.current = null + abortRef.current?.abort() + abortRef.current = null + setOpen(false) + } + + const scheduleCloseIfOpen = () => { + currentElementRef.current = null + if (dwellTimerRef.current != null) { + clearTimeout(dwellTimerRef.current) + dwellTimerRef.current = undefined + } + if (openRef.current && closeTimerRef.current == null) { + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = undefined + closeCard() + }, CLOSE_GRACE_MS) + } + } + + const placeCaret = (hit: LinkHit | WikilinkHit) => { + const { view } = editor + const pos = Math.min(hit.to, view.state.doc.content.size) + view.dispatch( + view.state.tr.setSelection(TextSelection.create(view.state.doc, pos)).scrollIntoView(), + ) + view.focus() + } + + const renderDefault = (hovered: Hovered): ReactNode => { + const onEdit = () => { + placeCaret(hovered.hit) + closeCard() + } + if (hovered.kind === 'link') { + const { href } = hovered.hit + return ( + void navigator.clipboard?.writeText(href)} + onOpen={(event) => { + closeCard() + if (onLinkClick) { + onLinkClick({ ...hovered.hit, event: event.nativeEvent }) + } else { + window.open(href, '_blank', 'noopener') + } + }} + /> + ) + } + return ( + { + closeCard() + onWikilinkClick({ ...hovered.hit, event: event.nativeEvent }) + } + : undefined + } + /> + ) + } + + const openCard = (hovered: Hovered) => { + cancelClose() + setAnchor(hovered.element) + setOpen(true) + openRef.current = true + + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + + const result = + hovered.kind === 'wikilink' + ? onWikilinkHover?.({ ...hovered.hit, signal: controller.signal }) + : onLinkHover?.({ ...hovered.hit, signal: controller.signal }) + + if (result === false) { + closeCard() + return + } + if (result == null) { + setContent(renderDefault(hovered)) + return + } + if (isPromise(result)) { + setContent(
Loading...
) + result.then( + (node) => { + if (controller.signal.aborted) return + setContent(node == null ? renderDefault(hovered) : node) + }, + () => { + if (!controller.signal.aborted) setContent(renderDefault(hovered)) + }, + ) + return + } + setContent(result) + } + + onMoveRef.current = (view, event) => { + if (!view.hasFocus()) return + const target = event.target as Element | null + if (!target || target.closest('pre, code')) { + scheduleCloseIfOpen() + return + } + const wikiEl = wikilinkEnabled ? target.closest('.md-wikilink') : null + const linkEl = !wikiEl && linkEnabled ? target.closest('a') : null + const element = wikiEl ?? linkEl + if (!element) { + scheduleCloseIfOpen() + return + } + cancelClose() + if (element === currentElementRef.current) return + + const at = view.posAtCoords({ left: event.clientX, top: event.clientY }) + let hovered: Hovered | null = null + if (at && wikiEl) { + const hit = wikilinkAt(view.state, at.pos) + if (hit) hovered = { kind: 'wikilink', element: wikiEl, hit } + } else if (at && linkEl) { + const hit = linkAt(view.state, at.pos) + if (hit) hovered = { kind: 'link', element: linkEl, hit } + } + if (!hovered) { + scheduleCloseIfOpen() + return + } + + currentElementRef.current = element + if (dwellTimerRef.current != null) clearTimeout(dwellTimerRef.current) + if (openRef.current) { + openCard(hovered) + } else { + const next = hovered + dwellTimerRef.current = setTimeout(() => { + dwellTimerRef.current = undefined + openCard(next) + }, DWELL_MS) + } + } + + onLeaveRef.current = () => scheduleCloseIfOpen() + onDownRef.current = () => closeCard() + + const extension = useMemo( + () => + union( + defineDOMEventHandler('pointermove', (view, event) => { + onMoveRef.current(view, event) + return false + }), + defineDOMEventHandler('pointerleave', () => { + onLeaveRef.current() + return false + }), + defineDOMEventHandler('pointerdown', () => { + onDownRef.current() + return false + }), + ), + [], + ) + useExtension(extension) + + useEffect(() => { + return () => { + if (dwellTimerRef.current != null) clearTimeout(dwellTimerRef.current) + if (closeTimerRef.current != null) clearTimeout(closeTimerRef.current) + abortRef.current?.abort() + } + }, []) + + return ( + setOpen(event.detail)} + > + + + {content} + + + + ) +} diff --git a/packages/react/src/components/prosekit-editor.tsx b/packages/react/src/components/prosekit-editor.tsx index 872e314..ae8440f 100644 --- a/packages/react/src/components/prosekit-editor.tsx +++ b/packages/react/src/components/prosekit-editor.tsx @@ -1,6 +1,8 @@ import { defineEditorExtension, + defineLinkClickHandler, defineMarkMode, + defineWikilinkClickHandler, type TypedEditor, type EditorExtension, type MarkMode, @@ -16,13 +18,18 @@ import { useImperativeHandle, useMemo, useState, type Ref } from 'react' import { BlockHandle } from './block-handle.tsx' import { DropIndicator } from './drop-indicator.tsx' +import { LinkHoverCard } from './link-hover-card.tsx' import { SlashMenu } from './slash-menu.tsx' import { TagMenu } from './tag-menu.tsx' import type { EditorHandle, EditorStateSnapshot, + LinkClickHandler, + LinkHoverHandler, SelectionHint, TagSearchHandler, + WikilinkClickHandler, + WikilinkHoverHandler, WikilinkSearchHandler, } from './types.ts' import { WikilinkMenu } from './wikilink-menu.tsx' @@ -61,6 +68,18 @@ export interface ProseKitEditorProps { /** Enables the wikilink menu. See `EditorProps.onWikilinkSearch`. */ onWikilinkSearch?: WikilinkSearchHandler + /** Builds the hover card for Markdown links. See `EditorProps.onLinkHover`. */ + onLinkHover?: LinkHoverHandler + + /** Fires on Mod+click of a Markdown link. See `EditorProps.onLinkClick`. */ + onLinkClick?: LinkClickHandler + + /** Builds the hover card for wikilinks. See `EditorProps.onWikilinkHover`. */ + onWikilinkHover?: WikilinkHoverHandler + + /** Fires on Mod+click of a wikilink. See `EditorProps.onWikilinkClick`. */ + onWikilinkClick?: WikilinkClickHandler + /** Enables or disables spell checking in the editor. */ spellCheck?: boolean @@ -74,6 +93,10 @@ export function ProseKitEditor({ onDocChange, onTagSearch, onWikilinkSearch, + onLinkHover, + onLinkClick, + onWikilinkHover, + onWikilinkClick, spellCheck, ref, }: ProseKitEditorProps) { @@ -146,6 +169,18 @@ export function ProseKitEditor({ }, [onDocChange]) useExtension(docChangeExtension, { editor }) + const linkClickExtension = useMemo(() => { + return onLinkClick ? defineLinkClickHandler(onLinkClick) : null + }, [onLinkClick]) + useExtension(linkClickExtension, { editor }) + + const wikilinkClickExtension = useMemo(() => { + return onWikilinkClick ? defineWikilinkClickHandler(onWikilinkClick) : null + }, [onWikilinkClick]) + useExtension(wikilinkClickExtension, { editor }) + + const hoverCardEnabled = !!(onLinkHover || onLinkClick || onWikilinkHover || onWikilinkClick) + return (
@@ -154,6 +189,14 @@ export function ProseKitEditor({ {onTagSearch && } {onWikilinkSearch && } + {hoverCardEnabled && ( + + )}
) } diff --git a/packages/react/src/components/types.ts b/packages/react/src/components/types.ts index eba732a..932fef7 100644 --- a/packages/react/src/components/types.ts +++ b/packages/react/src/components/types.ts @@ -1,4 +1,13 @@ +import type { LinkHit, WikilinkHit } from '@meowdown/core' import type { SelectionJSON } from '@prosekit/core' +import type { ReactNode } from 'react' + +export type { + LinkClickContext, + LinkClickHandler, + WikilinkClickContext, + WikilinkClickHandler, +} from '@meowdown/core' /** A selection to restore: an exact JSON selection, or a document edge. */ export type SelectionHint = SelectionJSON | 'start' | 'end' @@ -57,3 +66,32 @@ export type TagSearchHandler = (query: string) => string[] | Promise * and returns the note names to show, either synchronously or as a promise. */ export type WikilinkSearchHandler = (query: string) => string[] | Promise + +/** Context passed to a {@link LinkHoverHandler}. */ +export interface LinkHoverContext extends LinkHit { + /** Aborts when the pointer leaves before an async card resolves. */ + signal: AbortSignal +} + +/** Context passed to a {@link WikilinkHoverHandler}. */ +export interface WikilinkHoverContext extends WikilinkHit { + /** Aborts when the pointer leaves before an async card resolves. */ + signal: AbortSignal +} + +/** + * What a hover handler returns: a React node (or a promise of one) to fill the + * card, `null` / `undefined` for the built-in default card, or `false` to show + * no card. + */ +export type HoverCardResult = ReactNode | Promise | null | false + +/** + * Builds the hover card for a Markdown link. Runs while the editor is focused + * and the pointer dwells on a link. Return a node (or a promise of one) to fill + * the card; return nothing for the built-in card; `false` for none. + */ +export type LinkHoverHandler = (context: LinkHoverContext) => HoverCardResult + +/** Builds the hover card for a wikilink. Same contract as {@link LinkHoverHandler}. */ +export type WikilinkHoverHandler = (context: WikilinkHoverContext) => HoverCardResult diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6c0dfbc..05fecb0 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -2,8 +2,17 @@ export { Editor, type EditorMode, type EditorProps } from './components/editor.t export type { EditorHandle, EditorStateSnapshot, + HoverCardResult, + LinkClickContext, + LinkClickHandler, + LinkHoverContext, + LinkHoverHandler, SelectionHint, TagSearchHandler, + WikilinkClickContext, + WikilinkClickHandler, + WikilinkHoverContext, + WikilinkHoverHandler, WikilinkSearchHandler, } from './components/types.ts' From 25c9bc9ece02c01bdf54bb35cbcb83b83eb76a59 Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 14 Jun 2026 02:03:56 +1000 Subject: [PATCH 2/4] chore: upgrade prosekit to beta for the inline-popover anchor prop --- packages/core/package.json | 2 +- packages/react/package.json | 2 +- pnpm-lock.yaml | 30 +++++++++++++++--------------- pnpm-workspace.yaml | 3 +++ 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 7c7cae8..66f31e2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,7 @@ "@lezer/common": "^1.5.2", "@lezer/markdown": "^1.6.4", "@prosekit/core": "^0.12.3", - "@prosekit/extensions": "^0.17.4", + "@prosekit/extensions": "^0.17.5-beta.0", "@prosekit/pm": "^0.1.18", "prosemirror-tables": "^1.8.5" }, diff --git a/packages/react/package.json b/packages/react/package.json index 47567d3..cff88b2 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -29,7 +29,7 @@ "@ocavue/utils": "^1.7.0", "@prosekit/core": "^0.12.3", "@prosekit/pm": "^0.1.18", - "@prosekit/react": "^0.7.6" + "@prosekit/react": "^0.8.0-beta.0" }, "peerDependencies": { "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7ad015..0182cbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,8 +55,8 @@ importers: specifier: ^0.12.3 version: 0.12.3(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) '@prosekit/extensions': - specifier: ^0.17.4 - version: 0.17.4(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.2.0)(@types/hast@3.0.4)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) + specifier: ^0.17.5-beta.0 + version: 0.17.5-beta.0(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.2.0)(@types/hast@3.0.4)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) '@prosekit/pm': specifier: ^0.1.18 version: 0.1.18 @@ -110,8 +110,8 @@ importers: specifier: ^0.1.18 version: 0.1.18 '@prosekit/react': - specifier: ^0.7.6 - version: 0.7.6(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(react-dom@19.2.7)(react@19.2.7) + specifier: ^0.8.0-beta.0 + version: 0.8.0-beta.0(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(react-dom@19.2.7)(react@19.2.7) devDependencies: '@css-modules-kit/codegen': specifier: ^1.2.0 @@ -925,8 +925,8 @@ packages: '@prosekit/core@0.12.3': resolution: {integrity: sha512-FjiGhs7s7Wqyuf8h5X9x6OnfhOu2F6Z9IvJ0Bcy4p6VRQR6WeFZGMcV5zTTr+AVETfdvmnlkYILFmCzfcWl0dA==} - '@prosekit/extensions@0.17.4': - resolution: {integrity: sha512-xIltXsvjNAbq6TIp8UUbjXQgXMVx+7NNuO0e9DU7j4OyK1q6ZfUbPjGXz9olIOSeYDiStk3u9VydJqnDICh2YQ==} + '@prosekit/extensions@0.17.5-beta.0': + resolution: {integrity: sha512-6hafNoTMZlxPixhGd+SJ6o7P6ZnG6GNuIk3TrYqcSUbTd1IihQoqK1ufG4Fejq3ZUstqzfTGCqRhr75G5tPjHQ==} peerDependencies: loro-crdt: '>= 1.10.0' loro-prosemirror: '>= 0.4.1' @@ -945,8 +945,8 @@ packages: '@prosekit/pm@0.1.18': resolution: {integrity: sha512-K00S6TY4gIlkWo1Y53h9STePNSPZ6UroHaIiKTkJvfMnwBBUZEc7+CUpmtNMzLtUqjmVAoaCTnW6VDBJaEXqWg==} - '@prosekit/react@0.7.6': - resolution: {integrity: sha512-LIASVI9ellb2zqgbDLiralOw+TuHw31K8SP9Ai+txLtMKniva+eFVN+8RvLMmTRQ6qJO45bG2sKT8SCI6041tQ==} + '@prosekit/react@0.8.0-beta.0': + resolution: {integrity: sha512-Fo8rXcv/pyBQtU3mv2/i1ESO0e+PRFtin+9NvkLff4xo71sZFqAQH/q6iTiaWOgAyx115MpnY8sFl223fz3qxA==} peerDependencies: react: '>= 18.2.0' react-dom: '>= 18.2.0' @@ -956,8 +956,8 @@ packages: react-dom: optional: true - '@prosekit/web@0.8.6': - resolution: {integrity: sha512-/z1i55kPKWVAaYfDjR+YyEhU5EhyyqF3DsKr6SOpNbKvLyNyfWBYaiMpY1QZeHxcfRKG0iYpmEdgBQHpE69ylQ==} + '@prosekit/web@0.9.0-beta.0': + resolution: {integrity: sha512-oZVCWas1IKy3akItS+PvCYhZcsk3kFWjgg3IyfbEEzeOpYqicB3UGRydA+10GGqQn0+VupMc6ruBx7DOLgk96Q==} '@prosemirror-adapter/core@0.5.3': resolution: {integrity: sha512-jGcI/eq3USFqf1fCaejgCmifmXYDyFco6/5KjyyNBnuEhLqyLjD+020CtcduEOsnN4xPZr5ikclgHIR6kEFkQg==} @@ -3942,7 +3942,7 @@ snapshots: - prosemirror-state - prosemirror-transform - '@prosekit/extensions@0.17.4(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.2.0)(@types/hast@3.0.4)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)': + '@prosekit/extensions@0.17.5-beta.0(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.2.0)(@types/hast@3.0.4)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)': dependencies: '@ocavue/utils': 1.7.0 '@prosekit/core': 0.12.3(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) @@ -3984,11 +3984,11 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.41.8 - '@prosekit/react@0.7.6(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(react-dom@19.2.7)(react@19.2.7)': + '@prosekit/react@0.8.0-beta.0(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(react-dom@19.2.7)(react@19.2.7)': dependencies: '@prosekit/core': 0.12.3(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) '@prosekit/pm': 0.1.18 - '@prosekit/web': 0.8.6(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) + '@prosekit/web': 0.9.0-beta.0(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) '@prosemirror-adapter/core': 0.5.3 '@prosemirror-adapter/react': 0.5.3(react-dom@19.2.7)(react@19.2.7) optionalDependencies: @@ -4012,7 +4012,7 @@ snapshots: - y-prosemirror - yjs - '@prosekit/web@0.8.6(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)': + '@prosekit/web@0.9.0-beta.0(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)': dependencies: '@aria-ui/core': 0.2.1 '@aria-ui/elements': 0.1.10 @@ -4020,7 +4020,7 @@ snapshots: '@floating-ui/dom': 1.7.6 '@ocavue/utils': 1.7.0 '@prosekit/core': 0.12.3(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) - '@prosekit/extensions': 0.17.4(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.2.0)(@types/hast@3.0.4)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) + '@prosekit/extensions': 0.17.5-beta.0(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.2.0)(@types/hast@3.0.4)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) '@prosekit/pm': 0.1.18 prosemirror-tables: 1.8.5 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6fc8625..e1add6c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,3 +16,6 @@ trustLockfile: true minimumReleaseAgeExclude: - '@ocavue/utils@1.7.0' + - '@prosekit/extensions@0.17.5-beta.0' + - '@prosekit/react@0.8.0-beta.0' + - '@prosekit/web@0.9.0-beta.0' From 298c85a5e447e3648875ffe40eab8e7509661ab9 Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 14 Jun 2026 02:17:15 +1000 Subject: [PATCH 3/4] fix(react): regenerate css module types for arbitrary-extension convention --- ...hover-card.module.css.d.ts => link-hover-card.module.d.css.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react/src/components/{link-hover-card.module.css.d.ts => link-hover-card.module.d.css.ts} (100%) diff --git a/packages/react/src/components/link-hover-card.module.css.d.ts b/packages/react/src/components/link-hover-card.module.d.css.ts similarity index 100% rename from packages/react/src/components/link-hover-card.module.css.d.ts rename to packages/react/src/components/link-hover-card.module.d.css.ts From a9de9e90da790724b65c9546154d6f201c0cf0eb Mon Sep 17 00:00:00 2001 From: ocavue Date: Sun, 14 Jun 2026 04:23:40 +1000 Subject: [PATCH 4/4] feat(website): showcase link and wikilink hover and click in the playground --- website/src/app.tsx | 120 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/website/src/app.tsx b/website/src/app.tsx index 7f98140..ccd13cc 100644 --- a/website/src/app.tsx +++ b/website/src/app.tsx @@ -1,5 +1,12 @@ -import { Editor, type EditorMode } from '@meowdown/react' -import { type CSSProperties, useLayoutEffect, useState } from 'react' +import { + Editor, + type EditorMode, + type LinkClickHandler, + type LinkHoverHandler, + type WikilinkClickHandler, + type WikilinkHoverHandler, +} from '@meowdown/react' +import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useState } from 'react' interface ModeOption { value: EditorMode @@ -43,6 +50,8 @@ Label your notes with tags like #meow and #markdown. Type \`#\` followed by a le Connect notes with wikilinks like [[Daily journal]] and [[Reading list]]. Type \`[[\` to link another note. +Click into the editor, then hover a [link](https://prosekit.dev) or a [[wikilink]] to preview it. Hold Cmd (or Ctrl) and click to open. + | table | syntax | is | supported | | ----- | ------ | -- | --------- | | even | **in** | *tables* too! | :D | @@ -73,6 +82,71 @@ async function searchNotes(query: string): Promise { return NOTES.filter((note) => note.toLowerCase().includes(query)) } +const NOTE_EXCERPTS: Record = { + 'Cat care basics': 'Feeding schedules, grooming, and vet visit reminders.', + 'Daily journal': "Today's wins, blockers, and one line of gratitude.", + 'Meeting notes': 'Decisions and action items from this week.', + 'Project ideas': 'A running list of things to build someday.', + 'Reading list': 'Books and articles queued up for later.', + 'Travel plans': 'Flights, lodging, and a loose itinerary.', +} + +function noteExists(target: string): boolean { + return NOTES.some((note) => note.toLowerCase() === target.toLowerCase()) +} + +function urlHost(href: string): string { + try { + return new URL(href).host + } catch { + return href + } +} + +const cardStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '0.25rem', + maxWidth: '15rem', + padding: '0.5rem 0.625rem', +} +const cardTitleStyle: CSSProperties = { fontWeight: 600, color: 'var(--meowdown-heading)' } +const cardMetaStyle: CSSProperties = { fontSize: '0.75rem', color: 'var(--meowdown-muted)' } +const cardBodyStyle: CSSProperties = { fontSize: '0.8125rem', lineHeight: 1.4 } +const ellipsisStyle: CSSProperties = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} + +// A custom hover card for wikilinks: a note preview the app builds from its own +// data. Returned from `onWikilinkHover`. +function NotePreview({ target }: { target: string }) { + return ( +
+ {target} + + {noteExists(target) ? 'Note in your vault' : 'New note, not created yet'} + + + {NOTE_EXCERPTS[target] ?? 'No preview yet. Cmd/Ctrl-click to create it.'} + +
+ ) +} + +// A custom hover card for Markdown links. Returned from `onLinkHover`. Omit the +// prop to get meowdown's built-in card (the URL plus Edit / Copy / Open). +function LinkPreview({ href }: { href: string }) { + return ( +
+ {urlHost(href)} + {href} + Cmd/Ctrl-click to open +
+ ) +} + interface SegmentedControlProps { options: ReadonlyArray<{ value: T; label: string }> value: T @@ -144,6 +218,35 @@ export function App() { const [mode, setMode] = useState('focus') const activeMode = MODES.find((option) => option.value === mode) ?? MODES[0] + // Transient feedback so click handlers have a visible effect in the demo. + const [toast, setToast] = useState(null) + useEffect(() => { + if (!toast) return + const id = window.setTimeout(() => setToast(null), 2500) + return () => window.clearTimeout(id) + }, [toast]) + + const handleWikilinkHover = useCallback(async ({ target, signal }) => { + // Simulate fetching the note so the card's loading state shows. + await new Promise((resolve) => setTimeout(resolve, 250)) + if (signal.aborted) return null + return + }, []) + + const handleLinkHover = useCallback( + ({ href }) => , + [], + ) + + const handleWikilinkClick = useCallback(({ target }) => { + setToast(`Opening note: ${target}`) + }, []) + + const handleLinkClick = useCallback(({ href }) => { + setToast(`Opening link: ${href}`) + window.open(href, '_blank', 'noopener') + }, []) + return (
@@ -188,6 +291,10 @@ export function App() { initialMarkdown={INITIAL_CONTENT} onTagSearch={searchTags} onWikilinkSearch={searchNotes} + onLinkHover={handleLinkHover} + onLinkClick={handleLinkClick} + onWikilinkHover={handleWikilinkHover} + onWikilinkClick={handleWikilinkClick} /> @@ -212,6 +319,15 @@ export function App() { + + {toast && ( +
+ {toast} +
+ )}
) }