fix(editor): make formatting commands toggle and convert correctly

Bold (and italic/code) now remove an empty just-inserted delimiter pair instead of nesting more asterisks, and strip real marks whichever way the selection was made; clicking Link inside a link unwraps it instead of inserting [](); the three list modes convert between each other (bullet on a task line no longer leaves '[ ] ...' behind), toggle off any ordered number, respect indentation, and apply across multi-line selections; list/heading edits keep the cursor in place (task insert on an empty line lands after the marker).

Structurally, highlight and toggle now share one line-mode detector so they cannot disagree; the heading regex is shared with headingDecorations, so indented headings render styled; one MARKS table drives all inline-mark behavior; run("link") is total (URL doubles as label on empty selection, now inserted inline rather than as its own block); the unused getSelectedText contract method and the dead markdown-list-detection.ts util are removed.

Known divergence, not addressed here: markdown-task-actions.ts TASK_LINE_REGEXP accepts ordered task items ('1. [ ]') which the editor's TASK_LINE does not; the viewer toggles such checkboxes but the editor toolbar won't detect them.
pull/6065/head
boojack 5 days ago
parent ba609c5cb8
commit c349c1549e

@ -5,21 +5,89 @@ import {
type EditorCommandContext,
type EditorCommandId,
EMPTY_ACTIVE_FORMATS,
type ToolbarHeadingLevel,
toToolbarHeadingLevel,
} from "../formatting/commands";
import type { FormattingController } from "../types/editorController";
import { leadingWhitespace, selectedLineNumbers } from "./listIndent";
const MARK: Partial<Record<EditorCommandId, string>> = { bold: "**", italic: "*", code: "`" };
const LINE_PREFIX: Partial<Record<EditorCommandId, string>> = { bulletList: "- ", orderedList: "1. ", taskList: "- [ ] " };
type MarkCommand = "bold" | "italic" | "code";
type ListCommand = "bulletList" | "orderedList" | "taskList";
// Enclosing syntax-tree node and the delimiter child node for each inline mark.
// Delimiter names verified empirically against the Lezer markdown parser:
// StrongEmphasis/Emphasis use `EmphasisMark`, InlineCode uses `CodeMark`.
const MARK_NODES: Partial<Record<EditorCommandId, { wrapper: string; delimiter: string }>> = {
bold: { wrapper: "StrongEmphasis", delimiter: "EmphasisMark" },
italic: { wrapper: "Emphasis", delimiter: "EmphasisMark" },
code: { wrapper: "InlineCode", delimiter: "CodeMark" },
// One row per inline mark: the markdown token plus the syntax-tree wrapper and
// delimiter node names (verified empirically against the Lezer markdown parser:
// StrongEmphasis/Emphasis use `EmphasisMark`, InlineCode uses `CodeMark`).
// Dispatch, toggling, the delimiter guard, and active-state detection all
// derive from this table, so adding a mark is one row here + a catalog entry.
const MARKS: Record<MarkCommand, { token: string; wrapper: string; delimiter: string }> = {
bold: { token: "**", wrapper: "StrongEmphasis", delimiter: "EmphasisMark" },
italic: { token: "*", wrapper: "Emphasis", delimiter: "EmphasisMark" },
code: { token: "`", wrapper: "InlineCode", delimiter: "CodeMark" },
};
const MARK_COMMANDS = Object.keys(MARKS) as MarkCommand[];
const isMarkCommand = (command: EditorCommandId): command is MarkCommand => command in MARKS;
const WRAPPER_TO_MARK: Record<string, MarkCommand> = Object.fromEntries(MARK_COMMANDS.map((c) => [MARKS[c].wrapper, c]));
// A cursor inside one of these means the adjacent token characters belong to
// real parsed markup (e.g. between the two `*` of a bold delimiter), not to a
// dangling empty pair.
const DELIMITER_NODES = new Set(MARK_COMMANDS.map((c) => MARKS[c].delimiter));
const LIST_MARKERS: Record<ListCommand, string> = { bulletList: "- ", orderedList: "1. ", taskList: "- [ ] " };
// Line-mode detection shared by the list toggles and getActiveFormats so the
// highlighted state and the toggle-off condition can never disagree. Order
// matters: a task line also matches the bullet pattern. Markers follow
// listIndent.ts: bullets `-*+`, ordered `1.` / `1)`, content starts after the
// marker's trailing whitespace.
const TASK_LINE = /^(\s*)[-*+]\s+\[[ xX]\]\s+/;
const BULLET_LINE = /^(\s*)[-*+]\s+/;
const ORDERED_LINE = /^(\s*)\d+[.)]\s+/;
// ATX heading; CommonMark allows up to three leading spaces and requires
// whitespace after the hashes — a bare `#` or a `#tag` is intentionally NOT a
// heading. Shared with headingDecorations.ts so toolbar state and rendered
// heading styling can't drift.
export const HEADING_LINE = /^ {0,3}(#{1,6})\s+/;
// Region setHeading replaces: an existing heading prefix including its leading
// spaces, or just the leading spaces on a non-heading line (the optional group
// makes the regex always match).
const HEADING_PREFIX = /^ {0,3}(?:#{1,6}\s+)?/;
type Tree = ReturnType<typeof syntaxTree>;
type TreeNode = ReturnType<Tree["resolve"]>;
/** The node at (pos, side) and its ancestors, innermost first. */
function* ancestors(tree: Tree, pos: number, side: -1 | 1): Generator<TreeNode> {
for (let n: TreeNode | null = tree.resolve(pos, side); n; n = n.parent) {
yield n;
}
}
/** Ranges of the direct `name` children of `node`. */
function childRanges(node: TreeNode, name: string): { from: number; to: number }[] {
const ranges: { from: number; to: number }[] = [];
for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.name === name) ranges.push({ from: child.from, to: child.to });
}
return ranges;
}
interface LineListInfo {
mode: ListCommand | null;
/** Length of leading whitespace. */
indent: number;
/** Offset within the line where the item content starts (=== indent when mode is null). */
markerEnd: number;
}
function lineListInfo(text: string): LineListInfo {
const task = TASK_LINE.exec(text);
if (task) return { mode: "taskList", indent: task[1].length, markerEnd: task[0].length };
const bullet = BULLET_LINE.exec(text);
if (bullet) return { mode: "bulletList", indent: bullet[1].length, markerEnd: bullet[0].length };
const ordered = ORDERED_LINE.exec(text);
if (ordered) return { mode: "orderedList", indent: ordered[1].length, markerEnd: ordered[0].length };
const indent = leadingWhitespace(text);
return { mode: null, indent, markerEnd: indent };
}
function wrapSelection(view: EditorView, token: string) {
const { from, to } = view.state.selection.main;
@ -30,104 +98,190 @@ function wrapSelection(view: EditorView, token: string) {
});
}
/** Delimiter child ranges of the mark's wrapper node when the selection sits in one. */
function findMarkDelimiters(view: EditorView, command: MarkCommand): { from: number; to: number }[] | null {
const { wrapper, delimiter } = MARKS[command];
const { from, to, head } = view.state.selection.main;
const tree = syntaxTree(view.state);
// The head probe mirrors getActiveFormats (resolve side -1) so stripping
// fires exactly when the toolbar shows the mark active. With a non-empty
// selection, additionally probe both edges from inside the selection, so a
// selection that includes the delimiters (the whole `**text**`) still
// resolves into the mark regardless of selection direction.
const probes: [number, -1 | 1][] =
from === to
? [[head, -1]]
: [
[head, -1],
[from, 1],
[to, -1],
];
for (const [pos, side] of probes) {
for (const n of ancestors(tree, pos, side)) {
if (n.name !== wrapper) continue;
const marks = childRanges(n, delimiter);
if (marks.length >= 2) return marks;
}
}
return null;
}
/**
* Toggle an inline mark (bold/italic/code). When the selection head already sits
* inside the corresponding mark, strip the surrounding delimiter nodes instead of
* Toggle an inline mark (bold/italic/code). When the selection already sits in
* the corresponding mark, strip the surrounding delimiter nodes instead of
* nesting a new pair. Deleting the actual delimiter child ranges handles the
* differing delimiter lengths (`**` vs `*` vs `` ` ``) automatically.
*/
function toggleMark(view: EditorView, command: EditorCommandId, token: string) {
const nodes = MARK_NODES[command];
if (nodes) {
const head = view.state.selection.main.head;
function toggleMark(view: EditorView, command: MarkCommand) {
const { token } = MARKS[command];
const marks = findMarkDelimiters(view, command);
if (marks) {
const opening = marks[0];
const closing = marks[marks.length - 1];
view.dispatch({
changes: [
{ from: closing.from, to: closing.to, insert: "" },
{ from: opening.from, to: opening.to, insert: "" },
],
});
return;
}
// Empty pair: a cursor sitting between freshly inserted delimiters (`**|**`).
// Markdown never parses empty emphasis (bare `****` is a horizontal rule or
// plain text), so the tree probe above can't see it — check the text instead,
// but only when the adjacent tokens are NOT real parsed delimiters (otherwise
// an italic click between the `*`s of a bold delimiter would destroy it).
// Without this, re-clicking the button keeps nesting new pairs.
const { from, to } = view.state.selection.main;
if (
from === to &&
from >= token.length &&
to + token.length <= view.state.doc.length &&
view.state.sliceDoc(from - token.length, to + token.length) === token + token
) {
const tree = syntaxTree(view.state);
for (let n: ReturnType<typeof tree.resolve> | null = tree.resolve(head, -1); n; n = n.parent) {
if (n.name !== nodes.wrapper) continue;
// Collect the opening and closing delimiter child node ranges.
const marks: { from: number; to: number }[] = [];
for (let child = n.firstChild; child; child = child.nextSibling) {
if (child.name === nodes.delimiter) marks.push({ from: child.from, to: child.to });
}
if (marks.length >= 2) {
const opening = marks[0];
const closing = marks[marks.length - 1];
// Delete the later range first so the earlier offsets stay valid.
view.dispatch({
changes: [
{ from: closing.from, to: closing.to, insert: "" },
{ from: opening.from, to: opening.to, insert: "" },
],
});
return;
}
const inRealDelimiter = DELIMITER_NODES.has(tree.resolve(from, -1).name) || DELIMITER_NODES.has(tree.resolve(to, 1).name);
if (!inRealDelimiter) {
view.dispatch({
changes: [
{ from: from - token.length, to: from, insert: "" },
{ from: to, to: to + token.length, insert: "" },
],
});
return;
}
}
wrapSelection(view, token);
}
function toggleLinePrefix(view: EditorView, prefix: string) {
const line = view.state.doc.lineAt(view.state.selection.main.head);
const has = line.text.startsWith(prefix);
view.dispatch({
changes: has ? { from: line.from, to: line.from + prefix.length, insert: "" } : { from: line.from, insert: prefix },
});
/**
* Toggle/convert the list mode of the selected lines. The three list modes are
* mutually exclusive line states: when every selected line is already in the
* requested mode the markers are removed; otherwise lines are converted to it
* (replacing any other list marker, preserving indentation). With a multi-line
* selection blank lines are left alone; a single selected blank line still gets
* a marker so the "start a list on an empty line" flow works.
*/
function toggleListLine(view: EditorView, command: ListCommand) {
const { state } = view;
const lines = selectedLineNumbers(view)
.sort((a, b) => a - b)
.map((n) => state.doc.line(n));
const nonBlank = lines.filter((line) => line.text.trim() !== "");
const targets = lines.length === 1 || nonBlank.length === 0 ? lines : nonBlank;
const infos = targets.map((line) => lineListInfo(line.text));
const allOn = infos.every((info) => info.mode === command);
const specs: { from: number; to: number; insert: string }[] = [];
for (const [i, line] of targets.entries()) {
const { mode, indent, markerEnd } = infos[i];
if (allOn) {
specs.push({ from: line.from + indent, to: line.from + markerEnd, insert: "" });
} else if (mode !== command) {
// Lines already in the requested mode keep their marker untouched (a
// checked `- [x]` stays checked when the selection is extended).
const marker = command === "orderedList" ? `${i + 1}. ` : LIST_MARKERS[command];
specs.push({ from: line.from + indent, to: line.from + markerEnd, insert: marker });
}
}
if (specs.length === 0) return;
const changes = state.changes(specs);
// Map with assoc 1 so a cursor exactly at the insertion point (empty line)
// lands after the inserted marker instead of staying at the line start.
view.dispatch({ changes, selection: state.selection.map(changes, 1) });
}
function setHeading(view: EditorView, level: number) {
const line = view.state.doc.lineAt(view.state.selection.main.head);
const stripped = line.text.replace(/^#{1,6}\s+/, "");
const insert = level === 0 ? stripped : `${"#".repeat(level)} ${stripped}`;
view.dispatch({ changes: { from: line.from, to: line.to, insert } });
// Edit only the prefix region (not the whole line) so the cursor keeps its
// place in the text instead of being flung to the line start by the mapping.
const existing = HEADING_PREFIX.exec(line.text)?.[0].length ?? 0;
const insert = level === 0 ? "" : `${"#".repeat(level)} `;
const changes = view.state.changes({ from: line.from, to: line.from + existing, insert });
view.dispatch({ changes, selection: view.state.selection.map(changes, 1) });
}
/** Unwrap the link the head sits in to its label text. True when one was found. */
function unwrapLink(view: EditorView): boolean {
const head = view.state.selection.main.head;
const tree = syntaxTree(view.state);
for (const n of ancestors(tree, head, -1)) {
if (n.name !== "Link") continue;
const marks = childRanges(n, "LinkMark");
// marks[0] is `[`, marks[1] is `]` — the label sits between them.
if (marks.length < 2) continue;
const label = view.state.sliceDoc(marks[0].to, marks[1].from);
const anchor = n.from + Math.max(0, Math.min(label.length, head - marks[0].to));
view.dispatch({
changes: { from: n.from, to: n.to, insert: label },
selection: { anchor },
});
return true;
}
return false;
}
export function createFormattingController(view: EditorView, listeners: Set<() => void>): FormattingController {
return {
run(command: EditorCommandId, ctx?: EditorCommandContext) {
const mark = MARK[command];
if (mark) return toggleMark(view, command, mark);
const prefix = LINE_PREFIX[command];
if (prefix) return toggleLinePrefix(view, prefix);
if (isMarkCommand(command)) return toggleMark(view, command);
if (command === "bulletList" || command === "orderedList" || command === "taskList") {
return toggleListLine(view, command);
}
if (command === "heading1") return setHeading(view, 1);
if (command === "heading2") return setHeading(view, 2);
if (command === "heading3") return setHeading(view, 3);
if (command === "paragraph") return setHeading(view, 0);
if (command === "link") {
// Toggle: inside an existing link, unwrap it to its label.
if (unwrapLink(view)) return;
const { from, to } = view.state.selection.main;
const sel = view.state.sliceDoc(from, to);
view.dispatch({ changes: { from, to, insert: `[${sel}](${ctx?.url ?? ""})` } });
const url = ctx?.url ?? "";
// Empty selection: the URL doubles as the label.
const label = view.state.sliceDoc(from, to) || url;
const insert = `[${label}](${url})`;
view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } });
}
},
getActiveFormats(): ActiveFormatState {
const pos = view.state.selection.main.head;
const tree = syntaxTree(view.state);
const active: ActiveFormatState = { ...EMPTY_ACTIVE_FORMATS };
for (let n: ReturnType<typeof tree.resolve> | null = tree.resolve(pos, -1); n; n = n.parent) {
const name = n.name;
if (name === "StrongEmphasis") active.bold = true;
else if (name === "Emphasis") active.italic = true;
else if (name === "InlineCode") active.code = true;
else if (name === "Link") active.link = true;
else if (name === "BulletList") active.bulletList = true;
else if (name === "OrderedList") active.orderedList = true;
else {
const h = /^ATXHeading([1-6])$/.exec(name);
if (h) {
const lvl = Number(h[1]);
if (lvl <= 3) active.headingLevel = lvl as ToolbarHeadingLevel;
}
}
// Inline marks come from the syntax tree around the cursor.
for (const n of ancestors(tree, pos, -1)) {
const mark = WRAPPER_TO_MARK[n.name];
if (mark) active[mark] = true;
else if (n.name === "Link") active.link = true;
}
// Line modes (lists, headings) come from the same line inspection the
// toggles use, keeping highlight and toggle behavior in lockstep.
const line = view.state.doc.lineAt(pos).text;
if (/^\s*- \[[ xX]\] /.test(line)) {
active.taskList = true;
active.bulletList = false;
}
const heading = HEADING_LINE.exec(line);
if (heading) active.headingLevel = toToolbarHeadingLevel(heading[1].length);
const { mode } = lineListInfo(line);
if (mode) active[mode] = true;
return active;
},
getSelectedText() {
const { from, to } = view.state.selection.main;
return view.state.sliceDoc(from, to);
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {

@ -1,11 +1,8 @@
import { RangeSetBuilder } from "@codemirror/state";
import { Decoration, type DecorationSet, EditorView } from "@codemirror/view";
import { HEADING_LINE } from "./formatting";
import { viewportDecorations } from "./viewportDecorations";
// A heading is a line that starts with 1-6 hashes followed by a space or tab.
// A bare "#" or a "#tag" (no space) is intentionally NOT a heading.
const HEADING_RE = /^(#{1,6})[ \t]/;
const lineDecorations = [1, 2, 3, 4, 5, 6].map((level) => Decoration.line({ class: `cm-md-h${level}` }));
function build(view: EditorView): DecorationSet {
@ -15,7 +12,7 @@ function build(view: EditorView): DecorationSet {
const endLine = view.state.doc.lineAt(to).number;
for (let n = startLine; n <= endLine; n++) {
const line = view.state.doc.line(n);
const m = HEADING_RE.exec(line.text);
const m = HEADING_LINE.exec(line.text);
if (m) {
builder.add(line.from, line.from, lineDecorations[m[1].length - 1]);
}

@ -9,13 +9,15 @@ const ORDERED_ITEM = /^(\s*)(\d+)([.)])(\s+)(.*)$/;
// column where the item's content — and therefore a nested child — begins.
const LIST_PREFIX = /^\s*(?:[-*+]|\d+[.)])\s+/;
const leadingWhitespace = (text: string): number => text.length - text.trimStart().length;
export const leadingWhitespace = (text: string): number => text.length - text.trimStart().length;
function selectedLineNumbers(view: EditorView): number[] {
/** Unique line numbers covered by the selection (also used by formatting.ts). */
export function selectedLineNumbers(view: EditorView): number[] {
const { doc, selection } = view.state;
const nums = new Set<number>();
for (const range of selection.ranges) {
for (let n = doc.lineAt(range.from).number; n <= doc.lineAt(range.to).number; n++) {
const last = doc.lineAt(range.to).number;
for (let n = doc.lineAt(range.from).number; n <= last; n++) {
nums.add(n);
}
}

@ -93,14 +93,7 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
} else {
// window.prompt blurs the editor, so refocus below regardless of outcome.
const url = window.prompt(t("editor.format.link-prompt"));
if (url) {
const selected = formatting.getSelectedText();
if (selected) {
formatting.run("link", { url });
} else {
controllerRef.current?.insertMarkdown(`[${url}](${url})`);
}
}
if (url) formatting.run("link", { url });
}
controllerRef.current?.focus();
};

@ -15,6 +15,11 @@ import type { Translations } from "@/utils/i18n";
export type ToolbarHeadingLevel = 1 | 2 | 3;
/** Clamp a raw heading depth to the toolbar's addressable levels (H4H6 → null). */
export function toToolbarHeadingLevel(level: number): ToolbarHeadingLevel | null {
return level === 1 || level === 2 || level === 3 ? level : null;
}
export type EditorCommandId =
| "bold"
| "italic"

@ -38,8 +38,6 @@ export interface FormattingController {
run(command: EditorCommandId, ctx?: EditorCommandContext): void;
/** Snapshot of which marks/blocks are active at the current selection. */
getActiveFormats(): ActiveFormatState;
/** Plain text of the current selection ("" when the selection is empty). */
getSelectedText(): string;
/** Register a listener fired on every transaction/selection change. Returns an unsubscribe. */
subscribe(listener: () => void): () => void;
}

@ -1,67 +0,0 @@
export interface ListItemInfo {
type: "task" | "unordered" | "ordered" | null;
symbol?: string; // For task/unordered lists: "- ", "* ", "+ "
number?: number; // For ordered lists: 1, 2, 3, etc.
indent?: string; // Leading whitespace
}
// Detect the list item type of the last line before cursor
export function detectLastListItem(contentBeforeCursor: string): ListItemInfo {
const lines = contentBeforeCursor.split("\n");
const lastLine = lines[lines.length - 1];
// Extract indentation
const indentMatch = lastLine.match(/^(\s*)/);
const indent = indentMatch ? indentMatch[1] : "";
// Task list: - [ ] or - [x] or - [X]
const taskMatch = lastLine.match(/^(\s*)([-*+])\s+\[([ xX])\]\s+/);
if (taskMatch) {
return {
type: "task",
symbol: taskMatch[2], // -, *, or +
indent,
};
}
// Unordered list: - foo or * foo or + foo
const unorderedMatch = lastLine.match(/^(\s*)([-*+])\s+/);
if (unorderedMatch) {
return {
type: "unordered",
symbol: unorderedMatch[2],
indent,
};
}
// Ordered list: 1. foo or 2) foo
const orderedMatch = lastLine.match(/^(\s*)(\d+)[.)]\s+/);
if (orderedMatch) {
return {
type: "ordered",
number: parseInt(orderedMatch[2]),
indent,
};
}
return {
type: null,
indent,
};
}
// Generate the text to insert when pressing Enter on a list item
export function generateListContinuation(listInfo: ListItemInfo): string {
const indent = listInfo.indent || "";
switch (listInfo.type) {
case "task":
return `${indent}${listInfo.symbol} [ ] `;
case "unordered":
return `${indent}${listInfo.symbol} `;
case "ordered":
return `${indent}${(listInfo.number || 0) + 1}. `;
default:
return indent;
}
}
Loading…
Cancel
Save