Skip to content
Draft
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
10 changes: 10 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/extensions/link-click.ts
Original file line number Diff line number Diff line change
@@ -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)
}
105 changes: 105 additions & 0 deletions packages/core/src/extensions/link-hit.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
126 changes: 126 additions & 0 deletions packages/core/src/extensions/link-hit.ts
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
8 changes: 8 additions & 0 deletions packages/core/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
* ------------------------------------------------------------------------- */
Expand Down
4 changes: 4 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>`: 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<string[]>`: 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<ReactNode> | 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<ReactNode> | 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<EditorHandle>`

Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading