From a3dab2f263eeb31c1f13d9d1be8cb1d38a51515d Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Thu, 19 Feb 2026 09:43:20 -0600 Subject: [PATCH 1/5] Add utility functions to find mark ranges at a position Provides getMarkRange (generic) and getLinkRange (link-specific) for finding the contiguous extent of a mark containing a given ResolvedPos. Co-Authored-By: Claude Opus 4.5 --- src/editor/linkUtils.ts | 53 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/editor/linkUtils.ts diff --git a/src/editor/linkUtils.ts b/src/editor/linkUtils.ts new file mode 100644 index 0000000..6e3bf34 --- /dev/null +++ b/src/editor/linkUtils.ts @@ -0,0 +1,53 @@ +import type { Mark, MarkType, ResolvedPos } from "prosemirror-model"; + +/** + * Find the contiguous range of a mark containing the given position. + * Returns null if the position isn't inside a mark of the specified type. + */ +export function getMarkRange( + $pos: ResolvedPos, + markType: MarkType, +): { from: number; to: number; mark: Mark } | null { + let start = $pos.parent.childAfter($pos.parentOffset); + if (!start.node) start = $pos.parent.childBefore($pos.parentOffset); + const node = start.node; + const mark = node?.marks.find((m) => m.type === markType); + if (!mark || !node) return null; + + let startIndex = start.index, + startPos = $pos.start() + start.offset; + let endIndex = startIndex + 1, + endPos = startPos + node.nodeSize; + + while ( + startIndex > 0 && + mark.isInSet($pos.parent.child(startIndex - 1).marks) + ) { + startPos -= $pos.parent.child(--startIndex).nodeSize; + } + while ( + endIndex < $pos.parent.childCount && + mark.isInSet($pos.parent.child(endIndex).marks) + ) { + endPos += $pos.parent.child(endIndex++).nodeSize; + } + return { from: startPos, to: endPos, mark }; +} + +/** + * Find the contiguous range of a link mark containing the given position. + * Returns null if the position isn't inside a link. + */ +export function getLinkRange( + $pos: ResolvedPos, + linkType: MarkType, +): { from: number; to: number; href: string } | null { + const range = getMarkRange($pos, linkType); + return ( + range && { + from: range.from, + to: range.to, + href: range.mark.attrs.href as string, + } + ); +} From 4cc539645646403a4840118fe70eeac1834257d3 Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Thu, 19 Feb 2026 09:53:31 -0600 Subject: [PATCH 2/5] Reorder marks so links nest outermost in DOM Mark rank (and thus DOM nesting) is determined by schema order. New order: link > strikethrough > code > strong > em This ensures links wrap all other formatting, which is better for click handling and screen reader announcements. Co-Authored-By: Claude Opus 4.5 --- src/editor/schema.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/editor/schema.ts b/src/editor/schema.ts index 8b3d5eb..c5cd585 100644 --- a/src/editor/schema.ts +++ b/src/editor/schema.ts @@ -229,10 +229,8 @@ export const schema = new Schema({ }, }, }, + // Mark order determines DOM nesting: first = outermost (lowest rank) marks: { - strong: marks.strong, - em: marks.em, - code: marks.code, link: { ...marks.link, parseDOM: [ @@ -262,5 +260,8 @@ export const schema = new Schema({ return ["s", 0]; }, }, + code: marks.code, + strong: marks.strong, + em: marks.em, }, }); From fd2a88663642e6a1d081c503bf79b3232b910180 Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Thu, 19 Feb 2026 10:21:53 -0600 Subject: [PATCH 3/5] Add linkSpansInRange to find all links overlapping a range Returns full extent of each link span (not clipped to query range). Dedupes by start position to handle links spanning multiple text nodes. Co-Authored-By: Claude Opus 4.5 --- src/editor/linkUtils.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/editor/linkUtils.ts b/src/editor/linkUtils.ts index 6e3bf34..c4a9063 100644 --- a/src/editor/linkUtils.ts +++ b/src/editor/linkUtils.ts @@ -1,4 +1,9 @@ -import type { Mark, MarkType, ResolvedPos } from "prosemirror-model"; +import type { + Mark, + MarkType, + Node as PMNode, + ResolvedPos, +} from "prosemirror-model"; /** * Find the contiguous range of a mark containing the given position. @@ -51,3 +56,28 @@ export function getLinkRange( } ); } + +/** + * Find all distinct link spans overlapping the given range. + * Each span is returned with its full extent (not clipped to the query range). + */ +export function linkSpansInRange( + doc: PMNode, + from: number, + to: number, + linkType: MarkType, +): { from: number; to: number; href: string }[] { + const spans: { from: number; to: number; href: string }[] = []; + const seen = new Set(); + + doc.nodesBetween(from, to, (node, pos) => { + if (!node.isText || !node.marks.some((m) => m.type === linkType)) return; + const range = getLinkRange(doc.resolve(pos), linkType); + if (range && !seen.has(range.from)) { + seen.add(range.from); + spans.push(range); + } + }); + + return spans; +} From 26f9d8490996441ccbf51b2214153e6b9a70a73c Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Thu, 19 Feb 2026 14:51:16 -0600 Subject: [PATCH 4/5] Add unlinkCommand bound to Mod-Shift-K Removes link marks from selection, or from the full link span when cursor is inside a link. Returns false if no links to remove. Co-Authored-By: Claude Opus 4.5 --- src/editor/editor.ts | 2 ++ src/editor/linkUtils.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 91cb43e..504f53b 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -26,6 +26,7 @@ import { isAllowedImageType } from "../storage/image"; import { getImageManager } from "./ImageManager"; import { createImageNodeView } from "./imageNodeView"; import { categorizeImageSrc, type ImageSrcType } from "./imageUtils"; +import { unlinkCommand } from "./linkUtils"; import { createMathDisplayNodeView } from "./mathNodeView"; import { createMathPlugin } from "./mathPlugin"; import { parseHttpUrl, schema } from "./schema"; @@ -149,6 +150,7 @@ const markKeymap = keymap({ "Mod-i": toggleMark(schema.marks.em), "Mod-`": toggleMark(schema.marks.code), "Mod-Shift-`": toggleMark(schema.marks.strikethrough), + "Mod-Shift-k": unlinkCommand, }); // Tab navigation from title to first block (skipping created timestamp) diff --git a/src/editor/linkUtils.ts b/src/editor/linkUtils.ts index c4a9063..00665e5 100644 --- a/src/editor/linkUtils.ts +++ b/src/editor/linkUtils.ts @@ -4,6 +4,7 @@ import type { Node as PMNode, ResolvedPos, } from "prosemirror-model"; +import type { Command } from "prosemirror-state"; /** * Find the contiguous range of a mark containing the given position. @@ -81,3 +82,21 @@ export function linkSpansInRange( return spans; } + +/** + * Remove link marks from selection or the link at cursor. + */ +export const unlinkCommand: Command = (state, dispatch) => { + const { from, to, empty } = state.selection; + const linkType = state.schema.marks.link; + + if (empty) { + const range = getLinkRange(state.doc.resolve(from), linkType); + if (!range) return false; + dispatch?.(state.tr.removeMark(range.from, range.to, linkType)); + return true; + } + if (!state.doc.rangeHasMark(from, to, linkType)) return false; + dispatch?.(state.tr.removeMark(from, to, linkType)); + return true; +}; From 5d56fd4dedb147b14048101858cd3aefc1bf88f2 Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Thu, 19 Feb 2026 14:52:51 -0600 Subject: [PATCH 5/5] Add setLinkHref to apply/replace link marks on a range Removes existing links then adds new link with given href. Returns transaction for chaining. Co-Authored-By: Claude Opus 4.5 --- src/editor/linkUtils.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/editor/linkUtils.ts b/src/editor/linkUtils.ts index 00665e5..f781e45 100644 --- a/src/editor/linkUtils.ts +++ b/src/editor/linkUtils.ts @@ -4,7 +4,7 @@ import type { Node as PMNode, ResolvedPos, } from "prosemirror-model"; -import type { Command } from "prosemirror-state"; +import type { Command, Transaction } from "prosemirror-state"; /** * Find the contiguous range of a mark containing the given position. @@ -100,3 +100,19 @@ export const unlinkCommand: Command = (state, dispatch) => { dispatch?.(state.tr.removeMark(from, to, linkType)); return true; }; + +/** + * Set link href on a range, replacing any existing links. + * Returns the transaction for chaining. + */ +export function setLinkHref( + tr: Transaction, + from: number, + to: number, + href: string, + linkType: MarkType, +): Transaction { + return tr + .removeMark(from, to, linkType) + .addMark(from, to, linkType.create({ href })); +}