8ca7e685cc
- Implemented live link support in the Markdown editor, allowing users to click on links and open them in a new tab. - Added keybinding (Ctrl/⌘ + Enter) to open links under the cursor. - Introduced visual styles for live links to enhance user experience. - Enhanced selection utilities to check for intersection with selections. - Created a custom keymap for Markdown editing, including shortcuts for bold, italic, and fenced code blocks. - Improved code block handling with line numbering and copy buttons for easy access. - Added functionality to hide Markdown delimiters in live preview mode. - Refactored and organized code for better readability and maintainability.
227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
// Comments in English
|
|
import { EditorView } from "@codemirror/view";
|
|
import { EditorSelection, ChangeSet } from "@codemirror/state";
|
|
import { syntaxTree } from "@codemirror/language";
|
|
|
|
// ===== Selection helpers =====
|
|
export function expandToWord(state: EditorView["state"], from: number, to: number) {
|
|
if (from !== to) return { from, to };
|
|
const line = state.doc.lineAt(from);
|
|
const text = line.text;
|
|
const re = /[^\s\[\]\(\)\*_~`]+/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(text))) {
|
|
const s = line.from + m.index;
|
|
const e = line.from + m.index + m[0].length;
|
|
if (s <= from && e >= from) return { from: s, to: e };
|
|
}
|
|
return { from, to };
|
|
}
|
|
|
|
// ===== Inline wrap / unwrap (idempotent) =====
|
|
export function wrapOrUnwrapSegment(state: EditorView["state"], segFrom: number, segTo: number, targetWidth: 1 | 2) {
|
|
type Change = { from: number; to: number; insert: string };
|
|
const stars = "*".repeat(targetWidth);
|
|
const leftFrom = Math.max(0, segFrom - targetWidth);
|
|
const leftOk = segFrom >= targetWidth && state.doc.sliceString(leftFrom, segFrom) === stars;
|
|
const rightTo = Math.min(state.doc.length, segTo + targetWidth);
|
|
const rightOk = state.doc.sliceString(segTo, rightTo) === stars;
|
|
if (leftOk && rightOk) {
|
|
const changes: Change[] = [];
|
|
changes.push({ from: segTo, to: segTo + targetWidth, insert: "" });
|
|
changes.push({ from: segFrom - targetWidth, to: segFrom, insert: "" });
|
|
return changes;
|
|
}
|
|
return [
|
|
{ from: segFrom, to: segFrom, insert: stars },
|
|
{ from: segTo, to: segTo, insert: stars },
|
|
];
|
|
}
|
|
|
|
export function wrapSelectionPerLine(state: EditorView["state"], from: number, to: number, targetWidth: 1 | 2) {
|
|
type Change = { from: number; to: number; insert: string };
|
|
const changes: Change[] = [];
|
|
const first = state.doc.lineAt(from);
|
|
const last = state.doc.lineAt(to);
|
|
for (let ln = first.number; ln <= last.number; ln++) {
|
|
const line = state.doc.line(ln);
|
|
if (ln === last.number && to === line.from) break;
|
|
const segFrom = Math.max(line.from, from);
|
|
const segTo = Math.min(line.to, to);
|
|
if (segFrom < segTo) changes.push(...wrapOrUnwrapSegment(state, segFrom, segTo, targetWidth));
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
// ===== Emphasis syntax tree helpers =====
|
|
function countStarsForward(state: EditorView["state"], pos: number, max = 3) {
|
|
let k = 0; const len = state.doc.length;
|
|
while (k < max && pos + k < len && state.doc.sliceString(pos + k, pos + k + 1) === "*") k++;
|
|
return k;
|
|
}
|
|
function countStarsBackward(state: EditorView["state"], pos: number, max = 3) {
|
|
let k = 0;
|
|
while (k < max && pos - 1 - k >= 0 && state.doc.sliceString(pos - 1 - k, pos - k) === "*") k++;
|
|
return k;
|
|
}
|
|
function findEnclosingEmphasisNode(state: EditorView["state"], from: number, to: number) {
|
|
const tree = syntaxTree(state);
|
|
let best: { from: number; to: number; name: string } | null = null;
|
|
const lo = Math.max(0, Math.min(from, to) - 1);
|
|
const hi = Math.min(state.doc.length, Math.max(from, to) + 1);
|
|
tree.iterate({ from: lo, to: hi, enter(n) {
|
|
if ((n.name === "Emphasis" || n.name === "StrongEmphasis") && n.from <= from && n.to >= to) {
|
|
if (!best || (n.to - n.from) < (best.to - best.from)) best = { from: n.from, to: n.to, name: n.name };
|
|
}
|
|
}});
|
|
return best;
|
|
}
|
|
function getEmphasisDelimiters(state: EditorView["state"], node: { from: number; to: number; name: string }) {
|
|
const openRun = countStarsForward(state, node.from, 3);
|
|
const closeRun = countStarsBackward(state, node.to, 3);
|
|
const width = Math.min(3, Math.min(openRun, closeRun)) as 1 | 2 | 3 | 0;
|
|
if (!width) return null;
|
|
return { openFrom: node.from, openTo: node.from + width, closeFrom: node.to - width, closeTo: node.to, width } as const;
|
|
}
|
|
export function emphasisMarksToReveal(state: EditorView["state"], sel: EditorSelection) {
|
|
const reveal = new Set<string>();
|
|
for (const r of sel.ranges) {
|
|
const head = r.head;
|
|
const node = findEnclosingEmphasisNode(state, head, head);
|
|
if (!node) continue;
|
|
const delims = getEmphasisDelimiters(state, node);
|
|
if (!delims) continue;
|
|
reveal.add(`${delims.openFrom}:${delims.openTo}`);
|
|
reveal.add(`${delims.closeFrom}:${delims.closeTo}`);
|
|
}
|
|
return reveal;
|
|
}
|
|
|
|
// ===== Utilities =====
|
|
function dedupChanges(changes: {from:number; to:number; insert:string}[]) {
|
|
const seen = new Set<string>();
|
|
return changes.filter(c => { const k = `${c.from}:${c.to}:${c.insert}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
|
}
|
|
|
|
// ===== Main toggle API =====
|
|
export function toggleEmphasis(view: EditorView, targetWidth: 1|2) {
|
|
const { state } = view;
|
|
type Change = { from:number; to:number; insert:string };
|
|
const allChanges: Change[] = [];
|
|
const newRanges: {anchor:number; head:number}[] = [];
|
|
const hasAnySelection = state.selection.ranges.some(r => r.from !== r.to);
|
|
|
|
for (const r of state.selection.ranges) {
|
|
const from = r.from, to = r.to;
|
|
if (from !== to) {
|
|
allChanges.push(...wrapSelectionPerLine(state, from, to, targetWidth));
|
|
newRanges.push({ anchor: from, head: to });
|
|
continue;
|
|
}
|
|
if (hasAnySelection) { newRanges.push({ anchor: from, head: to }); continue; }
|
|
const expanded = expandToWord(state, from, to);
|
|
if (expanded.from !== expanded.to) {
|
|
allChanges.push(...wrapOrUnwrapSegment(state, expanded.from, expanded.to, targetWidth));
|
|
newRanges.push({ anchor: from, head: from });
|
|
} else {
|
|
const stars = "*".repeat(targetWidth*2);
|
|
allChanges.push({ from, to: from, insert: stars });
|
|
allChanges.push({ from: to, to: to, insert: stars });
|
|
newRanges.push({ anchor: from, head: from });
|
|
}
|
|
}
|
|
|
|
const changes = dedupChanges(allChanges).sort((a,b) => a.from - b.from || a.to - b.to);
|
|
if (!changes.length) return false;
|
|
const changeSet = ChangeSet.of(changes, state.doc.length);
|
|
const mappedSel = EditorSelection.create(newRanges.map(r => EditorSelection.range(changeSet.mapPos(r.anchor, 1), changeSet.mapPos(r.head, -1))));
|
|
view.dispatch({ changes, selection: mappedSel, userEvent: "input.toggleEmphasis" });
|
|
return true;
|
|
}
|
|
|
|
export const toggleBold = (view: EditorView) => toggleEmphasis(view, 2);
|
|
export const toggleItalic = (view: EditorView) => toggleEmphasis(view, 1);
|
|
|
|
// ===== Hide Markdown delimiters (live mode) =====
|
|
import { ViewPlugin, Decoration, DecorationSet } from "@codemirror/view";
|
|
import { RangeSetBuilder } from "@codemirror/state";
|
|
import { syntaxTree as treeFn } from "@codemirror/language";
|
|
import type { EditorSelection as ESel } from "@codemirror/state";
|
|
import { selectionIntersects } from "../utils/selection";
|
|
|
|
export function hideMarkdownDelimiters() {
|
|
return ViewPlugin.fromClass(class {
|
|
decorations: DecorationSet;
|
|
constructor(view: EditorView) { this.decorations = this.build(view); }
|
|
update(update: any) {
|
|
if (update.docChanged || update.selectionSet || update.viewportChanged) this.decorations = this.build(update.view);
|
|
}
|
|
build(view: EditorView) {
|
|
const builder = new RangeSetBuilder<Decoration>();
|
|
const tree = treeFn(view.state);
|
|
const sel = view.state.selection;
|
|
const revealSet = emphasisMarksToReveal(view.state, sel);
|
|
const { from, to } = view.viewport;
|
|
type PendingDeco = { from: number; to: number; deco: Decoration; priority: number };
|
|
const pending: PendingDeco[] = [];
|
|
const docLen = view.state.doc.length;
|
|
|
|
tree.iterate({ from, to, enter(node) {
|
|
if (node.name === "EmphasisMark" || node.name === "StrongMark" || node.name === "StrikethroughMark") {
|
|
const key = `${node.from}:${node.to}`;
|
|
const mustReveal = revealSet.has(key);
|
|
if (!selectionIntersects(sel, node.from, node.to) && !mustReveal) {
|
|
pending.push({ from: node.from, to: node.to, deco: Decoration.replace({}), priority: 0 });
|
|
}
|
|
return;
|
|
}
|
|
if (node.name === "HeaderMark") {
|
|
const line = view.state.doc.lineAt(node.from);
|
|
const cursorInLine = sel.ranges.some((r) => r.head >= line.from && r.head <= line.to);
|
|
if (!cursorInLine) {
|
|
let hideTo = node.to;
|
|
if (line.text[node.to - line.from] === " ") hideTo = node.to + 1;
|
|
const rf = Math.max(0, Math.min(node.from, docLen));
|
|
const rt = Math.max(0, Math.min(hideTo, docLen));
|
|
if (rf < rt) pending.push({ from: rf, to: rt, deco: Decoration.replace({ block: false }), priority: 0 });
|
|
}
|
|
}
|
|
if (node.name === "Link") {
|
|
const raw = view.state.doc.sliceString(node.from, node.to);
|
|
const ob = raw.indexOf("[");
|
|
const cb = raw.indexOf("]", ob + 1);
|
|
const op = raw.indexOf("(", cb + 1);
|
|
const cp = raw.indexOf(")", op + 1);
|
|
const shapeOk = ob !== -1 && cb !== -1 && op !== -1 && cp !== -1 && ob < cb && cb < op && op < cp;
|
|
if (!shapeOk) return;
|
|
const labelFrom = node.from + ob + 1;
|
|
const labelTo = node.from + cb;
|
|
const urlFrom = node.from + op + 1;
|
|
const urlTo = node.from + cp;
|
|
if (!(labelFrom < labelTo && urlFrom <= urlTo && node.from <= labelFrom && urlTo <= node.to)) return;
|
|
const url = view.state.doc.sliceString(urlFrom, urlTo);
|
|
const rf = Math.max(0, Math.min(urlFrom - 1, docLen));
|
|
const rt = Math.max(0, Math.min(urlTo + 1, docLen));
|
|
const selectionHitsLink = selectionIntersects(sel, node.from, node.to);
|
|
if (selectionHitsLink) return;
|
|
if (rf < rt && labelFrom < labelTo) {
|
|
pending.push({ from: rf, to: rt, deco: Decoration.replace({}), priority: 0 });
|
|
const lbOpen = labelFrom - 1;
|
|
const lbClose = labelTo;
|
|
if (lbOpen >= node.from && lbOpen < node.to) pending.push({ from: lbOpen, to: lbOpen + 1, deco: Decoration.replace({}), priority: 0 });
|
|
if (lbClose >= node.from && lbClose < node.to) pending.push({ from: lbClose, to: lbClose + 1, deco: Decoration.replace({}), priority: 0 });
|
|
pending.push({ from: labelFrom, to: labelTo, deco: Decoration.mark({ tagName: "a", attributes: { class: "cm-live-link", href: url, "data-url": url, role: "link", title: url } }), priority: 1 });
|
|
}
|
|
}
|
|
}});
|
|
|
|
pending.sort((a, b) => (a.from - b.from) || (a.priority - b.priority) || (a.to - b.to));
|
|
for (const p of pending) if (p.from < p.to) builder.add(p.from, p.to, p.deco);
|
|
return builder.finish();
|
|
}
|
|
}, { decorations: (v) => v.decorations });
|
|
}
|
|
|
|
// Export fence toggle here so keymap can import from a single place
|
|
export { toggleFenceBlock } from "./fencedCode";
|