Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f700da8
refactor(core): extract shared atomic mark navigation
ocavue Jun 18, 2026
6706c04
feat(core): render wikilinks as an immutable mark view
ocavue Jun 18, 2026
731f238
Merge remote-tracking branch 'origin/master' into feat/immutable-wiki…
ocavue Jun 18, 2026
b9014e3
Merge remote-tracking branch 'origin/master' into feat/immutable-wiki…
ocavue Jun 18, 2026
ab80e47
fix(website): build tag and wikilink menu items from search results
ocavue Jun 18, 2026
fe01336
build: typecheck the react and website projects in the root build
ocavue Jun 18, 2026
88ebab0
Merge remote-tracking branch 'origin/master' into feat/immutable-wiki…
ocavue Jun 18, 2026
d144b50
Merge branch 'master' into feat/immutable-wikilinks
ocavue Jun 18, 2026
2594c7e
refactor(core): anchor the wikilink view on the whole closing bracket
ocavue Jun 18, 2026
df7303c
test(core): share caret-trace helpers and match the image test style
ocavue Jun 18, 2026
7dd20fc
fix(core): drop the narrating comment in the wikilink mark test
ocavue Jun 18, 2026
d5c4f92
refactor(core): drop test-only parseWikilinkTarget wrapper
ocavue Jun 18, 2026
2210a86
refactor(core): drop unused md-wikilink-view-content class
ocavue Jun 18, 2026
713ad5d
refactor(core): return undefined from atomic selection decorations
ocavue Jun 18, 2026
dac2aa2
refactor(core): drive image and wikilink atomic nav from one extension
ocavue Jun 18, 2026
d5e9a36
refactor(core): consolidate the syntax-hiding and selection-ring CSS
ocavue Jun 18, 2026
f6338c3
refactor(core): anchor the wikilink view on the final char like the i…
ocavue Jun 18, 2026
c669339
refactor(core): move parseWikilink into wikilink.ts
ocavue Jun 18, 2026
deee9cb
Merge branch 'master' into feat/immutable-wikilinks
ocavue Jun 18, 2026
6ed9058
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 18, 2026
a463942
chore: trigger ci
ocavue Jun 18, 2026
6e8c2cb
test(core): cover wikilink label clicks in the browser
ocavue Jun 18, 2026
36c532b
test(core): fix foramtMarkChunks typo
ocavue Jun 18, 2026
578d3f0
refactor(core): dedupe the mark registration-order comment
ocavue Jun 18, 2026
49f04ee
refactor(core): drop needless unicode flag in parseWikilink
ocavue Jun 18, 2026
File filter

Filter by extension

Filter by extension

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

Markdown links (`[text](url)`) render the label as an `<a href>` with the `.md-link` class, colored by `--meowdown-accent`; the `[`, `]`, and `(url)` syntax dims in show mode and hides in hide and focus modes. Wire click handling with `defineLinkClickHandler(({ href, event }) => ...)` (or `@meowdown/react`'s `onLinkClick` prop). A plain click inside a link the caret already sits in just places the caret; `Mod`-click always fires.

Expand Down
193 changes: 193 additions & 0 deletions packages/core/src/extensions/atomic-mark-navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import {
defineKeymap,
definePlugin,
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 { getMarkRangeAt } from './get-mark-range-at.ts'
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 {
for (const name of markNames) {
const range = getMarkRangeAt(state, 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
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)),
)
}
7 changes: 7 additions & 0 deletions packages/core/src/extensions/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ 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'
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(
Expand All @@ -43,6 +45,11 @@ function defineEditorExtensionImpl() {
defineCodeBlockSyntaxHighlight(),
defineInlineMarkPlugin(),
defineInlineToggle(),
defineWikilink(),
defineAtomicMarkNavigation({
markNames: ['mdImageSource', 'mdWikilinkSource'],
selectedClass: 'md-atomic-selected',
}),

// others
defineBaseKeymap(),
Expand Down
161 changes: 0 additions & 161 deletions packages/core/src/extensions/image-navigation.ts

This file was deleted.

Loading
Loading