From f700da808067e29db603b54e2cf7d079de3240a1 Mon Sep 17 00:00:00 2001 From: ocavue Date: Thu, 18 Jun 2026 22:59:51 +1000 Subject: [PATCH 01/20] refactor(core): extract shared atomic mark navigation --- .../src/extensions/atomic-mark-navigation.ts | 194 ++++++++++++++++++ .../core/src/extensions/image-navigation.ts | 162 --------------- packages/core/src/extensions/image.ts | 7 +- 3 files changed, 199 insertions(+), 164 deletions(-) create mode 100644 packages/core/src/extensions/atomic-mark-navigation.ts delete mode 100644 packages/core/src/extensions/image-navigation.ts diff --git a/packages/core/src/extensions/atomic-mark-navigation.ts b/packages/core/src/extensions/atomic-mark-navigation.ts new file mode 100644 index 0000000..56c14d2 --- /dev/null +++ b/packages/core/src/extensions/atomic-mark-navigation.ts @@ -0,0 +1,194 @@ +import { + defineKeymap, + definePlugin, + getMarkRange, + isTextSelection, + Priority, + union, + withPriority, + type MarkRange, + type PlainExtension, +} from '@prosekit/core' +import type { Command, EditorState } from '@prosekit/pm/state' +import { Plugin, PluginKey, TextSelection } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' +import { Decoration, DecorationSet } from '@prosekit/pm/view' + +import { getMarkMode } from './mark-mode.ts' +import type { MarkName } from './mark-names.ts' + +export interface AtomicMarkNavigationOptions { + /** The source marks (e.g. `mdImageSource`) whose run is one atomic unit. */ + markNames: MarkName[] + /** Decoration class added over the source range while the unit is selected. */ + selectedClass: string +} + +// The contiguous run of one atomic source mark that touches `pos`, or undefined. +function getRangeAt(state: EditorState, pos: number, markNames: MarkName[]): MarkRange | undefined { + const $pos = state.doc.resolve(pos) + for (const name of markNames) { + const range = getMarkRange($pos, name) + if (range) return range + } + return undefined +} + +// The unit whose range ends exactly at `pos` (immediately left of the caret). +function getRangeBefore( + state: EditorState, + pos: number, + markNames: MarkName[], +): MarkRange | undefined { + const range = getRangeAt(state, pos, markNames) + return range && range.to === pos ? range : undefined +} + +// The unit whose range starts exactly at `pos` (immediately right of the caret). +function getRangeAfter( + state: EditorState, + pos: number, + markNames: MarkName[], +): MarkRange | undefined { + const range = getRangeAt(state, pos, markNames) + return range && range.from === pos ? range : undefined +} + +// The unit range a non-empty selection exactly spans, or undefined. +function getSelectedRange(state: EditorState, markNames: MarkName[]): MarkRange | undefined { + const { from, to, empty } = state.selection + if (empty) return + const range = getRangeAt(state, from, markNames) + return range && range.from === from && range.to === to ? range : undefined +} + +function isHideMode(view: EditorView | undefined): boolean { + return !!view && getMarkMode(view) === 'hide' +} + +function selectRange(state: EditorState, range: MarkRange): TextSelection { + return TextSelection.create(state.doc, range.from, range.to) +} + +// ArrowRight: select the unit to the right, collapse a selected unit to its far +// edge, or step past a unit to the left (which the browser cannot do). +function createArrowRight(markNames: MarkName[]): Command { + return (state, dispatch, view) => { + if (!isHideMode(view) || !isTextSelection(state.selection)) return false + const selection = state.selection + if (selection.empty) { + const after = getRangeAfter(state, selection.from, markNames) + if (after) { + dispatch?.(state.tr.setSelection(selectRange(state, after))) + return true + } + const before = getRangeBefore(state, selection.from, markNames) + if (before) { + const $from = state.doc.resolve(selection.from) + if (selection.from >= $from.end()) return false + dispatch?.(state.tr.setSelection(TextSelection.create(state.doc, selection.from + 1))) + return true + } + return false + } + const range = getSelectedRange(state, markNames) + if (!range) return false + dispatch?.(state.tr.setSelection(TextSelection.create(state.doc, range.to))) + return true + } +} + +// ArrowLeft: select the unit to the left, or collapse a selected unit to its +// near edge. +function createArrowLeft(markNames: MarkName[]): Command { + return (state, dispatch, view) => { + if (!isHideMode(view) || !isTextSelection(state.selection)) return false + const selection = state.selection + if (selection.empty) { + const before = getRangeBefore(state, selection.from, markNames) + if (!before) return false + dispatch?.(state.tr.setSelection(selectRange(state, before))) + return true + } + const range = getSelectedRange(state, markNames) + if (!range) return false + dispatch?.(state.tr.setSelection(TextSelection.create(state.doc, range.from))) + return true + } +} + +// Backspace: delete a whole unit to the left, or delete one character to the +// left while next to a unit (the browser's native delete mangles the hidden +// source). A selected unit falls through to the base `deleteSelection`. +function createBackspace(markNames: MarkName[]): Command { + return (state, dispatch, view) => { + if (!isHideMode(view) || !state.selection.empty) return false + const pos = state.selection.from + const before = getRangeBefore(state, pos, markNames) + if (before) { + dispatch?.(state.tr.delete(before.from, before.to)) + return true + } + if (!getRangeAfter(state, pos, markNames)) return false + if (pos <= state.doc.resolve(pos).start()) return false + dispatch?.(state.tr.delete(pos - 1, pos)) + return true + } +} + +// Delete: the forward mirror of `backspace`. +function createForwardDelete(markNames: MarkName[]): Command { + return (state, dispatch, view) => { + if (!isHideMode(view) || !state.selection.empty) return false + const pos = state.selection.from + const after = getRangeAfter(state, pos, markNames) + if (after) { + dispatch?.(state.tr.delete(after.from, after.to)) + return true + } + if (!getRangeBefore(state, pos, markNames)) return false + if (pos >= state.doc.resolve(pos).end()) return false + dispatch?.(state.tr.delete(pos, pos + 1)) + return true + } +} + +// Mark the source range while its whole unit is selected (see `style.css`). +function createSelectionPlugin(markNames: MarkName[], selectedClass: string): Plugin { + return new Plugin({ + key: new PluginKey(`atomic-mark-selection-${selectedClass}`), + props: { + decorations: (state) => { + const range = getSelectedRange(state, markNames) + if (!range) return null + return DecorationSet.create(state.doc, [ + Decoration.inline(range.from, range.to, { class: selectedClass }), + ]) + }, + }, + }) +} + +/** + * In hide mode, make a hidden text-backed unit (an image source, a wikilink + * source) a single caret stop: arrowing onto it selects the whole source (ringed + * by a `selectedClass` decoration), and Backspace/Delete remove it as a unit. + * Inert in show mode and without `defineMarkMode('hide')`. + */ +export function defineAtomicMarkNavigation({ + markNames, + selectedClass, +}: AtomicMarkNavigationOptions): PlainExtension { + return union( + withPriority( + defineKeymap({ + ArrowRight: createArrowRight(markNames), + ArrowLeft: createArrowLeft(markNames), + Backspace: createBackspace(markNames), + Delete: createForwardDelete(markNames), + }), + Priority.high, + ), + definePlugin(createSelectionPlugin(markNames, selectedClass)), + ) +} diff --git a/packages/core/src/extensions/image-navigation.ts b/packages/core/src/extensions/image-navigation.ts deleted file mode 100644 index 0d399f7..0000000 --- a/packages/core/src/extensions/image-navigation.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { - defineKeymap, - definePlugin, - getMarkRange, - isTextSelection, - Priority, - union, - withPriority, - type MarkRange, - type PlainExtension, -} from '@prosekit/core' -import type { Command, EditorState } from '@prosekit/pm/state' -import { Plugin, PluginKey, TextSelection } from '@prosekit/pm/state' -import type { EditorView } from '@prosekit/pm/view' -import { Decoration, DecorationSet } from '@prosekit/pm/view' - -import { getMarkMode } from './mark-mode.ts' -import type { MarkName } from './mark-names.ts' - -// The contiguous run of `mdImageSource` text that touches `pos`, or undefined. -function getImageSourceRangeAt(state: EditorState, pos: number): MarkRange | undefined { - return getMarkRange(state.doc.resolve(pos), 'mdImageSource' satisfies MarkName) -} - -// The image whose range ends exactly at `pos` (immediately left of the caret). -function getImageBefore(state: EditorState, pos: number): MarkRange | undefined { - const range = getImageSourceRangeAt(state, pos) - return range && range.to === pos ? range : undefined -} - -// The image whose range starts exactly at `pos` (immediately right of the caret). -function getImageAfter(state: EditorState, pos: number): MarkRange | undefined { - const range = getImageSourceRangeAt(state, pos) - return range && range.from === pos ? range : undefined -} - -// The image range a non-empty selection exactly spans, or undefined. -function getSelectedImageRange(state: EditorState): MarkRange | undefined { - const { from, to, empty } = state.selection - if (empty) return - const range = getImageSourceRangeAt(state, from) - return range && range.from === from && range.to === to ? range : undefined -} - -function isHideMode(view: EditorView | undefined): boolean { - return !!view && getMarkMode(view) === 'hide' -} - -function selectRange(state: EditorState, range: MarkRange): TextSelection { - return TextSelection.create(state.doc, range.from, range.to) -} - -// ArrowRight: select the image to the right, collapse a selected image to its -// far edge, or step past an image to the left (which the browser cannot do). -const arrowRight: Command = (state, dispatch, view) => { - if (!isHideMode(view) || !isTextSelection(state.selection)) return false - const selection = state.selection - if (selection.empty) { - const after = getImageAfter(state, selection.from) - if (after) { - dispatch?.(state.tr.setSelection(selectRange(state, after))) - return true - } - const before = getImageBefore(state, selection.from) - if (before) { - const $from = state.doc.resolve(selection.from) - if (selection.from >= $from.end()) return false - dispatch?.(state.tr.setSelection(TextSelection.create(state.doc, selection.from + 1))) - return true - } - return false - } - const range = getSelectedImageRange(state) - if (!range) return false - dispatch?.(state.tr.setSelection(TextSelection.create(state.doc, range.to))) - return true -} - -// ArrowLeft: select the image to the left, or collapse a selected image to its -// near edge. -const arrowLeft: Command = (state, dispatch, view) => { - if (!isHideMode(view) || !isTextSelection(state.selection)) return false - const selection = state.selection - if (selection.empty) { - const before = getImageBefore(state, selection.from) - if (!before) return false - dispatch?.(state.tr.setSelection(selectRange(state, before))) - return true - } - const range = getSelectedImageRange(state) - if (!range) return false - dispatch?.(state.tr.setSelection(TextSelection.create(state.doc, range.from))) - return true -} - -// Backspace: delete a whole image to the left, or delete one character to the -// left while next to an image (the browser's native delete mangles the hidden -// source). A selected image falls through to the base `deleteSelection`. -const backspace: Command = (state, dispatch, view) => { - if (!isHideMode(view) || !state.selection.empty) return false - const pos = state.selection.from - const before = getImageBefore(state, pos) - if (before) { - dispatch?.(state.tr.delete(before.from, before.to)) - return true - } - if (!getImageAfter(state, pos)) return false - if (pos <= state.doc.resolve(pos).start()) return false - dispatch?.(state.tr.delete(pos - 1, pos)) - return true -} - -// Delete: the forward mirror of `backspace`. -const forwardDelete: Command = (state, dispatch, view) => { - if (!isHideMode(view) || !state.selection.empty) return false - const pos = state.selection.from - const after = getImageAfter(state, pos) - if (after) { - dispatch?.(state.tr.delete(after.from, after.to)) - return true - } - if (!getImageBefore(state, pos)) return false - if (pos >= state.doc.resolve(pos).end()) return false - dispatch?.(state.tr.delete(pos, pos + 1)) - return true -} - -// Ring the preview when its whole source is selected (see `style.css`). -function createImageSelectionPlugin(): Plugin { - return new Plugin({ - key: new PluginKey('image-selection'), - props: { - decorations: (state) => { - const range = getSelectedImageRange(state) - if (!range) return null - return DecorationSet.create(state.doc, [ - Decoration.inline(range.from, range.to, { class: 'md-image-selected' }), - ]) - }, - }, - }) -} - -/** - * In hide mode, make a hidden image a single caret stop: arrowing onto it - * selects the whole `![alt](url)` (ringed by a decoration), and Backspace/Delete - * remove it as a unit. Inert in show mode and without `defineMarkMode('hide')`. - */ -export function defineImageNavigation(): PlainExtension { - return union( - withPriority( - defineKeymap({ - ArrowRight: arrowRight, - ArrowLeft: arrowLeft, - Backspace: backspace, - Delete: forwardDelete, - }), - Priority.high, - ), - definePlugin(createImageSelectionPlugin()), - ) -} diff --git a/packages/core/src/extensions/image.ts b/packages/core/src/extensions/image.ts index 32248a4..f6b6543 100644 --- a/packages/core/src/extensions/image.ts +++ b/packages/core/src/extensions/image.ts @@ -9,8 +9,8 @@ import { import { Plugin, PluginKey } from '@prosekit/pm/state' import type { EditorView, MarkViewConstructor } from '@prosekit/pm/view' +import { defineAtomicMarkNavigation } from './atomic-mark-navigation.ts' import { matchEmbed } from './embed/index.ts' -import { defineImageNavigation } from './image-navigation.ts' import type { MdImageViewAttrs } from './inline-marks.ts' import type { MarkName } from './mark-names.ts' @@ -158,7 +158,10 @@ export function defineImage(options: ImageOptions = {}): PlainExtension { name: 'mdImageView' satisfies MarkName, constructor: createImageMarkView(options), }), - defineImageNavigation(), + defineAtomicMarkNavigation({ + markNames: ['mdImageSource' satisfies MarkName], + selectedClass: 'md-image-selected', + }), // High priority so the drop/paste handler runs before ProseKit's // drop-indicator plugin. withPriority(definePlugin(createImageInputPlugin(options)), Priority.high), From 6706c042e4e734d74af3598fb392ed6986054ac8 Mon Sep 17 00:00:00 2001 From: ocavue Date: Thu, 18 Jun 2026 22:59:51 +1000 Subject: [PATCH 02/20] feat(core): render wikilinks as an immutable mark view --- packages/core/README.md | 2 +- packages/core/src/extensions/extension.ts | 2 + .../src/extensions/inline-mark-plugin.test.ts | 22 ++- packages/core/src/extensions/inline-marks.ts | 51 +++++-- .../inline-text-to-mark-chunks.test.ts | 47 +++--- .../extensions/inline-text-to-mark-chunks.ts | 69 ++++++++- .../core/src/extensions/mark-mode.test.ts | 10 +- packages/core/src/extensions/mark-mode.ts | 23 ++- packages/core/src/extensions/mark-names.ts | 3 +- .../core/src/extensions/wikilink-click.ts | 33 +++-- packages/core/src/extensions/wikilink.test.ts | 139 ++++++++++++++++++ packages/core/src/extensions/wikilink.ts | 56 +++++++ packages/core/src/style.css | 37 ++++- packages/react/src/components/editor.test.tsx | 2 +- 14 files changed, 424 insertions(+), 72 deletions(-) create mode 100644 packages/core/src/extensions/wikilink.test.ts create mode 100644 packages/core/src/extensions/wikilink.ts diff --git a/packages/core/README.md b/packages/core/README.md index 5033b48..aeff675 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -55,7 +55,7 @@ Selection colors are standalone variables, not derived from `--meowdown-accent`, Tags (`#tag`) render as pills via the `.md-tag` class, tinted from `--meowdown-accent`. -Wikilinks (`[[target]]`) render with a dashed underline via the `.md-wikilink` class, colored by `--meowdown-accent`, with a pointer cursor. Their `[[` `]]` brackets behave like other syntax characters: dimmed in show mode, hidden in hide and focus modes. Wire click navigation with `defineWikilinkClickHandler(({ target, event }) => ...)` (or `@meowdown/react`'s `onWikilinkClick` prop). +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-label` class and the raw source the `.md-wikilink-source` class, both dashed-underlined and colored by `--meowdown-accent`. In hide mode the link is a single caret stop: arrowing onto it selects the whole source (ringed with `--meowdown-node-outline`), and Backspace/Delete remove it as a unit. Wire click navigation with `defineWikilinkClickHandler(({ target, event }) => ...)` (or `@meowdown/react`'s `onWikilinkClick` prop). Inline images (`![alt](src)`) stay literal text and render in place via a mark view, with the raw `![alt](src)` hidden in hide and focus modes. Add it with `defineImage({ resolveImageUrl, onImagePaste })` (or `@meowdown/react`'s image props). `resolveImageUrl` is optional and defaults to showing http(s) URLs as-is. diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index cd2406b..24b4851 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -22,6 +22,7 @@ import { defineInlineMarkPlugin } from './inline-mark-plugin.ts' import { defineInlineMarks } from './inline-marks.ts' import { defineInlineToggle } from './inline-toggle-commands.ts' import { defineTable } from './table.ts' +import { defineWikilink } from './wikilink.ts' function defineEditorExtensionImpl() { return union( @@ -43,6 +44,7 @@ function defineEditorExtensionImpl() { defineCodeBlockSyntaxHighlight(), defineInlineMarkPlugin(), defineInlineToggle(), + defineWikilink(), // others defineBaseKeymap(), diff --git a/packages/core/src/extensions/inline-mark-plugin.test.ts b/packages/core/src/extensions/inline-mark-plugin.test.ts index 20a3f0a..a77b5ac 100644 --- a/packages/core/src/extensions/inline-mark-plugin.test.ts +++ b/packages/core/src/extensions/inline-mark-plugin.test.ts @@ -195,20 +195,17 @@ describe('inlineMarkPlugin', () => { expect(marksAt(fixture.doc, pos + 1)).toEqual([]) }) - it('applies mdWikilink to [[note]] with mdMark on the brackets', () => { + it('applies mdWikilinkSource to [[note]] with mdMark on the brackets', () => { using fixture = setupFixture() const { n } = fixture const doc = n.doc(n.paragraph('see [[note]] end')) fixture.set(doc) - // The marked wikilink splits into three text nodes: [[ note ]] const open = findText(fixture.doc, '[[') const target = findText(fixture.doc, 'note') - const close = findText(fixture.doc, ']]') expect(open).toBeGreaterThan(0) - expect(marksAt(fixture.doc, open + 1)).toEqual(['mdMark', 'mdWikilink']) - expect(marksAt(fixture.doc, target + 1)).toEqual(['mdWikilink']) - expect(marksAt(fixture.doc, close + 1)).toEqual(['mdMark', 'mdWikilink']) + expect(marksAt(fixture.doc, open + 1)).toEqual(['mdMark', 'mdWikilinkSource']) + expect(marksAt(fixture.doc, target + 1)).toEqual(['mdWikilinkSource']) expect(marksAt(fixture.doc, open)).toEqual([]) // the space before }) @@ -219,7 +216,7 @@ describe('inlineMarkPlugin', () => { fixture.set(doc) const pos = findText(fixture.doc, 'note') - expect(marksAt(fixture.doc, pos + 1)).toEqual(['mdWikilink']) + expect(marksAt(fixture.doc, pos + 1)).toEqual(['mdWikilinkSource']) }) it('does not mark [[note]] inside code blocks', () => { @@ -232,17 +229,18 @@ describe('inlineMarkPlugin', () => { expect(marksAt(fixture.doc, pos + 1)).toEqual([]) }) - it('removes mdWikilink when the closing ] is deleted', () => { + it('removes mdWikilinkSource when the closing ] is deleted', () => { using fixture = setupFixture() const { n } = fixture const doc = n.doc(n.paragraph('see [[note]] end')) fixture.set(doc) const pos = findText(fixture.doc, 'note') - expect(marksAt(fixture.doc, pos + 1)).toEqual(['mdWikilink']) - // Delete the last ']': "see [[note] end" is no longer a wikilink. - const lastBracket = findText(fixture.doc, ']]') + 1 - fixture.view.dispatch(fixture.state.tr.delete(lastBracket, lastBracket + 1)) + expect(marksAt(fixture.doc, pos + 1)).toEqual(['mdWikilinkSource']) + // Delete the last ']': "see [[note] end" is no longer a wikilink. The + // closing brackets are separate text nodes, so target the first `]`. + const firstBracket = findText(fixture.doc, ']') + fixture.view.dispatch(fixture.state.tr.delete(firstBracket + 1, firstBracket + 2)) const after = findText(fixture.doc, 'note') expect(marksAt(fixture.doc, after + 1)).toEqual([]) }) diff --git a/packages/core/src/extensions/inline-marks.ts b/packages/core/src/extensions/inline-marks.ts index f76ddd2..8705ea8 100644 --- a/packages/core/src/extensions/inline-marks.ts +++ b/packages/core/src/extensions/inline-marks.ts @@ -130,18 +130,47 @@ function defineMdTag() { } /** - * Covers the whole `[[target]]`; the `[[` `]]` brackets also carry - * `mdMark` (they are removable syntax, unlike a tag's `#`). + * Covers the whole `[[target]]`/`[[target|alias]]` source. This is the mark + * `defineMarkMode` hides in hide/focus mode so the rendered label replaces the + * raw syntax. The `target` attribute keeps adjacent wikilinks distinct so their + * ranges stay separate. The `[[` `]]` brackets also carry `mdMark` (removable + * syntax, unlike a tag's `#`). */ -function defineMdWikilink() { - return defineMarkSpec({ - name: 'mdWikilink' satisfies MarkName, +function defineMdWikilinkSource() { + return defineMarkSpec<'mdWikilinkSource', MdWikilinkSourceAttrs>({ + name: 'mdWikilinkSource' satisfies MarkName, inclusive: false, - toDOM: () => ['span', { class: 'md-wikilink' }, 0], - parseDOM: [{ tag: 'span.md-wikilink' }], + attrs: { target: { default: '' } }, + toDOM: () => ['span', { class: 'md-wikilink-source' }, 0], + parseDOM: [{ tag: 'span.md-wikilink-source' }], }) } +export interface MdWikilinkSourceAttrs { + target: string +} + +/** + * Anchors the rendered wikilink label on the final character of + * `[[target]]`/`[[target|alias]]`. A mark view (see `defineWikilink`) renders the + * non-editable label; without it the anchor char just renders as text. Carries + * the parsed `target` and `display` (the alias, or empty when none). + */ +function defineMdWikilinkView() { + return defineMarkSpec<'mdWikilinkView', MdWikilinkViewAttrs>({ + name: 'mdWikilinkView' satisfies MarkName, + inclusive: false, + attrs: { target: { default: '' }, display: { default: '' } }, + toDOM: () => ['span', { class: 'md-wikilink-anchor' }, 0], + parseDOM: [{ tag: 'span.md-wikilink-anchor' }], + }) +} + +export interface MdWikilinkViewAttrs { + target: string + display: string +} + export function defineInlineMarks() { // The last mark registered here gets the lowest rank and becomes the outermost DOM wrapper. @@ -154,10 +183,12 @@ export function defineInlineMarks() { defineMdLinkUri(), defineMdDel(), defineMdTag(), - defineMdWikilink(), - // The image marks are registered last so the preview (mdImageView) wraps the - // source (mdImageSource), which wraps the syntax marks. + // The wikilink/image marks are registered last so the view mark + // (mdWikilinkView / mdImageView) wraps the source, which wraps the syntax + // marks. + defineMdWikilinkSource(), + defineMdWikilinkView(), defineMdImageSource(), defineMdImageView(), ) diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts index 181e24a..49eaf08 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts @@ -466,28 +466,32 @@ describe('inlineTextToMarkChunks', () => { `) }) - it('wikilink yields mdMark brackets around an mdWikilink target', () => { + it('wikilink yields mdMark brackets around an mdWikilinkSource target', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a [[note]] b') expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - - 2-4: mdMark + mdWikilink - 4-8: mdWikilink - 8-10: mdMark + mdWikilink + 2-4: mdMark + mdWikilinkSource(target=note) + 4-8: mdWikilinkSource(target=note) + 8-9: mdMark + mdWikilinkSource(target=note) + 9-10: mdMark + mdWikilinkSource(target=note) + mdWikilinkView(target=note) 10-12: - " `) }) - it('adjacent wikilinks coalesce the middle ]][[ into one chunk', () => { + it('adjacent wikilinks stay distinct via their target attribute', () => { const chunks = inlineTextToMarkChunks(markBuilders, '[[a]][[b]]') expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` " - 0-2: mdMark + mdWikilink - 2-3: mdWikilink - 3-7: mdMark + mdWikilink - 7-8: mdWikilink - 8-10: mdMark + mdWikilink + 0-2: mdMark + mdWikilinkSource(target=a) + 2-3: mdWikilinkSource(target=a) + 3-4: mdMark + mdWikilinkSource(target=a) + 4-5: mdMark + mdWikilinkSource(target=a) + mdWikilinkView(target=a) + 5-7: mdMark + mdWikilinkSource(target=b) + 7-8: mdWikilinkSource(target=b) + 8-9: mdMark + mdWikilinkSource(target=b) + 9-10: mdMark + mdWikilinkSource(target=b) + mdWikilinkView(target=b) " `) }) @@ -498,9 +502,10 @@ describe('inlineTextToMarkChunks', () => { " 0-1: mdEm + mdMark 1-3: mdEm - 3-5: mdEm + mdMark + mdWikilink - 5-6: mdEm + mdWikilink - 6-8: mdEm + mdMark + mdWikilink + 3-5: mdEm + mdMark + mdWikilinkSource(target=n) + 5-6: mdEm + mdWikilinkSource(target=n) + 6-7: mdEm + mdMark + mdWikilinkSource(target=n) + 7-8: mdEm + mdMark + mdWikilinkSource(target=n) + mdWikilinkView(target=n) 8-10: mdEm 10-11: mdEm + mdMark " @@ -513,9 +518,10 @@ describe('inlineTextToMarkChunks', () => { " 0-1: mdLinkText(href=http://y) + mdMark 1-5: mdLinkText(href=http://y) - 5-7: mdLinkText(href=http://y) + mdMark + mdWikilink - 7-8: mdLinkText(href=http://y) + mdWikilink - 8-10: mdLinkText(href=http://y) + mdMark + mdWikilink + 5-7: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) + 7-8: mdLinkText(href=http://y) + mdWikilinkSource(target=x) + 8-9: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) + 9-10: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) + mdWikilinkView(target=x) 10-12: mdMark 12-20: mdLinkUri 20-21: mdMark @@ -527,15 +533,16 @@ describe('inlineTextToMarkChunks', () => { const chunks = inlineTextToMarkChunks(markBuilders, '[[note #tag]]') expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` " - 0-2: mdMark + mdWikilink - 2-11: mdWikilink - 11-13: mdMark + mdWikilink + 0-2: mdMark + mdWikilinkSource(target=note #tag) + 2-11: mdWikilinkSource(target=note #tag) + 11-12: mdMark + mdWikilinkSource(target=note #tag) + 12-13: mdMark + mdWikilinkSource(target=note #tag) + mdWikilinkView(target=note #tag) " `) }) it('unclosed wikilink falls back to link parsing of the inner [a]', () => { - // No mdWikilink anywhere; the inner `[a]` becomes a shortcut + // No mdWikilinkSource anywhere; the inner `[a]` becomes a shortcut // reference link (pre-existing lezer behavior). const chunks = inlineTextToMarkChunks(markBuilders, '[[a]') expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.ts index cfc3404..d727e96 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.ts @@ -4,11 +4,18 @@ import type { InlineElement } from '../lezer/inline.ts' import { parseInline } from '../lezer/inline.ts' import { LEZER_NODE_IDS } from '../lezer/node-ids.ts' -import type { MdImageSourceAttrs, MdImageViewAttrs, MdLinkTextAttrs } from './inline-marks.ts' +import type { + MdImageSourceAttrs, + MdImageViewAttrs, + MdLinkTextAttrs, + MdWikilinkSourceAttrs, + MdWikilinkViewAttrs, +} from './inline-marks.ts' import type { MarkChunk } from './mark-chunk.ts' import type { MarkName } from './mark-names.ts' import { marksEqual } from './marks-equal.ts' import type { TypedMarkBuilders } from './schema.ts' +import { parseWikilink } from './wikilink-click.ts' /** * Lookup from Lezer node type id to the ProseMirror mark. @@ -32,7 +39,6 @@ const MARK_NAME_BY_TYPE_ID: ReadonlyMap = new Map([ [LEZER_NODE_IDS.StrikethroughMark, 'mdMark'], [LEZER_NODE_IDS.URL, 'mdLinkUri'], [LEZER_NODE_IDS.Hashtag, 'mdTag'], - [LEZER_NODE_IDS.Wikilink, 'mdWikilink'], [LEZER_NODE_IDS.WikilinkMark, 'mdMark'], ]) @@ -86,6 +92,8 @@ function walk( walkLink(node, parentMarks, text, marks, out) } else if (node.type === LEZER_NODE_IDS.Image) { walkImage(node, parentMarks, text, marks, out) + } else if (node.type === LEZER_NODE_IDS.Wikilink) { + walkWikilink(node, parentMarks, text, marks, out) } else if (node.type === LEZER_NODE_IDS.URL) { // A standalone `URL` node is a GFM autolink (the address part of a real // `[text](url)` is handled inside `walkLink`, not here). Linkify the @@ -159,6 +167,13 @@ function walkLink( emit(out, pos, child.from, childMarks) } const baseForChild = inLabel(child.from) ? [...parentMarks, linkTextMark!] : parentMarks + // A wikilink in the label needs its own source/view walk, not the generic + // per-child mark mapping. + if (child.type === LEZER_NODE_IDS.Wikilink) { + walkWikilink(child, baseForChild, text, marks, out) + pos = child.to + continue + } const maybeMarkName = MARK_NAME_BY_TYPE_ID.get(child.type) const childMarks = maybeMarkName ? [...baseForChild, marks[maybeMarkName].create()] @@ -238,6 +253,56 @@ function walkImage( } } +/** + * Special walker for a wikilink `[[target]]`/`[[target|alias]]`. + * + * Emits `mdWikilinkSource({ target })` across the whole node (the mark + * `defineMarkMode` hides) and `mdWikilinkView({ target, display })` on the node's + * final character, the anchor a mark view renders the non-editable label on. The + * node's only children are the two `WikilinkMark` brackets (`[[` and `]]`); they + * carry `mdMark` for show mode, and the target text between them carries only + * `mdWikilinkSource`. The closing `]]` straddles the anchor, so it splits: the + * final `]` also gets `mdWikilinkView`. + */ +function walkWikilink( + node: InlineElement, + parentMarks: readonly Mark[], + text: string, + marks: TypedMarkBuilders, + out: MarkChunk[], +): void { + const { target, display } = parseWikilink(text.slice(node.from, node.to)) + const source = marks.mdWikilinkSource.create({ target } satisfies MdWikilinkSourceAttrs) + const view = marks.mdWikilinkView.create({ target, display } satisfies MdWikilinkViewAttrs) + const anchorFrom = node.to - 1 + + let pos = node.from + for (const child of node.children) { + if (child.from > pos) { + emit(out, pos, child.from, [...parentMarks, source]) + } + if (child.from < anchorFrom) { + emit(out, child.from, Math.min(child.to, anchorFrom), [ + ...parentMarks, + source, + marks.mdMark.create(), + ]) + } + if (child.to > anchorFrom) { + emit(out, Math.max(child.from, anchorFrom), child.to, [ + ...parentMarks, + source, + view, + marks.mdMark.create(), + ]) + } + pos = child.to + } + if (pos < node.to) { + emit(out, pos, node.to, [...parentMarks, source]) + } +} + /** * Push `[from, to, marks]` to `out`, coalescing with the previous chunk * when both share the same mark set. Coalescing keeps the chunk list diff --git a/packages/core/src/extensions/mark-mode.test.ts b/packages/core/src/extensions/mark-mode.test.ts index 9656303..2fff329 100644 --- a/packages/core/src/extensions/mark-mode.test.ts +++ b/packages/core/src/extensions/mark-mode.test.ts @@ -131,13 +131,15 @@ describe('defineMarkMode', () => { await expectRevealedMarkers(0) }) + // `[[` is one span; the closing `]]` is two (the final `]` anchors the + // label mark view), so three marker spans reveal in all. it('reveals both [[ ]] when the cursor is inside a wikilink', async () => { using fixture = setupFixture() fixture.editor.use(defineMarkMode('focus')) const { n } = fixture const doc = n.doc(n.paragraph('see [[note]] end')) fixture.set(doc) - await expectRevealedMarkers(2) + await expectRevealedMarkers(3) }) it('reveals only the wikilink brackets next to a markdown link', async () => { @@ -146,7 +148,7 @@ describe('defineMarkMode', () => { const { n } = fixture const doc = n.doc(n.paragraph('[a](http://x)[[note]]')) fixture.set(doc) - await expectRevealedMarkers(2) + await expectRevealedMarkers(3) }) it('reveals nothing inside a #tag (tags have no syntax to reveal)', async () => { @@ -238,13 +240,13 @@ describe('defineMarkMode', () => { expect(clipboardText(fixture)).toBe('see ![cat](https://example.com/cat.png) end') }) - it('strips [[ ]] from the copied text', () => { + it('keeps the whole [[ ]] source in the copied text', () => { using fixture = setupFixture() fixture.editor.use(defineMarkMode('hide')) const { n } = fixture const doc = n.doc(n.paragraph('see [[note]] end')) fixture.set(doc) - expect(clipboardText(fixture)).toBe('see note end') + expect(clipboardText(fixture)).toBe('see [[note]] end') }) it('keeps #tag verbatim in the copied text', () => { diff --git a/packages/core/src/extensions/mark-mode.ts b/packages/core/src/extensions/mark-mode.ts index e85aa35..15a7643 100644 --- a/packages/core/src/extensions/mark-mode.ts +++ b/packages/core/src/extensions/mark-mode.ts @@ -26,7 +26,8 @@ const REVEAL_TRIGGERING_MARKS: ReadonlySet = new Set([ 'mdDel', 'mdLinkText', 'mdLinkUri', - 'mdWikilink', + // Reveals the raw `[[target]]` when the caret is at the wikilink source. + 'mdWikilinkSource', // Reveals the raw `![alt](url)` when the caret is anywhere in the image // source, including its plain alt text (which carries only mdImageSource). 'mdImageSource', @@ -37,9 +38,17 @@ const REVEAL_TRIGGERING_MARKS: ReadonlySet = new Set([ const REVEALABLE_MARK_NAMES: ReadonlySet = new Set(['mdMark', 'mdLinkUri']) // Marks whose text is dropped from a clean clipboard copy, so copied markdown -// omits the rendered syntax. Image source is exempt (see `cleanCopySerializer`). +// omits the rendered syntax. Source marks are exempt (see `cleanCopySerializer`). const CLIPBOARD_STRIP_MARK_NAMES: ReadonlySet = new Set(['mdMark', 'mdLinkUri']) +// Source marks whose whole run is kept verbatim in a clean copy, so a rendered +// image stays `![alt](url)` and a rendered wikilink stays `[[target]]`, even +// though their punctuation/url carry otherwise-stripped marks. +const CLIPBOARD_KEEP_SOURCE_MARK_NAMES: ReadonlySet = new Set([ + 'mdImageSource', + 'mdWikilinkSource', +]) + // Marks that make a text node part of a link; revealing one reveals the whole // link (text + URL) envelope. const LINK_BEARING_MARKS: ReadonlySet = new Set(['mdLinkText', 'mdLinkUri']) @@ -51,7 +60,7 @@ const SYNTAX_BEARING_MARKS: ReadonlySet = new Set([ 'mdEm', 'mdCode', 'mdDel', - 'mdWikilink', + 'mdWikilinkSource', ]) const markModeKey = new PluginKey('mark-mode') @@ -86,13 +95,11 @@ function cleanCopySerializer(slice: Slice): string { const parts: string[] = [] blockNode.descendants((textNode) => { if (!textNode.isText || !textNode.text) return true - // Keep the whole image source so a copied image stays `![alt](url)`, - // even though its punctuation/url carry otherwise-stripped marks. - const isImageSource = textNode.marks.some( - (m: Mark) => m.type.name === ('mdImageSource' satisfies MarkName), + const isKeptSource = textNode.marks.some((m: Mark) => + CLIPBOARD_KEEP_SOURCE_MARK_NAMES.has(m.type.name as MarkName), ) const stripped = - !isImageSource && + !isKeptSource && textNode.marks.some((m: Mark) => CLIPBOARD_STRIP_MARK_NAMES.has(m.type.name as MarkName)) if (!stripped) parts.push(textNode.text) return false diff --git a/packages/core/src/extensions/mark-names.ts b/packages/core/src/extensions/mark-names.ts index ad343e8..28a8a1f 100644 --- a/packages/core/src/extensions/mark-names.ts +++ b/packages/core/src/extensions/mark-names.ts @@ -9,7 +9,8 @@ export const MARK_NAMES = [ 'mdLinkUri', 'mdDel', 'mdTag', - 'mdWikilink', + 'mdWikilinkSource', + 'mdWikilinkView', ] as const export type MarkName = (typeof MARK_NAMES)[number] diff --git a/packages/core/src/extensions/wikilink-click.ts b/packages/core/src/extensions/wikilink-click.ts index 0257713..fe99928 100644 --- a/packages/core/src/extensions/wikilink-click.ts +++ b/packages/core/src/extensions/wikilink-click.ts @@ -1,6 +1,7 @@ import { definePlugin, getMarkRange, isApple, type PlainExtension } from '@prosekit/core' import { Plugin, PluginKey, type EditorState } from '@prosekit/pm/state' +import type { MdWikilinkSourceAttrs } from './inline-marks.ts' import type { MarkName } from './mark-names.ts' const wikilinkClickKey = new PluginKey('meowdown-wikilink-click') @@ -11,24 +12,32 @@ export interface WikilinkHit { target: string } -/** The wikilink covering `pos`, found via the `mdWikilink` mark. Exported for tests. */ +/** The wikilink covering `pos`, found via the `mdWikilinkSource` mark. Exported for tests. */ export function wikilinkAt(state: EditorState, pos: number): WikilinkHit | undefined { const $pos = state.doc.resolve(pos) if (!$pos.parent.isTextblock || $pos.parent.type.spec.code) return - const range = getMarkRange($pos, 'mdWikilink' satisfies MarkName) + const range = getMarkRange($pos, 'mdWikilinkSource' satisfies MarkName) if (!range) return - return { - from: range.from, - to: range.to, - target: parseWikilinkTarget(state.doc.textBetween(range.from, range.to)), - } + const { target } = range.mark.attrs as MdWikilinkSourceAttrs + return { from: range.from, to: range.to, target } } -/** Extracts the target from `[[target]]` or `[[target|alias]]`. Exported for tests. */ -export function parseWikilinkTarget(text: string): string { +export interface ParsedWikilink { + target: string + display: string +} + +/** Splits `[[target]]`/`[[target|alias]]` into its target and display label (the alias, or empty). */ +export function parseWikilink(text: string): ParsedWikilink { const inner = text.replace(/^\[\[/u, '').replace(/\]\]$/u, '') const pipe = inner.indexOf('|') - return (pipe >= 0 ? inner.slice(0, pipe) : inner).trim() + if (pipe < 0) return { target: inner.trim(), display: '' } + return { target: inner.slice(0, pipe).trim(), display: inner.slice(pipe + 1).trim() } +} + +/** Extracts the target from `[[target]]` or `[[target|alias]]`. Exported for tests. */ +export function parseWikilinkTarget(text: string): string { + return parseWikilink(text).target } export interface WikilinkClickPayload { @@ -53,7 +62,9 @@ export function defineWikilinkClickHandler(onClick: WikilinkClickHandler): Plain }, }, handleClick: (view, pos, event) => { - const onLink = (event.target as HTMLElement | null)?.closest?.('.md-wikilink') + const onLink = (event.target as HTMLElement | null)?.closest?.( + '.md-wikilink-label, .md-wikilink-source', + ) if (!onLink) return false const link = wikilinkAt(view.state, pos) if (!link) return false diff --git a/packages/core/src/extensions/wikilink.test.ts b/packages/core/src/extensions/wikilink.test.ts new file mode 100644 index 0000000..328b97c --- /dev/null +++ b/packages/core/src/extensions/wikilink.test.ts @@ -0,0 +1,139 @@ +import { TextSelection } from '@prosekit/pm/state' +import { describe, expect, it } from 'vitest' +import { page, userEvent } from 'vitest/browser' + +import { getSelectionSnapshot, setupFixture, type Fixture } from '../testing/index.ts' + +import { defineMarkMode } from './mark-mode.ts' + +const pmRoot = page.locate('.ProseMirror') +const label = pmRoot.getByTestId('wikilink') + +// Text: A B [ [ N o t e ] ] C D +// Offset 0 1 2 3 4 5 6 7 8 9 10 11 12 +// +// The hidden wikilink source `[[Note]]` occupies the characters between offsets +// 2 and 10. +const TEXT = 'AB[[Note]]CD' + +// A hide-mode editor showing the wikilink, shared by the caret-navigation and +// selection-ring suites below. +function setupHidden(): Fixture { + const fixture = setupFixture() + const { editor, n } = fixture + editor.use(defineMarkMode('hide')) + fixture.set(n.doc(n.paragraph(TEXT))) + return fixture +} + +// Place a collapsed caret at text offset `offset`. +function setCaret(fixture: Fixture, offset: number): void { + const { view } = fixture + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, offset + 1))) + view.focus() +} + +// Press `key` `times` times, capturing the selection snapshot before and after +// each press. +async function trace(fixture: Fixture, key: string, times: number): Promise { + const steps = [getSelectionSnapshot(fixture.state)] + for (let index = 0; index < times; index++) { + await userEvent.keyboard(`{${key}}`) + steps.push(getSelectionSnapshot(fixture.state)) + } + return steps +} + +async function backspaceAt(offset: number): Promise { + using fixture = setupHidden() + setCaret(fixture, offset) + const before = getSelectionSnapshot(fixture.state) + await userEvent.keyboard('{Backspace}') + return `${before} -> ${getSelectionSnapshot(fixture.state)}` +} + +describe('wikilink rendering', () => { + it('renders the target as the label', async () => { + using fixture = setupFixture() + const { editor, n } = fixture + editor.use(defineMarkMode('hide')) + fixture.set(n.doc(n.paragraph('see [[Note]] here'))) + await expect.element(label).toHaveTextContent('Note') + }) + + it('renders the alias as the label', async () => { + using fixture = setupFixture() + const { editor, n } = fixture + editor.use(defineMarkMode('hide')) + fixture.set(n.doc(n.paragraph('see [[Note|My Note]] here'))) + await expect.element(label).toHaveTextContent('My Note') + }) +}) + +// A hidden wikilink is one caret stop in hide mode: arrowing onto it selects the +// whole `[[Note]]`, the next arrow steps past, and Backspace/Delete remove it as +// a unit. +describe('wikilink caret navigation in hide mode', () => { + it('ArrowRight selects the wikilink, then steps past into CD', async () => { + using fixture = setupHidden() + setCaret(fixture, 1) + expect(await trace(fixture, 'ArrowRight', 5)).toMatchInlineSnapshot(` + [ + "A▌B[[Note]]CD", + "AB▌[[Note]]CD", + "AB▛[[Note]]▟CD", + "AB[[Note]]▌CD", + "AB[[Note]]C▌D", + "AB[[Note]]CD▌", + ] + `) + }) + + it('ArrowLeft selects the wikilink, then collapses to its left edge', async () => { + using fixture = setupHidden() + setCaret(fixture, 11) + expect(await trace(fixture, 'ArrowLeft', 3)).toMatchInlineSnapshot(` + [ + "AB[[Note]]C▌D", + "AB[[Note]]▌CD", + "AB▛[[Note]]▟CD", + "AB▌[[Note]]CD", + ] + `) + }) + + it('Backspace deletes the wikilink as a unit, plain text one char', async () => { + const result = [ + await backspaceAt(1), // between A and B + await backspaceAt(2), // just before the wikilink + await backspaceAt(10), // just after the wikilink + await backspaceAt(11), // between C and D + ] + + expect(result).toMatchInlineSnapshot(` + [ + "A▌B[[Note]]CD -> ▌B[[Note]]CD", + "AB▌[[Note]]CD -> A▌[[Note]]CD", + "AB[[Note]]▌CD -> AB▌CD", + "AB[[Note]]C▌D -> AB[[Note]]▌D", + ] + `) + }) +}) + +describe('wikilink selection ring in hide mode', () => { + // Selecting the whole `[[Note]]` rings the label; a collapsed caret next to it + // does not. This is what the `md-wikilink-selected` decoration drives. + it('rings the label only while the wikilink is selected', async () => { + using fixture = setupHidden() + setCaret(fixture, 2) // AB| just before the wikilink + + await expect.element(label).toHaveStyle({ outlineStyle: 'none' }) + + await userEvent.keyboard('{ArrowRight}') // selects the whole wikilink + await expect.element(label).toHaveStyle({ outlineStyle: 'solid' }) + + await userEvent.keyboard('{ArrowRight}') // steps past, collapses the caret + await expect.element(label).toHaveStyle({ outlineStyle: 'none' }) + }) +}) diff --git a/packages/core/src/extensions/wikilink.ts b/packages/core/src/extensions/wikilink.ts new file mode 100644 index 0000000..e962c48 --- /dev/null +++ b/packages/core/src/extensions/wikilink.ts @@ -0,0 +1,56 @@ +import { defineMarkView, union, type PlainExtension } from '@prosekit/core' +import type { MarkViewConstructor } from '@prosekit/pm/view' + +import { defineAtomicMarkNavigation } from './atomic-mark-navigation.ts' +import type { MdWikilinkViewAttrs } from './inline-marks.ts' +import type { MarkName } from './mark-names.ts' + +/** + * Render `mdWikilinkView` (anchored on the wikilink's final character) as the + * non-editable label: the anchor char stays inside `contentDOM`, and the label + * is a `contentEditable="false"` sibling. Mark-mode hides the surrounding + * `mdWikilinkSource`, so what remains visible is the label, in place of the raw + * `[[target]]`/`[[target|alias]]`. + */ +function createWikilinkMarkView(): MarkViewConstructor { + return (mark) => { + const attrs = mark.attrs as MdWikilinkViewAttrs + + const dom = document.createElement('span') + dom.className = 'md-wikilink-view' + const contentDOM = document.createElement('span') + contentDOM.className = 'md-wikilink-view-content' + dom.appendChild(contentDOM) + + const label = document.createElement('span') + label.className = 'md-wikilink-label' + label.contentEditable = 'false' + label.dataset.testid = 'wikilink' + label.textContent = attrs.display || attrs.target + dom.appendChild(label) + + return { + dom, + contentDOM, + ignoreMutation: (mutation) => !contentDOM.contains(mutation.target), + } + } +} + +/** + * Render `[[target]]`/`[[target|alias]]` as an immutable inline label (a mark + * view) and make it a single caret stop in hide mode: arrowing onto it selects + * the whole source, and Backspace/Delete remove it as a unit. + */ +export function defineWikilink(): PlainExtension { + return union( + defineMarkView({ + name: 'mdWikilinkView' satisfies MarkName, + constructor: createWikilinkMarkView(), + }), + defineAtomicMarkNavigation({ + markNames: ['mdWikilinkSource' satisfies MarkName], + selectedClass: 'md-wikilink-selected', + }), + ) +} diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 259fe8d..95752e1 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -268,9 +268,11 @@ } /* --------------------------------------------------------------------------- - * Wikilinks (`[[target]]`) + * Wikilinks (`[[target]]`/`[[target|alias]]`), rendered in place via a mark + * view: an immutable label standing in for the raw source. * ------------------------------------------------------------------------- */ -.ProseMirror .md-wikilink { +.ProseMirror .md-wikilink-label, +.ProseMirror .md-wikilink-source { color: var(--meowdown-accent); text-decoration: underline; text-decoration-style: dashed; @@ -279,6 +281,37 @@ cursor: pointer; } +/* Wikilink source: hidden as a unit in hide/focus so the rendered label stands + * in for it; revealed near the caret in focus mode. */ +.ProseMirror[data-mark-mode='hide'] .md-wikilink-source, +.ProseMirror[data-mark-mode='focus'] .md-wikilink-source { + display: none; +} +.ProseMirror[data-mark-mode='focus'] .md-wikilink-source:has(.show) { + display: inline; +} + +/* The label stands in for the source in hide/focus. In show mode the raw source + * is visible, so the label would only duplicate it; likewise once the source is + * revealed near the caret in focus mode. */ +.ProseMirror[data-mark-mode='show'] .md-wikilink-label, +.ProseMirror[data-mark-mode='focus'] .md-wikilink-view:has(.show) .md-wikilink-label { + display: none; +} + +/* The whole wikilink source is selected: a single caret stop in hide mode. The + * `md-wikilink-selected` decoration wraps the source text spans, so match the + * mark view that contains a selected span and ring its label like a node + * selection (`.ProseMirror-selectednode`). */ +.ProseMirror[data-mark-mode='hide'] + .md-wikilink-view:has(.md-wikilink-selected) + .md-wikilink-label { + outline: 2px solid var(--meowdown-node-outline); + outline-offset: 2px; + border-radius: 0.25rem; + background: var(--meowdown-node-selection); +} + /* --------------------------------------------------------------------------- * Inline image / embed previews (`![alt](url)`), rendered in place via a mark * view at the image's source position. diff --git a/packages/react/src/components/editor.test.tsx b/packages/react/src/components/editor.test.tsx index 748e27c..124f880 100644 --- a/packages/react/src/components/editor.test.tsx +++ b/packages/react/src/components/editor.test.tsx @@ -404,7 +404,7 @@ describe('MeowdownEditor', () => { const screen = await render( , ) - await screen.getByText('Note').click() + await screen.getByTestId('wikilink').click() await vi.waitFor(() => { expect(onWikilinkClick).toHaveBeenCalledWith(expect.objectContaining({ target: 'Note' })) }) From ab80e472e090c34081773a524ac2f7cfa940a218 Mon Sep 17 00:00:00 2001 From: ocavue Date: Thu, 18 Jun 2026 23:57:32 +1000 Subject: [PATCH 03/20] fix(website): build tag and wikilink menu items from search results --- website/src/app.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/website/src/app.tsx b/website/src/app.tsx index 10f49e1..e22ced6 100644 --- a/website/src/app.tsx +++ b/website/src/app.tsx @@ -1,4 +1,4 @@ -import { MeowdownEditor, type EditorMode } from '@meowdown/react' +import { MeowdownEditor, type EditorMode, type TagItem, type WikilinkItem } from '@meowdown/react' import { type CSSProperties, useLayoutEffect, useState } from 'react' import { uploadFile } from './upload-file.ts' @@ -90,10 +90,10 @@ function greet(name: string): string { const TAGS = ['cats', 'editor', 'ideas', 'markdown', 'meow', 'notes', 'react', 'todo', 'work'] -async function searchTags(query: string): Promise { +async function searchTags(query: string): Promise { // Simulate network latency so the tag menu's loading state shows up. await new Promise((resolve) => setTimeout(resolve, 200)) - return TAGS.filter((tag) => tag.includes(query)) + return TAGS.filter((tag) => tag.includes(query)).map((tag) => ({ tag })) } const NOTES = [ @@ -105,10 +105,12 @@ const NOTES = [ 'Travel plans', ] -async function searchNotes(query: string): Promise { +async function searchNotes(query: string): Promise { // Simulate network latency so the wikilink menu's loading state shows up. await new Promise((resolve) => setTimeout(resolve, 200)) - return NOTES.filter((note) => note.toLowerCase().includes(query)) + return NOTES.filter((note) => note.toLowerCase().includes(query)).map((note) => ({ + target: note, + })) } const ICON_BUTTON_CLASS = From fe01336ea543c7e2971b562baed84176aea15e9e Mon Sep 17 00:00:00 2001 From: ocavue Date: Thu, 18 Jun 2026 23:57:32 +1000 Subject: [PATCH 04/20] build: typecheck the react and website projects in the root build --- tsconfig.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index f236fad..5fbc154 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,8 @@ { "extends": "@ocavue/tsconfig/dom/root.json", - "references": [{ "path": "./packages/core/tsconfig.json" }] + "references": [ + { "path": "./packages/core/tsconfig.json" }, + { "path": "./packages/react/tsconfig.json" }, + { "path": "./website/tsconfig.json" } + ] } From 2594c7e093c6f3bd2e63b30cb0843699dc69267d Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 01:05:30 +1000 Subject: [PATCH 05/20] refactor(core): anchor the wikilink view on the whole closing bracket --- .../inline-text-to-mark-chunks.test.ts | 18 ++++------- .../extensions/inline-text-to-mark-chunks.ts | 32 +++++++------------ .../core/src/extensions/mark-mode.test.ts | 6 ++-- 3 files changed, 20 insertions(+), 36 deletions(-) diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts index 76d8d54..c8f0819 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts @@ -473,8 +473,7 @@ describe('inlineTextToMarkChunks', () => { 0-2: - 2-4: mdMark + mdWikilinkSource(target=note) 4-8: mdWikilinkSource(target=note) - 8-9: mdMark + mdWikilinkSource(target=note) - 9-10: mdMark + mdWikilinkSource(target=note) + mdWikilinkView(target=note) + 8-10: mdMark + mdWikilinkSource(target=note) + mdWikilinkView(target=note) 10-12: - " `) @@ -486,12 +485,10 @@ describe('inlineTextToMarkChunks', () => { " 0-2: mdMark + mdWikilinkSource(target=a) 2-3: mdWikilinkSource(target=a) - 3-4: mdMark + mdWikilinkSource(target=a) - 4-5: mdMark + mdWikilinkSource(target=a) + mdWikilinkView(target=a) + 3-5: mdMark + mdWikilinkSource(target=a) + mdWikilinkView(target=a) 5-7: mdMark + mdWikilinkSource(target=b) 7-8: mdWikilinkSource(target=b) - 8-9: mdMark + mdWikilinkSource(target=b) - 9-10: mdMark + mdWikilinkSource(target=b) + mdWikilinkView(target=b) + 8-10: mdMark + mdWikilinkSource(target=b) + mdWikilinkView(target=b) " `) }) @@ -504,8 +501,7 @@ describe('inlineTextToMarkChunks', () => { 1-3: mdEm 3-5: mdEm + mdMark + mdWikilinkSource(target=n) 5-6: mdEm + mdWikilinkSource(target=n) - 6-7: mdEm + mdMark + mdWikilinkSource(target=n) - 7-8: mdEm + mdMark + mdWikilinkSource(target=n) + mdWikilinkView(target=n) + 6-8: mdEm + mdMark + mdWikilinkSource(target=n) + mdWikilinkView(target=n) 8-10: mdEm 10-11: mdEm + mdMark " @@ -520,8 +516,7 @@ describe('inlineTextToMarkChunks', () => { 1-5: mdLinkText(href=http://y) 5-7: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) 7-8: mdLinkText(href=http://y) + mdWikilinkSource(target=x) - 8-9: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) - 9-10: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) + mdWikilinkView(target=x) + 8-10: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) + mdWikilinkView(target=x) 10-12: mdMark 12-20: mdLinkUri 20-21: mdMark @@ -535,8 +530,7 @@ describe('inlineTextToMarkChunks', () => { " 0-2: mdMark + mdWikilinkSource(target=note #tag) 2-11: mdWikilinkSource(target=note #tag) - 11-12: mdMark + mdWikilinkSource(target=note #tag) - 12-13: mdMark + mdWikilinkSource(target=note #tag) + mdWikilinkView(target=note #tag) + 11-13: mdMark + mdWikilinkSource(target=note #tag) + mdWikilinkView(target=note #tag) " `) }) diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.ts index b6436ba..7a09d9a 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.ts @@ -257,12 +257,11 @@ function walkImage( * Special walker for a wikilink `[[target]]`/`[[target|alias]]`. * * Emits `mdWikilinkSource({ target })` across the whole node (the mark - * `defineMarkMode` hides) and `mdWikilinkView({ target, display })` on the node's - * final character, the anchor a mark view renders the non-editable label on. The + * `defineMarkMode` hides) and `mdWikilinkView({ target, display })` on the + * closing `]]`, the anchor a mark view renders the non-editable label on. The * node's only children are the two `WikilinkMark` brackets (`[[` and `]]`); they * carry `mdMark` for show mode, and the target text between them carries only - * `mdWikilinkSource`. The closing `]]` straddles the anchor, so it splits: the - * final `]` also gets `mdWikilinkView`. + * `mdWikilinkSource`. */ function walkWikilink( node: InlineElement, @@ -274,28 +273,21 @@ function walkWikilink( const { target, display } = parseWikilink(text.slice(node.from, node.to)) const source = marks.mdWikilinkSource.create({ target } satisfies MdWikilinkSourceAttrs) const view = marks.mdWikilinkView.create({ target, display } satisfies MdWikilinkViewAttrs) - const anchorFrom = node.to - 1 let pos = node.from for (const child of node.children) { if (child.from > pos) { emit(out, pos, child.from, [...parentMarks, source]) } - if (child.from < anchorFrom) { - emit(out, child.from, Math.min(child.to, anchorFrom), [ - ...parentMarks, - source, - marks.mdMark.create(), - ]) - } - if (child.to > anchorFrom) { - emit(out, Math.max(child.from, anchorFrom), child.to, [ - ...parentMarks, - source, - view, - marks.mdMark.create(), - ]) - } + // The closing `]]` (the node's last child) anchors the label mark view; the + // opening `[[` is plain source syntax. + const isClosing = child.to === node.to + emit(out, child.from, child.to, [ + ...parentMarks, + source, + ...(isClosing ? [view] : []), + marks.mdMark.create(), + ]) pos = child.to } if (pos < node.to) { diff --git a/packages/core/src/extensions/mark-mode.test.ts b/packages/core/src/extensions/mark-mode.test.ts index 2fff329..a5e8577 100644 --- a/packages/core/src/extensions/mark-mode.test.ts +++ b/packages/core/src/extensions/mark-mode.test.ts @@ -131,15 +131,13 @@ describe('defineMarkMode', () => { await expectRevealedMarkers(0) }) - // `[[` is one span; the closing `]]` is two (the final `]` anchors the - // label mark view), so three marker spans reveal in all. it('reveals both [[ ]] when the cursor is inside a wikilink', async () => { using fixture = setupFixture() fixture.editor.use(defineMarkMode('focus')) const { n } = fixture const doc = n.doc(n.paragraph('see [[note]] end')) fixture.set(doc) - await expectRevealedMarkers(3) + await expectRevealedMarkers(2) }) it('reveals only the wikilink brackets next to a markdown link', async () => { @@ -148,7 +146,7 @@ describe('defineMarkMode', () => { const { n } = fixture const doc = n.doc(n.paragraph('[a](http://x)[[note]]')) fixture.set(doc) - await expectRevealedMarkers(3) + await expectRevealedMarkers(2) }) it('reveals nothing inside a #tag (tags have no syntax to reveal)', async () => { From df7303ce42044dd5e41c504bf16b9bdc59e1b5c6 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 01:05:30 +1000 Subject: [PATCH 06/20] test(core): share caret-trace helpers and match the image test style --- packages/core/src/extensions/image.test.ts | 48 ++++-------- packages/core/src/extensions/wikilink.test.ts | 76 +++++++++---------- packages/core/src/testing/caret.ts | 46 +++++++++++ packages/core/src/testing/index.ts | 1 + 4 files changed, 99 insertions(+), 72 deletions(-) create mode 100644 packages/core/src/testing/caret.ts diff --git a/packages/core/src/extensions/image.test.ts b/packages/core/src/extensions/image.test.ts index 691f644..cce7455 100644 --- a/packages/core/src/extensions/image.test.ts +++ b/packages/core/src/extensions/image.test.ts @@ -1,8 +1,14 @@ -import { TextSelection } from '@prosekit/pm/state' import { describe, expect, it, vi } from 'vitest' import { page, userEvent } from 'vitest/browser' -import { getSelectionSnapshot, setupFixture, type Fixture } from '../testing/index.ts' +import { + getSelectionSnapshot, + setCaret, + setupFixture, + traceKeyAt, + traceKeySelection, + type Fixture, +} from '../testing/index.ts' import { defineImageClickHandler, type ImageClickHandler } from './image-click.ts' import { defineImage } from './image.ts' @@ -33,32 +39,6 @@ function setupHidden(): Fixture { return fixture } -// Place a collapsed caret at text offset `offset`. -function setCaret(fixture: Fixture, offset: number): void { - const { view } = fixture - view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, offset + 1))) - view.focus() -} - -// Press `key` `times` times, capturing the selection snapshot before and after -// each press. -async function trace(fixture: Fixture, key: string, times: number): Promise { - const steps = [getSelectionSnapshot(fixture.state)] - for (let index = 0; index < times; index++) { - await userEvent.keyboard(`{${key}}`) - steps.push(getSelectionSnapshot(fixture.state)) - } - return steps -} - -async function backspaceAt(offset: number): Promise { - using fixture = setupHidden() - setCaret(fixture, offset) - const before = getSelectionSnapshot(fixture.state) - await userEvent.keyboard('{Backspace}') - return `${before} -> ${getSelectionSnapshot(fixture.state)}` -} - describe('image', () => { it('renders an image preview in place of its source', async () => { using fixture = setupFixture() @@ -137,7 +117,7 @@ describe('image caret navigation in hide mode', () => { it('ArrowRight selects the image, then steps past into DEF', async () => { using fixture = setupHidden() setCaret(fixture, 1) - expect(await trace(fixture, 'ArrowRight', 6)).toMatchInlineSnapshot(` + expect(await traceKeySelection(fixture, 'ArrowRight', 6)).toMatchInlineSnapshot(` [ "A▌BC![img](url)DEF", "AB▌C![img](url)DEF", @@ -153,7 +133,7 @@ describe('image caret navigation in hide mode', () => { it('ArrowLeft selects the image, then collapses to its left edge', async () => { using fixture = setupHidden() setCaret(fixture, 15) - expect(await trace(fixture, 'ArrowLeft', 3)).toMatchInlineSnapshot(` + expect(await traceKeySelection(fixture, 'ArrowLeft', 3)).toMatchInlineSnapshot(` [ "ABC![img](url)D▌EF", "ABC![img](url)▌DEF", @@ -165,10 +145,10 @@ describe('image caret navigation in hide mode', () => { it('Backspace deletes the image as a unit, plain text one char', async () => { const result = [ - await backspaceAt(2), // between B and C - await backspaceAt(3), // just before the image - await backspaceAt(14), // just after the image - await backspaceAt(15), // between D and E + await traceKeyAt(setupHidden, 2, 'Backspace'), // between B and C + await traceKeyAt(setupHidden, 3, 'Backspace'), // just before the image + await traceKeyAt(setupHidden, 14, 'Backspace'), // just after the image + await traceKeyAt(setupHidden, 15, 'Backspace'), // between D and E ] expect(result).toMatchInlineSnapshot(` diff --git a/packages/core/src/extensions/wikilink.test.ts b/packages/core/src/extensions/wikilink.test.ts index 328b97c..c166a04 100644 --- a/packages/core/src/extensions/wikilink.test.ts +++ b/packages/core/src/extensions/wikilink.test.ts @@ -1,8 +1,14 @@ -import { TextSelection } from '@prosekit/pm/state' import { describe, expect, it } from 'vitest' import { page, userEvent } from 'vitest/browser' -import { getSelectionSnapshot, setupFixture, type Fixture } from '../testing/index.ts' +import { + getSelectionSnapshot, + setCaret, + setupFixture, + traceKeyAt, + traceKeySelection, + type Fixture, +} from '../testing/index.ts' import { defineMarkMode } from './mark-mode.ts' @@ -10,7 +16,7 @@ const pmRoot = page.locate('.ProseMirror') const label = pmRoot.getByTestId('wikilink') // Text: A B [ [ N o t e ] ] C D -// Offset 0 1 2 3 4 5 6 7 8 9 10 11 12 +// Offset: 0 1 2 3 4 5 6 7 8 9 10 11 12 // // The hidden wikilink source `[[Note]]` occupies the characters between offsets // 2 and 10. @@ -26,32 +32,6 @@ function setupHidden(): Fixture { return fixture } -// Place a collapsed caret at text offset `offset`. -function setCaret(fixture: Fixture, offset: number): void { - const { view } = fixture - view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, offset + 1))) - view.focus() -} - -// Press `key` `times` times, capturing the selection snapshot before and after -// each press. -async function trace(fixture: Fixture, key: string, times: number): Promise { - const steps = [getSelectionSnapshot(fixture.state)] - for (let index = 0; index < times; index++) { - await userEvent.keyboard(`{${key}}`) - steps.push(getSelectionSnapshot(fixture.state)) - } - return steps -} - -async function backspaceAt(offset: number): Promise { - using fixture = setupHidden() - setCaret(fixture, offset) - const before = getSelectionSnapshot(fixture.state) - await userEvent.keyboard('{Backspace}') - return `${before} -> ${getSelectionSnapshot(fixture.state)}` -} - describe('wikilink rendering', () => { it('renders the target as the label', async () => { using fixture = setupFixture() @@ -77,7 +57,7 @@ describe('wikilink caret navigation in hide mode', () => { it('ArrowRight selects the wikilink, then steps past into CD', async () => { using fixture = setupHidden() setCaret(fixture, 1) - expect(await trace(fixture, 'ArrowRight', 5)).toMatchInlineSnapshot(` + expect(await traceKeySelection(fixture, 'ArrowRight', 5)).toMatchInlineSnapshot(` [ "A▌B[[Note]]CD", "AB▌[[Note]]CD", @@ -92,7 +72,7 @@ describe('wikilink caret navigation in hide mode', () => { it('ArrowLeft selects the wikilink, then collapses to its left edge', async () => { using fixture = setupHidden() setCaret(fixture, 11) - expect(await trace(fixture, 'ArrowLeft', 3)).toMatchInlineSnapshot(` + expect(await traceKeySelection(fixture, 'ArrowLeft', 3)).toMatchInlineSnapshot(` [ "AB[[Note]]C▌D", "AB[[Note]]▌CD", @@ -104,10 +84,10 @@ describe('wikilink caret navigation in hide mode', () => { it('Backspace deletes the wikilink as a unit, plain text one char', async () => { const result = [ - await backspaceAt(1), // between A and B - await backspaceAt(2), // just before the wikilink - await backspaceAt(10), // just after the wikilink - await backspaceAt(11), // between C and D + await traceKeyAt(setupHidden, 1, 'Backspace'), // between A and B + await traceKeyAt(setupHidden, 2, 'Backspace'), // just before the wikilink + await traceKeyAt(setupHidden, 10, 'Backspace'), // just after the wikilink + await traceKeyAt(setupHidden, 11, 'Backspace'), // between C and D ] expect(result).toMatchInlineSnapshot(` @@ -126,14 +106,34 @@ describe('wikilink selection ring in hide mode', () => { // does not. This is what the `md-wikilink-selected` decoration drives. it('rings the label only while the wikilink is selected', async () => { using fixture = setupHidden() - setCaret(fixture, 2) // AB| just before the wikilink + // Put the caret just before the wikilink + setCaret(fixture, 2) + expect(getSelectionSnapshot(fixture.state)).toMatchInlineSnapshot(`"AB▌[[Note]]CD"`) await expect.element(label).toHaveStyle({ outlineStyle: 'none' }) - await userEvent.keyboard('{ArrowRight}') // selects the whole wikilink + // Selects the whole wikilink + await userEvent.keyboard('{ArrowRight}') + expect(getSelectionSnapshot(fixture.state)).toMatchInlineSnapshot(`"AB▛[[Note]]▟CD"`) await expect.element(label).toHaveStyle({ outlineStyle: 'solid' }) - await userEvent.keyboard('{ArrowRight}') // steps past, collapses the caret + // Steps past, collapses the caret + await userEvent.keyboard('{ArrowRight}') + expect(getSelectionSnapshot(fixture.state)).toMatchInlineSnapshot(`"AB[[Note]]▌CD"`) await expect.element(label).toHaveStyle({ outlineStyle: 'none' }) }) + + it('rings the label when selected from its right edge', async () => { + using fixture = setupHidden() + + // Put the caret just after the wikilink + setCaret(fixture, 10) + expect(getSelectionSnapshot(fixture.state)).toMatchInlineSnapshot(`"AB[[Note]]▌CD"`) + await expect.element(label).toHaveStyle({ outlineStyle: 'none' }) + + // Selects the whole wikilink + await userEvent.keyboard('{ArrowLeft}') + expect(getSelectionSnapshot(fixture.state)).toMatchInlineSnapshot(`"AB▛[[Note]]▟CD"`) + await expect.element(label).toHaveStyle({ outlineStyle: 'solid' }) + }) }) diff --git a/packages/core/src/testing/caret.ts b/packages/core/src/testing/caret.ts new file mode 100644 index 0000000..41676c9 --- /dev/null +++ b/packages/core/src/testing/caret.ts @@ -0,0 +1,46 @@ +import { TextSelection } from '@prosekit/pm/state' +import { userEvent } from 'vitest/browser' + +import { getSelectionSnapshot } from './selection-snapshot.ts' + +import type { Fixture } from './index.ts' + +/** Place a collapsed caret at text offset `offset`. */ +export function setCaret(fixture: Fixture, offset: number): void { + const { view } = fixture + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, offset + 1))) + view.focus() +} + +/** + * Press `key` `times` times, capturing the selection snapshot before the first + * press and after each one. The returned array has `times + 1` entries. + */ +export async function traceKeySelection( + fixture: Fixture, + key: string, + times: number, +): Promise { + const steps = [getSelectionSnapshot(fixture.state)] + for (let index = 0; index < times; index++) { + await userEvent.keyboard(`{${key}}`) + steps.push(getSelectionSnapshot(fixture.state)) + } + return steps +} + +/** + * On a fresh fixture from `setup`, place the caret at text offset `offset`, + * press `key` once, and return the `before -> after` selection snapshot. + */ +export async function traceKeyAt( + setup: () => Fixture, + offset: number, + key: string, +): Promise { + using fixture = setup() + setCaret(fixture, offset) + const before = getSelectionSnapshot(fixture.state) + await userEvent.keyboard(`{${key}}`) + return `${before} -> ${getSelectionSnapshot(fixture.state)}` +} diff --git a/packages/core/src/testing/index.ts b/packages/core/src/testing/index.ts index 2683624..a3705f9 100644 --- a/packages/core/src/testing/index.ts +++ b/packages/core/src/testing/index.ts @@ -8,6 +8,7 @@ import type { EditorNode } from '@prosekit/pm/model' import { defineEditorExtension } from '../extensions/extension.ts' export { getSelectionSnapshot } from './selection-snapshot.ts' +export { setCaret, traceKeySelection, traceKeyAt } from './caret.ts' export interface SetupFixtureOptions { /** Whether to mount the editor onto a real DOM container. Defaults to `true`. */ From 7dd20fca4a60f06c6deeeccec9932c09c49da059 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:30:08 +1000 Subject: [PATCH 07/20] fix(core): drop the narrating comment in the wikilink mark test --- packages/core/src/extensions/inline-mark-plugin.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/extensions/inline-mark-plugin.test.ts b/packages/core/src/extensions/inline-mark-plugin.test.ts index a77b5ac..78a6c02 100644 --- a/packages/core/src/extensions/inline-mark-plugin.test.ts +++ b/packages/core/src/extensions/inline-mark-plugin.test.ts @@ -237,8 +237,7 @@ describe('inlineMarkPlugin', () => { const pos = findText(fixture.doc, 'note') expect(marksAt(fixture.doc, pos + 1)).toEqual(['mdWikilinkSource']) - // Delete the last ']': "see [[note] end" is no longer a wikilink. The - // closing brackets are separate text nodes, so target the first `]`. + // Delete one ']': "see [[note] end" is no longer a wikilink. const firstBracket = findText(fixture.doc, ']') fixture.view.dispatch(fixture.state.tr.delete(firstBracket + 1, firstBracket + 2)) const after = findText(fixture.doc, 'note') From d5c4f926301b4fe57c6e0c4ad54afd8e95e9f570 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:30:08 +1000 Subject: [PATCH 08/20] refactor(core): drop test-only parseWikilinkTarget wrapper --- .../core/src/extensions/wikilink-click.test.ts | 15 ++++++++------- packages/core/src/extensions/wikilink-click.ts | 5 ----- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/core/src/extensions/wikilink-click.test.ts b/packages/core/src/extensions/wikilink-click.test.ts index 3c3642b..e3514ec 100644 --- a/packages/core/src/extensions/wikilink-click.test.ts +++ b/packages/core/src/extensions/wikilink-click.test.ts @@ -2,15 +2,16 @@ import { describe, expect, it } from 'vitest' import { setupFixture } from '../testing/index.ts' -import { parseWikilinkTarget, findWikilinkAt } from './wikilink-click.ts' +import { parseWikilink, findWikilinkAt } from './wikilink-click.ts' -describe('parseWikilinkTarget', () => { +describe('parseWikilink', () => { it.each([ - ['[[Note]]', 'Note'], - ['[[Note|Alias]]', 'Note'], - ['[[ Spaced Name ]]', 'Spaced Name'], - ])('extracts the target from %s', (input, expected) => { - expect(parseWikilinkTarget(input)).toBe(expected) + ['[[Note]]', 'Note', ''], + ['[[Note|Alias]]', 'Note', 'Alias'], + ['[[ Spaced Name ]]', 'Spaced Name', ''], + ['[[Note | My Note]]', 'Note', 'My Note'], + ])('parses %s', (input, target, display) => { + expect(parseWikilink(input)).toEqual({ target, display }) }) }) diff --git a/packages/core/src/extensions/wikilink-click.ts b/packages/core/src/extensions/wikilink-click.ts index 8eab7a4..98cfe19 100644 --- a/packages/core/src/extensions/wikilink-click.ts +++ b/packages/core/src/extensions/wikilink-click.ts @@ -34,11 +34,6 @@ export function parseWikilink(text: string): ParsedWikilink { return { target: inner.slice(0, pipe).trim(), display: inner.slice(pipe + 1).trim() } } -/** Extracts the target from `[[target]]` or `[[target|alias]]`. Exported for tests. */ -export function parseWikilinkTarget(text: string): string { - return parseWikilink(text).target -} - export interface WikilinkClickPayload { target: string event: MouseEvent From 2210a8683a2345c2e3872797f8970944f634626b Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:30:08 +1000 Subject: [PATCH 09/20] refactor(core): drop unused md-wikilink-view-content class --- packages/core/src/extensions/wikilink.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/extensions/wikilink.ts b/packages/core/src/extensions/wikilink.ts index e962c48..12cce15 100644 --- a/packages/core/src/extensions/wikilink.ts +++ b/packages/core/src/extensions/wikilink.ts @@ -19,7 +19,6 @@ function createWikilinkMarkView(): MarkViewConstructor { const dom = document.createElement('span') dom.className = 'md-wikilink-view' const contentDOM = document.createElement('span') - contentDOM.className = 'md-wikilink-view-content' dom.appendChild(contentDOM) const label = document.createElement('span') From 713ad5dbb76ad23d9b4e18466b258cfbffdd2969 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:30:08 +1000 Subject: [PATCH 10/20] refactor(core): return undefined from atomic selection decorations --- packages/core/src/extensions/atomic-mark-navigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/extensions/atomic-mark-navigation.ts b/packages/core/src/extensions/atomic-mark-navigation.ts index eecf72d..3d8ace5 100644 --- a/packages/core/src/extensions/atomic-mark-navigation.ts +++ b/packages/core/src/extensions/atomic-mark-navigation.ts @@ -159,7 +159,7 @@ function createSelectionPlugin(markNames: MarkName[], selectedClass: string): Pl props: { decorations: (state) => { const range = getSelectedRange(state, markNames) - if (!range) return null + if (!range) return undefined return DecorationSet.create(state.doc, [ Decoration.inline(range.from, range.to, { class: selectedClass }), ]) From dac2aa2c9b57bfa429434a54eca390dfbff3d640 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:33:32 +1000 Subject: [PATCH 11/20] refactor(core): drive image and wikilink atomic nav from one extension --- packages/core/src/extensions/extension.ts | 5 +++++ packages/core/src/extensions/image.ts | 5 ----- packages/core/src/extensions/wikilink.ts | 22 ++++++++-------------- packages/core/src/style.css | 8 ++++---- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index 24b4851..4c5a601 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -16,6 +16,7 @@ import { defineParagraph } from '@prosekit/extensions/paragraph' import { defineText } from '@prosekit/extensions/text' import { defineVirtualSelection } from '@prosekit/extensions/virtual-selection' +import { defineAtomicMarkNavigation } from './atomic-mark-navigation.ts' import { defineCodeBlockSyntaxHighlight } from './code-block-highlight.ts' import { defineHeading } from './heading.ts' import { defineInlineMarkPlugin } from './inline-mark-plugin.ts' @@ -45,6 +46,10 @@ function defineEditorExtensionImpl() { defineInlineMarkPlugin(), defineInlineToggle(), defineWikilink(), + defineAtomicMarkNavigation({ + markNames: ['mdImageSource', 'mdWikilinkSource'], + selectedClass: 'md-atomic-selected', + }), // others defineBaseKeymap(), diff --git a/packages/core/src/extensions/image.ts b/packages/core/src/extensions/image.ts index f6b6543..6e5e685 100644 --- a/packages/core/src/extensions/image.ts +++ b/packages/core/src/extensions/image.ts @@ -9,7 +9,6 @@ import { import { Plugin, PluginKey } from '@prosekit/pm/state' import type { EditorView, MarkViewConstructor } from '@prosekit/pm/view' -import { defineAtomicMarkNavigation } from './atomic-mark-navigation.ts' import { matchEmbed } from './embed/index.ts' import type { MdImageViewAttrs } from './inline-marks.ts' import type { MarkName } from './mark-names.ts' @@ -158,10 +157,6 @@ export function defineImage(options: ImageOptions = {}): PlainExtension { name: 'mdImageView' satisfies MarkName, constructor: createImageMarkView(options), }), - defineAtomicMarkNavigation({ - markNames: ['mdImageSource' satisfies MarkName], - selectedClass: 'md-image-selected', - }), // High priority so the drop/paste handler runs before ProseKit's // drop-indicator plugin. withPriority(definePlugin(createImageInputPlugin(options)), Priority.high), diff --git a/packages/core/src/extensions/wikilink.ts b/packages/core/src/extensions/wikilink.ts index 12cce15..4e0e667 100644 --- a/packages/core/src/extensions/wikilink.ts +++ b/packages/core/src/extensions/wikilink.ts @@ -1,7 +1,6 @@ -import { defineMarkView, union, type PlainExtension } from '@prosekit/core' +import { defineMarkView, type PlainExtension } from '@prosekit/core' import type { MarkViewConstructor } from '@prosekit/pm/view' -import { defineAtomicMarkNavigation } from './atomic-mark-navigation.ts' import type { MdWikilinkViewAttrs } from './inline-marks.ts' import type { MarkName } from './mark-names.ts' @@ -38,18 +37,13 @@ function createWikilinkMarkView(): MarkViewConstructor { /** * Render `[[target]]`/`[[target|alias]]` as an immutable inline label (a mark - * view) and make it a single caret stop in hide mode: arrowing onto it selects - * the whole source, and Backspace/Delete remove it as a unit. + * view) standing in for the raw source. The single-caret-stop behavior in hide + * mode comes from the shared `defineAtomicMarkNavigation` in the editor + * extension, which treats `mdWikilinkSource` (and `mdImageSource`) as one unit. */ export function defineWikilink(): PlainExtension { - return union( - defineMarkView({ - name: 'mdWikilinkView' satisfies MarkName, - constructor: createWikilinkMarkView(), - }), - defineAtomicMarkNavigation({ - markNames: ['mdWikilinkSource' satisfies MarkName], - selectedClass: 'md-wikilink-selected', - }), - ) + return defineMarkView({ + name: 'mdWikilinkView' satisfies MarkName, + constructor: createWikilinkMarkView(), + }) as PlainExtension } diff --git a/packages/core/src/style.css b/packages/core/src/style.css index bf008e5..d45875a 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -301,11 +301,11 @@ } /* The whole wikilink source is selected: a single caret stop in hide mode. The - * `md-wikilink-selected` decoration wraps the source text spans, so match the + * `md-atomic-selected` decoration wraps the source text spans, so match the * mark view that contains a selected span and ring its label like a node * selection (`.ProseMirror-selectednode`). */ .ProseMirror[data-mark-mode='hide'] - .md-wikilink-view:has(.md-wikilink-selected) + .md-wikilink-view:has(.md-atomic-selected) .md-wikilink-label { outline: 2px solid var(--meowdown-node-outline); outline-offset: 2px; @@ -353,11 +353,11 @@ } /* The whole image source is selected: a single caret stop in hide mode. The - * `md-image-selected` decoration wraps the source text spans, so the preview + * `md-atomic-selected` decoration wraps the source text spans, so the preview * (a sibling of the mark view's content) is not a descendant of it; match the * mark view that contains a selected span instead. Ring the preview like a node * selection (`.ProseMirror-selectednode`). */ -.ProseMirror[data-mark-mode='hide'] .md-image-view:has(.md-image-selected) .md-image-preview { +.ProseMirror[data-mark-mode='hide'] .md-image-view:has(.md-atomic-selected) .md-image-preview { outline: 2px solid var(--meowdown-node-outline); outline-offset: 2px; border-radius: var(--meowdown-image-radius, 0.5rem); From d5e9a36bb143481e11eb4eeb23cf19e59a4944e2 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:36:00 +1000 Subject: [PATCH 12/20] refactor(core): consolidate the syntax-hiding and selection-ring CSS --- packages/core/src/style.css | 73 +++++++++++++------------------------ 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/packages/core/src/style.css b/packages/core/src/style.css index d45875a..6752873 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -282,16 +282,6 @@ cursor: pointer; } -/* Wikilink source: hidden as a unit in hide/focus so the rendered label stands - * in for it; revealed near the caret in focus mode. */ -.ProseMirror[data-mark-mode='hide'] .md-wikilink-source, -.ProseMirror[data-mark-mode='focus'] .md-wikilink-source { - display: none; -} -.ProseMirror[data-mark-mode='focus'] .md-wikilink-source:has(.show) { - display: inline; -} - /* The label stands in for the source in hide/focus. In show mode the raw source * is visible, so the label would only duplicate it; likewise once the source is * revealed near the caret in focus mode. */ @@ -300,19 +290,6 @@ display: none; } -/* The whole wikilink source is selected: a single caret stop in hide mode. The - * `md-atomic-selected` decoration wraps the source text spans, so match the - * mark view that contains a selected span and ring its label like a node - * selection (`.ProseMirror-selectednode`). */ -.ProseMirror[data-mark-mode='hide'] - .md-wikilink-view:has(.md-atomic-selected) - .md-wikilink-label { - outline: 2px solid var(--meowdown-node-outline); - outline-offset: 2px; - border-radius: 0.25rem; - background: var(--meowdown-node-selection); -} - /* --------------------------------------------------------------------------- * Inline image / embed previews (`![alt](url)`), rendered in place via a mark * view at the image's source position. @@ -352,17 +329,22 @@ border: 0; } -/* The whole image source is selected: a single caret stop in hide mode. The - * `md-atomic-selected` decoration wraps the source text spans, so the preview - * (a sibling of the mark view's content) is not a descendant of it; match the - * mark view that contains a selected span instead. Ring the preview like a node - * selection (`.ProseMirror-selectednode`). */ -.ProseMirror[data-mark-mode='hide'] .md-image-view:has(.md-atomic-selected) .md-image-preview { +/* The whole image/wikilink source is selected: a single caret stop in hide + * mode. The `md-atomic-selected` decoration wraps the source text spans; the + * rendered preview/label is a sibling of the mark view's content, not a + * descendant of the decoration, so match the mark view that contains a selected + * span and ring it like a node selection (`.ProseMirror-selectednode`). */ +.ProseMirror[data-mark-mode='hide'] .md-image-view:has(.md-atomic-selected) .md-image-preview, +.ProseMirror[data-mark-mode='hide'] .md-wikilink-view:has(.md-atomic-selected) .md-wikilink-label { outline: 2px solid var(--meowdown-node-outline); outline-offset: 2px; - border-radius: var(--meowdown-image-radius, 0.5rem); + border-radius: 0.25rem; background: var(--meowdown-node-selection); } +/* The image preview keeps its own corner radius. */ +.ProseMirror[data-mark-mode='hide'] .md-image-view:has(.md-atomic-selected) .md-image-preview { + border-radius: var(--meowdown-image-radius, 0.5rem); +} /* --------------------------------------------------------------------------- * Placeholder text for empty blocks (ProseKit's definePlaceholder) @@ -392,28 +374,23 @@ opacity: 0.7; } -/* hide: syntax is never visible */ +/* Syntax hidden as a unit in hide and focus modes, revealed near the caret via + * the `.show` decoration in focus mode. Covers the punctuation (`md-mark`), the + * link/image URL (`md-link-uri`), and the whole image/wikilink source whose + * rendered preview/label stands in for it (incl. the image alt text). */ .ProseMirror[data-mark-mode='hide'] .md-mark, -.ProseMirror[data-mark-mode='hide'] .md-link-uri { - display: none; -} - -/* focus: syntax is hidden until revealed near the caret */ +.ProseMirror[data-mark-mode='hide'] .md-link-uri, +.ProseMirror[data-mark-mode='hide'] .md-image-source, +.ProseMirror[data-mark-mode='hide'] .md-wikilink-source, .ProseMirror[data-mark-mode='focus'] .md-mark, -.ProseMirror[data-mark-mode='focus'] .md-link-uri { +.ProseMirror[data-mark-mode='focus'] .md-link-uri, +.ProseMirror[data-mark-mode='focus'] .md-image-source, +.ProseMirror[data-mark-mode='focus'] .md-wikilink-source { display: none; } .ProseMirror[data-mark-mode='focus'] .md-mark:has(.show), -.ProseMirror[data-mark-mode='focus'] .md-link-uri:has(.show) { - display: inline; -} - -/* Image source (`![alt](url)`, incl. alt text): hidden as a unit so the inline - * preview stands in for it. */ -.ProseMirror[data-mark-mode='hide'] .md-image-source, -.ProseMirror[data-mark-mode='focus'] .md-image-source { - display: none; -} -.ProseMirror[data-mark-mode='focus'] .md-image-source:has(.show) { +.ProseMirror[data-mark-mode='focus'] .md-link-uri:has(.show), +.ProseMirror[data-mark-mode='focus'] .md-image-source:has(.show), +.ProseMirror[data-mark-mode='focus'] .md-wikilink-source:has(.show) { display: inline; } From f6338c311725c134cc9e2afa79591fe0e8d80a7b Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:39:10 +1000 Subject: [PATCH 13/20] refactor(core): anchor the wikilink view on the final char like the image --- .../inline-text-to-mark-chunks.test.ts | 18 ++++++---- .../extensions/inline-text-to-mark-chunks.ts | 34 +++++++++++-------- .../core/src/extensions/mark-mode.test.ts | 4 +-- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts index c8f0819..76d8d54 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts @@ -473,7 +473,8 @@ describe('inlineTextToMarkChunks', () => { 0-2: - 2-4: mdMark + mdWikilinkSource(target=note) 4-8: mdWikilinkSource(target=note) - 8-10: mdMark + mdWikilinkSource(target=note) + mdWikilinkView(target=note) + 8-9: mdMark + mdWikilinkSource(target=note) + 9-10: mdMark + mdWikilinkSource(target=note) + mdWikilinkView(target=note) 10-12: - " `) @@ -485,10 +486,12 @@ describe('inlineTextToMarkChunks', () => { " 0-2: mdMark + mdWikilinkSource(target=a) 2-3: mdWikilinkSource(target=a) - 3-5: mdMark + mdWikilinkSource(target=a) + mdWikilinkView(target=a) + 3-4: mdMark + mdWikilinkSource(target=a) + 4-5: mdMark + mdWikilinkSource(target=a) + mdWikilinkView(target=a) 5-7: mdMark + mdWikilinkSource(target=b) 7-8: mdWikilinkSource(target=b) - 8-10: mdMark + mdWikilinkSource(target=b) + mdWikilinkView(target=b) + 8-9: mdMark + mdWikilinkSource(target=b) + 9-10: mdMark + mdWikilinkSource(target=b) + mdWikilinkView(target=b) " `) }) @@ -501,7 +504,8 @@ describe('inlineTextToMarkChunks', () => { 1-3: mdEm 3-5: mdEm + mdMark + mdWikilinkSource(target=n) 5-6: mdEm + mdWikilinkSource(target=n) - 6-8: mdEm + mdMark + mdWikilinkSource(target=n) + mdWikilinkView(target=n) + 6-7: mdEm + mdMark + mdWikilinkSource(target=n) + 7-8: mdEm + mdMark + mdWikilinkSource(target=n) + mdWikilinkView(target=n) 8-10: mdEm 10-11: mdEm + mdMark " @@ -516,7 +520,8 @@ describe('inlineTextToMarkChunks', () => { 1-5: mdLinkText(href=http://y) 5-7: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) 7-8: mdLinkText(href=http://y) + mdWikilinkSource(target=x) - 8-10: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) + mdWikilinkView(target=x) + 8-9: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) + 9-10: mdLinkText(href=http://y) + mdMark + mdWikilinkSource(target=x) + mdWikilinkView(target=x) 10-12: mdMark 12-20: mdLinkUri 20-21: mdMark @@ -530,7 +535,8 @@ describe('inlineTextToMarkChunks', () => { " 0-2: mdMark + mdWikilinkSource(target=note #tag) 2-11: mdWikilinkSource(target=note #tag) - 11-13: mdMark + mdWikilinkSource(target=note #tag) + mdWikilinkView(target=note #tag) + 11-12: mdMark + mdWikilinkSource(target=note #tag) + 12-13: mdMark + mdWikilinkSource(target=note #tag) + mdWikilinkView(target=note #tag) " `) }) diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.ts index 7a09d9a..280504d 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.ts @@ -258,10 +258,11 @@ function walkImage( * * Emits `mdWikilinkSource({ target })` across the whole node (the mark * `defineMarkMode` hides) and `mdWikilinkView({ target, display })` on the - * closing `]]`, the anchor a mark view renders the non-editable label on. The - * node's only children are the two `WikilinkMark` brackets (`[[` and `]]`); they - * carry `mdMark` for show mode, and the target text between them carries only - * `mdWikilinkSource`. + * wikilink's final character, the anchor a mark view renders the non-editable + * label on (the same rule as `walkImage`). The node's only children are the two + * `WikilinkMark` brackets (`[[` and `]]`); they carry `mdMark` for show mode, + * and the target text between them carries only `mdWikilinkSource`. The closing + * `]]` is one child, so it is split here so only its last `]` carries the view. */ function walkWikilink( node: InlineElement, @@ -274,24 +275,27 @@ function walkWikilink( const source = marks.mdWikilinkSource.create({ target } satisfies MdWikilinkSourceAttrs) const view = marks.mdWikilinkView.create({ target, display } satisfies MdWikilinkViewAttrs) + // The wikilink's final character, where `mdWikilinkView` is anchored. + const anchorFrom = node.to - 1 + const baseAt = (from: number): Mark[] => + from >= anchorFrom ? [...parentMarks, source, view] : [...parentMarks, source] + let pos = node.from for (const child of node.children) { if (child.from > pos) { - emit(out, pos, child.from, [...parentMarks, source]) + emit(out, pos, child.from, baseAt(pos)) + } + const mark = marks.mdMark.create() + if (child.from < anchorFrom && child.to > anchorFrom) { + emit(out, child.from, anchorFrom, [...baseAt(child.from), mark]) + emit(out, anchorFrom, child.to, [...baseAt(anchorFrom), mark]) + } else { + emit(out, child.from, child.to, [...baseAt(child.from), mark]) } - // The closing `]]` (the node's last child) anchors the label mark view; the - // opening `[[` is plain source syntax. - const isClosing = child.to === node.to - emit(out, child.from, child.to, [ - ...parentMarks, - source, - ...(isClosing ? [view] : []), - marks.mdMark.create(), - ]) pos = child.to } if (pos < node.to) { - emit(out, pos, node.to, [...parentMarks, source]) + emit(out, pos, node.to, baseAt(pos)) } } diff --git a/packages/core/src/extensions/mark-mode.test.ts b/packages/core/src/extensions/mark-mode.test.ts index a5e8577..afc3e14 100644 --- a/packages/core/src/extensions/mark-mode.test.ts +++ b/packages/core/src/extensions/mark-mode.test.ts @@ -137,7 +137,7 @@ describe('defineMarkMode', () => { const { n } = fixture const doc = n.doc(n.paragraph('see [[note]] end')) fixture.set(doc) - await expectRevealedMarkers(2) + await expectRevealedMarkers(3) }) it('reveals only the wikilink brackets next to a markdown link', async () => { @@ -146,7 +146,7 @@ describe('defineMarkMode', () => { const { n } = fixture const doc = n.doc(n.paragraph('[a](http://x)[[note]]')) fixture.set(doc) - await expectRevealedMarkers(2) + await expectRevealedMarkers(3) }) it('reveals nothing inside a #tag (tags have no syntax to reveal)', async () => { From c669339feddf9edbea9341f80a9f44aaa12da784 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:41:21 +1000 Subject: [PATCH 14/20] refactor(core): move parseWikilink into wikilink.ts --- .../src/extensions/inline-text-to-mark-chunks.ts | 2 +- packages/core/src/extensions/wikilink-click.test.ts | 3 ++- packages/core/src/extensions/wikilink-click.ts | 13 ------------- packages/core/src/extensions/wikilink.ts | 13 +++++++++++++ 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.ts index 280504d..adab2cb 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.ts @@ -15,7 +15,7 @@ import type { MarkChunk } from './mark-chunk.ts' import type { MarkName } from './mark-names.ts' import { marksEqual } from './marks-equal.ts' import type { TypedMarkBuilders } from './schema.ts' -import { parseWikilink } from './wikilink-click.ts' +import { parseWikilink } from './wikilink.ts' /** * Lookup from Lezer node type id to the ProseMirror mark. diff --git a/packages/core/src/extensions/wikilink-click.test.ts b/packages/core/src/extensions/wikilink-click.test.ts index e3514ec..65c99db 100644 --- a/packages/core/src/extensions/wikilink-click.test.ts +++ b/packages/core/src/extensions/wikilink-click.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { setupFixture } from '../testing/index.ts' -import { parseWikilink, findWikilinkAt } from './wikilink-click.ts' +import { findWikilinkAt } from './wikilink-click.ts' +import { parseWikilink } from './wikilink.ts' describe('parseWikilink', () => { it.each([ diff --git a/packages/core/src/extensions/wikilink-click.ts b/packages/core/src/extensions/wikilink-click.ts index 98cfe19..79321d8 100644 --- a/packages/core/src/extensions/wikilink-click.ts +++ b/packages/core/src/extensions/wikilink-click.ts @@ -21,19 +21,6 @@ export function findWikilinkAt(state: EditorState, pos: number): WikilinkHit | u return { from: range.from, to: range.to, target } } -export interface ParsedWikilink { - target: string - display: string -} - -/** Splits `[[target]]`/`[[target|alias]]` into its target and display label (the alias, or empty). */ -export function parseWikilink(text: string): ParsedWikilink { - const inner = text.replace(/^\[\[/u, '').replace(/\]\]$/u, '') - const pipe = inner.indexOf('|') - if (pipe < 0) return { target: inner.trim(), display: '' } - return { target: inner.slice(0, pipe).trim(), display: inner.slice(pipe + 1).trim() } -} - export interface WikilinkClickPayload { target: string event: MouseEvent diff --git a/packages/core/src/extensions/wikilink.ts b/packages/core/src/extensions/wikilink.ts index 4e0e667..69beab2 100644 --- a/packages/core/src/extensions/wikilink.ts +++ b/packages/core/src/extensions/wikilink.ts @@ -4,6 +4,19 @@ import type { MarkViewConstructor } from '@prosekit/pm/view' import type { MdWikilinkViewAttrs } from './inline-marks.ts' import type { MarkName } from './mark-names.ts' +export interface ParsedWikilink { + target: string + display: string +} + +/** Splits `[[target]]`/`[[target|alias]]` into its target and display label (the alias, or empty). */ +export function parseWikilink(text: string): ParsedWikilink { + const inner = text.replace(/^\[\[/u, '').replace(/\]\]$/u, '') + const pipe = inner.indexOf('|') + if (pipe < 0) return { target: inner.trim(), display: '' } + return { target: inner.slice(0, pipe).trim(), display: inner.slice(pipe + 1).trim() } +} + /** * Render `mdWikilinkView` (anchored on the wikilink's final character) as the * non-editable label: the anchor char stays inside `contentDOM`, and the label From 6ed9058d329a8dcdd904118dd33e489683c688ed Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:44:35 +0000 Subject: [PATCH 15/20] [autofix.ci] apply automated fixes --- packages/core/src/extensions/atomic-mark-navigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/extensions/atomic-mark-navigation.ts b/packages/core/src/extensions/atomic-mark-navigation.ts index 3d8ace5..fe7f149 100644 --- a/packages/core/src/extensions/atomic-mark-navigation.ts +++ b/packages/core/src/extensions/atomic-mark-navigation.ts @@ -159,7 +159,7 @@ function createSelectionPlugin(markNames: MarkName[], selectedClass: string): Pl props: { decorations: (state) => { const range = getSelectedRange(state, markNames) - if (!range) return undefined + if (!range) return return DecorationSet.create(state.doc, [ Decoration.inline(range.from, range.to, { class: selectedClass }), ]) From a463942d1c8daf35c10c390e1599b11457be72f7 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:46:03 +1000 Subject: [PATCH 16/20] chore: trigger ci From 6e8c2cb8bbad617a7c6fd2f9ceac1e585f0b99a8 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:50:51 +1000 Subject: [PATCH 17/20] test(core): cover wikilink label clicks in the browser --- .../src/extensions/wikilink-click.test.ts | 78 ++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/core/src/extensions/wikilink-click.test.ts b/packages/core/src/extensions/wikilink-click.test.ts index 65c99db..99dfc51 100644 --- a/packages/core/src/extensions/wikilink-click.test.ts +++ b/packages/core/src/extensions/wikilink-click.test.ts @@ -1,10 +1,18 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { page, userEvent } from 'vitest/browser' -import { setupFixture } from '../testing/index.ts' +import { setupFixture, type Fixture } from '../testing/index.ts' -import { findWikilinkAt } from './wikilink-click.ts' +import { defineMarkMode } from './mark-mode.ts' +import { + defineWikilinkClickHandler, + findWikilinkAt, + type WikilinkClickHandler, +} from './wikilink-click.ts' import { parseWikilink } from './wikilink.ts' +const pmRoot = page.locate('.ProseMirror') + describe('parseWikilink', () => { it.each([ ['[[Note]]', 'Note', ''], @@ -31,4 +39,68 @@ describe('findWikilinkAt', () => { fixture.set(n.doc(n.paragraph('plain text'))) expect(findWikilinkAt(fixture.state, 2)).toBeUndefined() }) + + it('resolves adjacent wikilinks to distinct targets by position', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('[[Alpha]][[Beta]]'))) + const { textContent } = fixture.doc + const alphaPos = textContent.indexOf('Alpha') + 1 + const betaPos = textContent.indexOf('Beta') + 1 + expect(findWikilinkAt(fixture.state, alphaPos)?.target).toBe('Alpha') + expect(findWikilinkAt(fixture.state, betaPos)?.target).toBe('Beta') + }) +}) + +describe('wikilink click callback', () => { + // Render `markdown` in hide mode (so only the label shows) with a click + // handler attached. + function applyClickable( + fixture: Fixture, + markdown: string, + onWikilinkClick: WikilinkClickHandler, + ): void { + const { editor, n } = fixture + editor.use(defineMarkMode('hide')) + editor.use(defineWikilinkClickHandler(onWikilinkClick)) + fixture.set(n.doc(n.paragraph(markdown))) + } + + it('fires with the target when the label is clicked', async () => { + const onWikilinkClick = vi.fn() + using fixture = setupFixture() + applyClickable(fixture, 'see [[Note]] here', onWikilinkClick) + const label = pmRoot.getByTestId('wikilink') + await expect.element(label).toBeInTheDocument() + await userEvent.click(label) + await vi.waitFor(() => { + expect(onWikilinkClick).toHaveBeenCalledWith(expect.objectContaining({ target: 'Note' })) + }) + }) + + it('passes the originating MouseEvent', async () => { + const onWikilinkClick = vi.fn() + using fixture = setupFixture() + applyClickable(fixture, 'see [[Note]] here', onWikilinkClick) + await expect.element(pmRoot.getByTestId('wikilink')).toBeInTheDocument() + await userEvent.click(pmRoot.getByTestId('wikilink')) + await vi.waitFor(() => expect(onWikilinkClick).toHaveBeenCalled()) + expect(onWikilinkClick.mock.calls[0][0].event).toBeInstanceOf(MouseEvent) + }) + + it('does not fire when plain text is clicked', async () => { + const onWikilinkClick = vi.fn() + using fixture = setupFixture() + applyClickable(fixture, 'hello [[Note]] world', onWikilinkClick) + await expect.element(pmRoot.getByTestId('wikilink')).toBeInTheDocument() + await userEvent.click(pmRoot.getByText('hello', { exact: false })) + 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. }) From 36c532b83b28da154d935d0f429c2b748debb060 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:52:09 +1000 Subject: [PATCH 18/20] test(core): fix foramtMarkChunks typo --- .../inline-text-to-mark-chunks.test.ts | 104 +++++++++--------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts index 0108a15..ece9db8 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts @@ -27,7 +27,7 @@ function formatMarkChunk([from, to, marks]: MarkChunk): string { return `${from}-${to}: ${names || '-'}` } -function foramtMarkChunks(chunks: MarkChunk[]): string { +function formatMarkChunks(chunks: MarkChunk[]): string { return '\n' + chunks.map(formatMarkChunk).join('\n') + '\n' } @@ -43,7 +43,7 @@ describe('inlineTextToMarkChunks', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'hello world') // Pure text has no inline nodes; the implementation does not emit // a "no-mark" gap when the entire range is plain. - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-11: - " @@ -52,7 +52,7 @@ describe('inlineTextToMarkChunks', () => { it('emphasis yields gap + mark + content + mark', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'Hello *world*') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-6: - 6-7: mdEm + mdMark @@ -64,7 +64,7 @@ describe('inlineTextToMarkChunks', () => { it('strong emphasis', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a **bold** b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-4: mdMark + mdStrong @@ -77,7 +77,7 @@ describe('inlineTextToMarkChunks', () => { it('inline code', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a `c` b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-3: mdCode + mdMark @@ -90,7 +90,7 @@ describe('inlineTextToMarkChunks', () => { it('strikethrough', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a ~~b~~ c') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-4: mdDel + mdMark @@ -103,7 +103,7 @@ describe('inlineTextToMarkChunks', () => { it('link with href on its text portion', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'see [docs](http://x) now') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-4: - 4-5: mdLinkText(href=http://x) + mdMark @@ -118,7 +118,7 @@ describe('inlineTextToMarkChunks', () => { it('link with emphasis nested inside the text', () => { const chunks = inlineTextToMarkChunks(markBuilders, '[*ital*](http://x)') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdLinkText(href=http://x) + mdMark 1-2: mdEm + mdLinkText(href=http://x) + mdMark @@ -133,7 +133,7 @@ describe('inlineTextToMarkChunks', () => { it('image: mdImageSource over the source, mdImageView on the final char', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'see ![alt](http://x/p.png) end') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-4: - 4-6: mdImageSource(src=http://x/p.png,alt=alt) + mdMark @@ -148,7 +148,7 @@ describe('inlineTextToMarkChunks', () => { it('image with empty alt', () => { const chunks = inlineTextToMarkChunks(markBuilders, '![](z.png)') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-4: mdImageSource(src=z.png) + mdMark 4-9: mdImageSource(src=z.png) + mdLinkUri @@ -159,7 +159,7 @@ describe('inlineTextToMarkChunks', () => { it('reference image does not get image marks (falls through to the link walk)', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a ![b][id] c') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-4: mdMark @@ -172,7 +172,7 @@ describe('inlineTextToMarkChunks', () => { it('image with a title leaves the title node unmarked', () => { const chunks = inlineTextToMarkChunks(markBuilders, '![a](http://x "t")') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: mdImageSource(src=http://x,alt=a) + mdMark 2-3: mdImageSource(src=http://x,alt=a) @@ -186,7 +186,7 @@ describe('inlineTextToMarkChunks', () => { it('image with formatted alt marks the nested emphasis', () => { const chunks = inlineTextToMarkChunks(markBuilders, '![a **b** c](http://x)') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: mdImageSource(src=http://x,alt=a **b** c) + mdMark 2-4: mdImageSource(src=http://x,alt=a **b** c) @@ -203,7 +203,7 @@ describe('inlineTextToMarkChunks', () => { it('autolinks a bare https URL', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'visit https://example.com now') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-6: - 6-25: mdLinkText(href=https://example.com) @@ -214,7 +214,7 @@ describe('inlineTextToMarkChunks', () => { it('autolinks a www URL with an implied https scheme', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'see www.example.com here') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-4: - 4-19: mdLinkText(href=https://www.example.com) @@ -225,7 +225,7 @@ describe('inlineTextToMarkChunks', () => { it('autolinks a bare email as mailto', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'mail me@example.com ok') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-5: - 5-19: mdLinkText(href=mailto:me@example.com) @@ -236,7 +236,7 @@ describe('inlineTextToMarkChunks', () => { it('autolinks a bare mailto URL, keeping the scheme', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a mailto:me@example.com b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-23: mdLinkText(href=mailto:me@example.com) @@ -247,7 +247,7 @@ describe('inlineTextToMarkChunks', () => { it('autolinks an angle-bracket URL, with the brackets as mdMark', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-3: mdMark @@ -260,7 +260,7 @@ describe('inlineTextToMarkChunks', () => { it('keeps a non-http scheme in an angle autolink', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-3: mdMark @@ -273,7 +273,7 @@ describe('inlineTextToMarkChunks', () => { it('keeps an ssh scheme in an angle autolink', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-3: mdMark @@ -286,7 +286,7 @@ describe('inlineTextToMarkChunks', () => { it('excludes trailing punctuation from an autolink', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'end https://example.com.') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-4: - 4-23: mdLinkText(href=https://example.com) @@ -297,7 +297,7 @@ describe('inlineTextToMarkChunks', () => { it('autolinks a URL nested in emphasis', () => { const chunks = inlineTextToMarkChunks(markBuilders, '*https://example.com*') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdEm + mdMark 1-20: mdEm + mdLinkText(href=https://example.com) @@ -308,7 +308,7 @@ describe('inlineTextToMarkChunks', () => { it('does not bare-autolink a non-http scheme', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a ftp://example.com b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-21: - " @@ -317,7 +317,7 @@ describe('inlineTextToMarkChunks', () => { it('autolinks a bare domain on the curated TLD list', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a example.com b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-13: mdLinkText(href=https://example.com) @@ -328,7 +328,7 @@ describe('inlineTextToMarkChunks', () => { it('does not autolink a bare host whose TLD is off the list', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a README.md b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-13: - " @@ -337,7 +337,7 @@ describe('inlineTextToMarkChunks', () => { it('bare-autolinks a domain that starts the text', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'google.com') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-10: mdLinkText(href=https://google.com) " @@ -346,7 +346,7 @@ describe('inlineTextToMarkChunks', () => { it('bare-autolinks a domain with a path, keeping the path in the href', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'sub.domain.com/path?q=1') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-23: mdLinkText(href=https://sub.domain.com/path?q=1) " @@ -355,7 +355,7 @@ describe('inlineTextToMarkChunks', () => { it('preserves case in the bare-autolink href', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'GOOGLE.COM') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-10: mdLinkText(href=https://GOOGLE.COM) " @@ -364,7 +364,7 @@ describe('inlineTextToMarkChunks', () => { it('excludes a trailing period from a bare autolink', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'Visit google.com.') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-6: - 6-16: mdLinkText(href=https://google.com) @@ -375,7 +375,7 @@ describe('inlineTextToMarkChunks', () => { it('does not bare-autolink a code-file name', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'edit node.js then') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-17: - " @@ -384,7 +384,7 @@ describe('inlineTextToMarkChunks', () => { it('claims a www. autolink as one chunk, not a nested bare domain', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'www.example.com') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-15: mdLinkText(href=https://www.example.com) " @@ -393,7 +393,7 @@ describe('inlineTextToMarkChunks', () => { it('does not bare-autolink the label of an explicit link', () => { const chunks = inlineTextToMarkChunks(markBuilders, '[google.com](http://x)') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdLinkText(href=http://x) + mdMark 1-11: mdLinkText(href=http://x) @@ -406,7 +406,7 @@ describe('inlineTextToMarkChunks', () => { it('does not bare-autolink inside inline code', () => { const chunks = inlineTextToMarkChunks(markBuilders, '`see google.com`') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdCode + mdMark 1-15: mdCode @@ -417,7 +417,7 @@ describe('inlineTextToMarkChunks', () => { it('does not bare-autolink a domain after an @ (it is an email)', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'mail a@google.com here') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-5: - 5-17: mdLinkText(href=mailto:a@google.com) @@ -428,7 +428,7 @@ describe('inlineTextToMarkChunks', () => { it('nested emphasis inside strong (***foo***)', () => { const chunks = inlineTextToMarkChunks(markBuilders, '***foo***') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdEm + mdMark 1-3: mdEm + mdMark + mdStrong @@ -441,7 +441,7 @@ describe('inlineTextToMarkChunks', () => { it('adjacent emphasis and strong', () => { const chunks = inlineTextToMarkChunks(markBuilders, '*a***b**') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdEm + mdMark 1-2: mdEm @@ -455,7 +455,7 @@ describe('inlineTextToMarkChunks', () => { it('emphasis at start and end of text', () => { const chunks = inlineTextToMarkChunks(markBuilders, '*a* mid *b*') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdEm + mdMark 1-2: mdEm @@ -470,7 +470,7 @@ describe('inlineTextToMarkChunks', () => { it('entire content is emphasized', () => { const chunks = inlineTextToMarkChunks(markBuilders, '*all*') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdEm + mdMark 1-4: mdEm @@ -485,7 +485,7 @@ describe('inlineTextToMarkChunks', () => { it('escape characters produce no marks (visible literal text)', () => { const chunks = inlineTextToMarkChunks(markBuilders, String.raw`\*not\*`) - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-7: - " @@ -494,7 +494,7 @@ describe('inlineTextToMarkChunks', () => { it('hard break produces no mark', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a \nb') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-5: - " @@ -503,7 +503,7 @@ describe('inlineTextToMarkChunks', () => { it('tag yields a single mdTag chunk covering the # too', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a #meow b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-7: mdTag @@ -514,7 +514,7 @@ describe('inlineTextToMarkChunks', () => { it('two tags', () => { const chunks = inlineTextToMarkChunks(markBuilders, '#a #b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: mdTag 2-3: - @@ -525,7 +525,7 @@ describe('inlineTextToMarkChunks', () => { it('tag inside emphasis', () => { const chunks = inlineTextToMarkChunks(markBuilders, '*x #tag y*') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdEm + mdMark 1-3: mdEm @@ -538,7 +538,7 @@ describe('inlineTextToMarkChunks', () => { it('tag inside a link label', () => { const chunks = inlineTextToMarkChunks(markBuilders, '[see #tag](http://x)') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdLinkText(href=http://x) + mdMark 1-5: mdLinkText(href=http://x) @@ -552,7 +552,7 @@ describe('inlineTextToMarkChunks', () => { it('heading-like text produces no tag', () => { const chunks = inlineTextToMarkChunks(markBuilders, '# heading text') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-14: - " @@ -561,7 +561,7 @@ describe('inlineTextToMarkChunks', () => { it('all-digit tag produces no mark', () => { const chunks = inlineTextToMarkChunks(markBuilders, "we're #1") - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-8: - " @@ -570,7 +570,7 @@ describe('inlineTextToMarkChunks', () => { it('wikilink yields mdMark brackets around an mdWikilinkSource target', () => { const chunks = inlineTextToMarkChunks(markBuilders, 'a [[note]] b') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: - 2-4: mdMark + mdWikilinkSource(target=note) @@ -584,7 +584,7 @@ describe('inlineTextToMarkChunks', () => { it('adjacent wikilinks stay distinct via their target attribute', () => { const chunks = inlineTextToMarkChunks(markBuilders, '[[a]][[b]]') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: mdMark + mdWikilinkSource(target=a) 2-3: mdWikilinkSource(target=a) @@ -600,7 +600,7 @@ describe('inlineTextToMarkChunks', () => { it('wikilink inside emphasis', () => { const chunks = inlineTextToMarkChunks(markBuilders, '*x [[n]] y*') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdEm + mdMark 1-3: mdEm @@ -616,7 +616,7 @@ describe('inlineTextToMarkChunks', () => { it('wikilink inside a link label', () => { const chunks = inlineTextToMarkChunks(markBuilders, '[see [[x]]](http://y)') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: mdLinkText(href=http://y) + mdMark 1-5: mdLinkText(href=http://y) @@ -633,7 +633,7 @@ describe('inlineTextToMarkChunks', () => { it('no mdTag inside a wikilink target', () => { const chunks = inlineTextToMarkChunks(markBuilders, '[[note #tag]]') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-2: mdMark + mdWikilinkSource(target=note #tag) 2-11: mdWikilinkSource(target=note #tag) @@ -647,7 +647,7 @@ describe('inlineTextToMarkChunks', () => { // No mdWikilinkSource anywhere; the inner `[a]` becomes a shortcut // reference link (pre-existing lezer behavior). const chunks = inlineTextToMarkChunks(markBuilders, '[[a]') - expect(foramtMarkChunks(chunks)).toMatchInlineSnapshot(` + expect(formatMarkChunks(chunks)).toMatchInlineSnapshot(` " 0-1: - 1-2: mdMark From 578d3f0c673012eaa18bfcca6452a149d69117dc Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:52:09 +1000 Subject: [PATCH 19/20] refactor(core): dedupe the mark registration-order comment --- packages/core/src/extensions/inline-marks.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/extensions/inline-marks.ts b/packages/core/src/extensions/inline-marks.ts index 87453cf..738c4e9 100644 --- a/packages/core/src/extensions/inline-marks.ts +++ b/packages/core/src/extensions/inline-marks.ts @@ -175,8 +175,9 @@ export interface MdWikilinkViewAttrs { } export function defineInlineMarks() { - // The last mark registered here gets the lowest rank and becomes the outermost DOM wrapper. - + // The last mark registered gets the lowest rank and becomes the outermost DOM + // wrapper, so the wikilink/image marks go last: the view mark (mdWikilinkView / + // mdImageView) wraps the source, which wraps the syntax marks. return union( defineMdMark(), defineMdEm(), @@ -187,9 +188,6 @@ export function defineInlineMarks() { defineMdDel(), defineMdTag(), - // The wikilink/image marks are registered last so the view mark - // (mdWikilinkView / mdImageView) wraps the source, which wraps the syntax - // marks. defineMdWikilinkSource(), defineMdWikilinkView(), defineMdImageSource(), From 49f04ee839a3abda91f06862440701d3d1b2ee91 Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 19 Jun 2026 02:52:09 +1000 Subject: [PATCH 20/20] refactor(core): drop needless unicode flag in parseWikilink --- packages/core/src/extensions/wikilink.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/extensions/wikilink.ts b/packages/core/src/extensions/wikilink.ts index 69beab2..0dbc0c0 100644 --- a/packages/core/src/extensions/wikilink.ts +++ b/packages/core/src/extensions/wikilink.ts @@ -11,7 +11,7 @@ export interface ParsedWikilink { /** Splits `[[target]]`/`[[target|alias]]` into its target and display label (the alias, or empty). */ export function parseWikilink(text: string): ParsedWikilink { - const inner = text.replace(/^\[\[/u, '').replace(/\]\]$/u, '') + const inner = text.replace(/^\[\[/, '').replace(/\]\]$/, '') const pipe = inner.indexOf('|') if (pipe < 0) return { target: inner.trim(), display: '' } return { target: inner.slice(0, pipe).trim(), display: inner.slice(pipe + 1).trim() }