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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a href>` 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).

Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/extensions/link-hover.test.ts
Original file line number Diff line number Diff line change
@@ -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<LinkHoverHandler>()
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<LinkHoverHandler>()
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<LinkHoverHandler>()
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,
])
})
})
7 changes: 7 additions & 0 deletions packages/core/src/extensions/link-hover.ts
Original file line number Diff line number Diff line change
@@ -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<LinkUnit> | undefined) => void

export function defineLinkHoverHandler(onHoverChange: LinkHoverHandler): PlainExtension {
return defineMarkHoverHandler<LinkUnit>({
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,
})
}
10 changes: 8 additions & 2 deletions packages/core/src/extensions/mark-click.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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<Payload> {
key: PluginKey
/** The click target must sit inside this selector, tested via `closest`. */
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. `<a>` navigation) before firing. */
Expand All @@ -24,8 +27,11 @@ export function defineMarkClickHandler<Payload>(config: MarkClickConfig<Payload>
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?.<HTMLElement>(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)
Expand Down
87 changes: 72 additions & 15 deletions packages/core/src/extensions/mark-hover.ts
Original file line number Diff line number Diff line change
@@ -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<Payload> {
Expand All @@ -9,42 +9,99 @@ export interface MarkHoverHit<Payload> {
}

export interface MarkHoverConfig<Payload> {
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<Payload> | 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<Payload>(config: MarkHoverConfig<Payload>): PlainExtension {
let current: HTMLElement | undefined
let current: MarkHoverHit<Payload> | 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<HTMLElement>(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,
}),
}),
)
}
20 changes: 14 additions & 6 deletions packages/core/src/extensions/wikilink-click.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WikilinkClickHandler>()
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'])
})
})
20 changes: 20 additions & 0 deletions packages/core/src/extensions/wikilink-click.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<HTMLElement>('.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. */
Expand All @@ -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 }),
})
}
Loading
Loading