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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down
118 changes: 118 additions & 0 deletions src/editor/linkUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type {
Mark,
MarkType,
Node as PMNode,
ResolvedPos,
} from "prosemirror-model";
import type { Command, Transaction } from "prosemirror-state";

/**
* 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,
}
);
}

/**
* 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<number>();

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;
}

/**
* 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;
};

/**
* 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 }));
}
7 changes: 4 additions & 3 deletions src/editor/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -262,5 +260,8 @@ export const schema = new Schema({
return ["s", 0];
},
},
code: marks.code,
strong: marks.strong,
em: marks.em,
},
});