feat(web): add code block and strikethrough to formatting toolbar

Reorganize the toolbar into inline marks (bold, italic, strikethrough,
inline code) and blocks (lists, fenced code block), with the Type glyph
for the paragraph/heading picker. Marks derive from the shared MARKS
table; code block toggling reuses the same selection-probe lookup so
toggle-off always agrees with the button's active state. A fresh empty
strikethrough pair (~~~~) parses as a tilde code fence, so empty-pair
removal and code-block detection recognize doubled mark tokens.
This commit is contained in:
johnnyjoygh
2026-07-07 22:08:33 +08:00
parent 475b04f765
commit e3c231fcac
6 changed files with 167 additions and 60 deletions
@@ -1,4 +1,5 @@
import { syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import {
type ActiveFormatState,
@@ -10,17 +11,19 @@ import {
import type { FormattingController } from "../types/editorController";
import { leadingWhitespace, selectedLineNumbers } from "./listIndent";
type MarkCommand = "bold" | "italic" | "code";
type MarkCommand = "bold" | "italic" | "strikethrough" | "code";
type ListCommand = "bulletList" | "orderedList" | "taskList";
// 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`).
// StrongEmphasis/Emphasis use `EmphasisMark`, InlineCode uses `CodeMark`, and
// GFM strikethrough uses `Strikethrough`/`StrikethroughMark`).
// 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" },
strikethrough: { token: "~~", wrapper: "Strikethrough", delimiter: "StrikethroughMark" },
code: { token: "`", wrapper: "InlineCode", delimiter: "CodeMark" },
};
const MARK_COMMANDS = Object.keys(MARKS) as MarkCommand[];
@@ -30,6 +33,17 @@ const WRAPPER_TO_MARK: Record<string, MarkCommand> = Object.fromEntries(MARK_COM
// 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));
// Doubled mark tokens (`****`, `~~~~`, …): what a freshly inserted empty pair
// looks like. Markdown parses some of them as entirely different constructs
// (`~~~~` is a bare tilde code fence), so consumers that would otherwise
// believe that construct check here first.
const EMPTY_MARK_PAIRS = new Set(MARK_COMMANDS.map((c) => MARKS[c].token + MARKS[c].token));
const MAX_EMPTY_PAIR_LENGTH = Math.max(...MARK_COMMANDS.map((c) => 2 * MARKS[c].token.length));
/** Whether [from, to) is exactly some mark's empty delimiter pair. */
function isEmptyMarkPair(state: EditorState, from: number, to: number): boolean {
return to - from <= MAX_EMPTY_PAIR_LENGTH && EMPTY_MARK_PAIRS.has(state.sliceDoc(from, to));
}
const LIST_MARKERS: Record<ListCommand, string> = { bulletList: "- ", orderedList: "1. ", taskList: "- [ ] " };
@@ -98,16 +112,25 @@ 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];
/**
* Delimiter child ranges of the nearest `wrapper` ancestor at the selection,
* or null when the selection doesn't sit in one (or it has fewer than
* `minMarks` delimiters). Shared by every toggle-off path so they all agree.
*
* The head probe mirrors getActiveFormats (resolve side -1) so stripping
* fires exactly when the toolbar shows the command 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 wrapper regardless of selection direction.
*/
function findWrappedDelimiters(
view: EditorView,
wrapper: string,
delimiter: string,
minMarks: number,
): { from: number; to: number }[] | null {
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]]
@@ -120,21 +143,21 @@ function findMarkDelimiters(view: EditorView, command: MarkCommand): { from: num
for (const n of ancestors(tree, pos, side)) {
if (n.name !== wrapper) continue;
const marks = childRanges(n, delimiter);
if (marks.length >= 2) return marks;
if (marks.length >= minMarks) return marks;
}
}
return null;
}
/**
* 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.
* Toggle an inline mark (bold/italic/strikethrough/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 `` ` ``) automatically.
*/
function toggleMark(view: EditorView, command: MarkCommand) {
const { token } = MARKS[command];
const marks = findMarkDelimiters(view, command);
const { token, wrapper, delimiter } = MARKS[command];
const marks = findWrappedDelimiters(view, wrapper, delimiter, 2);
if (marks) {
const opening = marks[0];
const closing = marks[marks.length - 1];
@@ -147,11 +170,10 @@ function toggleMark(view: EditorView, command: MarkCommand) {
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.
// Markdown never parses an empty mark as that mark (bare `****` is a
// horizontal rule, `~~~~` a tilde code fence, `` `` `` plain text), so the
// tree probe above can't see it — check the text instead. Without this,
// re-clicking the button keeps nesting new pairs.
const { from, to } = view.state.selection.main;
if (
from === to &&
@@ -159,13 +181,24 @@ function toggleMark(view: EditorView, command: MarkCommand) {
to + token.length <= view.state.doc.length &&
view.state.sliceDoc(from - token.length, to + token.length) === token + token
) {
const delFrom = from - token.length;
const delTo = to + token.length;
// Deleting is only unsafe when the adjacent tokens belong to parsed markup
// reaching beyond the pair itself — e.g. an italic click between the `*`s
// of a bold delimiter would destroy that bold. A construct contained
// entirely in the deletion range (the `~~~~` the parser reads as an empty
// tilde fence) is just this empty pair wearing another node name.
const tree = syntaxTree(view.state);
const inRealDelimiter = DELIMITER_NODES.has(tree.resolve(from, -1).name) || DELIMITER_NODES.has(tree.resolve(to, 1).name);
if (!inRealDelimiter) {
const blocking = (n: TreeNode) => {
if (!DELIMITER_NODES.has(n.name)) return false;
const construct = n.parent ?? n;
return construct.from < delFrom || construct.to > delTo;
};
if (!blocking(tree.resolve(from, -1)) && !blocking(tree.resolve(to, 1))) {
view.dispatch({
changes: [
{ from: from - token.length, to: from, insert: "" },
{ from: to, to: to + token.length, insert: "" },
{ from: delFrom, to: from, insert: "" },
{ from: to, to: delTo, insert: "" },
],
});
return;
@@ -174,6 +207,50 @@ function toggleMark(view: EditorView, command: MarkCommand) {
wrapSelection(view, token);
}
/**
* Toggle a fenced code block. When the selection sits inside one, remove its
* fence lines (keeping the content); otherwise wrap the selected lines in a
* new ``` fence. Unclosed blocks (opening fence only) lose just that fence.
*/
function toggleCodeBlock(view: EditorView) {
const { state } = view;
const { from, to } = state.selection.main;
const marks = findWrappedDelimiters(view, "FencedCode", "CodeMark", 1);
if (marks) {
const openLine = state.doc.lineAt(marks[0].from);
// Delete each fence line together with its trailing newline. When the
// closing fence is the document's last line there is no trailing newline
// to take, so eat the preceding one instead — unless that would overlap
// the opening deletion (empty block at end of document).
const openEnd = Math.min(openLine.to + 1, state.doc.length);
const specs = [{ from: openLine.from, to: openEnd, insert: "" }];
if (marks.length >= 2) {
const closeLine = state.doc.lineAt(marks[marks.length - 1].from);
const closeIsLastLine = closeLine.to === state.doc.length;
const closeTo = closeIsLastLine ? closeLine.to : closeLine.to + 1;
const closeFrom = closeIsLastLine && closeLine.from - 1 >= openEnd ? closeLine.from - 1 : closeLine.from;
specs.push({ from: closeFrom, to: closeTo, insert: "" });
}
const changes = state.changes(specs);
view.dispatch({ changes, selection: state.selection.map(changes) });
return;
}
const lineNumbers = selectedLineNumbers(view);
const first = state.doc.line(lineNumbers[0]);
const last = state.doc.line(lineNumbers[lineNumbers.length - 1]);
const fence = "```";
// Both selection ends sit within [first.from, last.to], so they shift by
// exactly the opening `\`\`\`\n` — keeping the selection on the content (and
// dropping a lone cursor inside the new empty block).
view.dispatch({
changes: [
{ from: first.from, insert: `${fence}\n` },
{ from: last.to, insert: `\n${fence}` },
],
selection: { anchor: from + fence.length + 1, head: to + fence.length + 1 },
});
}
/**
* 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
@@ -184,9 +261,7 @@ function toggleMark(view: EditorView, command: MarkCommand) {
*/
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 lines = selectedLineNumbers(view).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));
@@ -245,6 +320,7 @@ export function createFormattingController(view: EditorView, listeners: Set<() =
return {
run(command: EditorCommandId, ctx?: EditorCommandContext) {
if (isMarkCommand(command)) return toggleMark(view, command);
if (command === "codeBlock") return toggleCodeBlock(view);
if (command === "bulletList" || command === "orderedList" || command === "taskList") {
return toggleListLine(view, command);
}
@@ -272,6 +348,10 @@ export function createFormattingController(view: EditorView, listeners: Set<() =
const mark = WRAPPER_TO_MARK[n.name];
if (mark) active[mark] = true;
else if (n.name === "Link") active.link = true;
// The isEmptyMarkPair guard: a fresh empty strikethrough pair
// (`~~|~~`) parses as a bare tilde code fence — don't light the
// code-block button while the cursor sits in one.
else if (n.name === "FencedCode" && !isEmptyMarkPair(view.state, n.from, n.to)) active.codeBlock = true;
}
// Line modes (lists, headings) come from the same line inspection the
// toggles use, keeping highlight and toggle behavior in lockstep.
@@ -11,7 +11,7 @@ const LIST_PREFIX = /^\s*(?:[-*+]|\d+[.)])\s+/;
export const leadingWhitespace = (text: string): number => text.length - text.trimStart().length;
/** Unique line numbers covered by the selection (also used by formatting.ts). */
/** Unique line numbers covered by the selection, ascending (also used by formatting.ts). */
export function selectedLineNumbers(view: EditorView): number[] {
const { doc, selection } = view.state;
const nums = new Set<number>();
@@ -21,7 +21,7 @@ export function selectedLineNumbers(view: EditorView): number[] {
nums.add(n);
}
}
return [...nums];
return [...nums].sort((a, b) => a - b);
}
/** Preceding lines, nearest first, stopping at the first blank line (list end). */
@@ -1,4 +1,4 @@
import { Heading1Icon, Heading2Icon, Heading3Icon, type LucideIcon, Minimize2Icon, MoreHorizontalIcon, PilcrowIcon } from "lucide-react";
import { Heading1Icon, Heading2Icon, Heading3Icon, type LucideIcon, Minimize2Icon, MoreHorizontalIcon, TypeIcon } from "lucide-react";
import { type ComponentPropsWithoutRef, forwardRef, type MouseEventHandler, type RefObject, useRef } from "react";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -23,7 +23,7 @@ interface FormattingToolbarProps {
}
const MARK_COMMANDS = EDITOR_COMMANDS.filter((command) => command.group === "mark");
const LIST_COMMANDS = EDITOR_COMMANDS.filter((command) => command.group === "list");
const BLOCK_COMMANDS = EDITOR_COMMANDS.filter((command) => command.group === "block");
// Paragraph + headings render as a single icon dropdown (a closed set); the
// trigger glyph reflects the current block level.
const HEADING_COMMANDS = EDITOR_COMMANDS.filter((command) => command.group === "heading");
@@ -53,12 +53,12 @@ const SEGMENT_ACTIVE = "bg-accent text-accent-foreground";
const preventFocusSteal: MouseEventHandler<HTMLButtonElement> = (event) => event.preventDefault();
/**
* Formatting toolbar: a lean inline row of the heading picker plus mark/list
* Formatting toolbar: a lean inline row of the heading picker plus mark/block
* controls, every one derived from the shared command catalog
* (formatting/commands.ts), so adding a verb there surfaces it here automatically.
* Groups are separated by thin vertical dividers. Responsive: below
* COMPACT_TOOLBAR_WIDTH the list controls fold into a "more" menu while marks stay
* inline. In focus mode an exit button is pushed to the far edge.
* COMPACT_TOOLBAR_WIDTH the block controls fold into a "more" menu while marks
* stay inline. In focus mode an exit button is pushed to the far edge.
*/
export function FormattingToolbar({ controllerRef, onExit, className }: FormattingToolbarProps) {
const t = useTranslate();
@@ -84,11 +84,11 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
onClick: () => run(command.id),
});
// Pilcrow for paragraph, else the matching Hn glyph. Deeper levels (H4H6)
// aren't toolbar-addressable and report as null, i.e. the pilcrow.
const HeadingGlyph = active.headingLevel === null ? PilcrowIcon : HEADING_LEVEL_ICONS[active.headingLevel];
// Type glyph for paragraph, else the matching Hn glyph. Deeper levels (H4H6)
// aren't toolbar-addressable and report as null, i.e. the Type glyph.
const HeadingGlyph = active.headingLevel === null ? TypeIcon : HEADING_LEVEL_ICONS[active.headingLevel];
const markButtons = MARK_COMMANDS.map(toButton);
const listButtons = LIST_COMMANDS.map(toButton);
const blockButtons = BLOCK_COMMANDS.map(toButton);
return (
<div
@@ -124,7 +124,7 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
<SegmentButton Icon={MoreHorizontalIcon} label={t("editor.format.more")} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onCloseAutoFocus={returnFocusToEditor}>
{listButtons.map((button) => (
{blockButtons.map((button) => (
<DropdownMenuItem key={button.label} onClick={button.onClick}>
{button.label}
</DropdownMenuItem>
@@ -132,7 +132,7 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
</DropdownMenuContent>
</DropdownMenu>
) : (
listButtons.map((button) => <SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />)
blockButtons.map((button) => <SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />)
)}
{onExit && (
@@ -147,7 +147,7 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
);
}
// Thin vertical rule between command groups (heading · marks · lists).
// Thin vertical rule between command groups (heading · marks · blocks).
function Divider() {
return <span aria-hidden="true" className="w-px h-5 bg-border mx-1.5 shrink-0" />;
}
@@ -1,4 +1,15 @@
import { BoldIcon, CodeIcon, ItalicIcon, LinkIcon, ListIcon, ListOrderedIcon, ListTodoIcon, type LucideIcon } from "lucide-react";
import {
BoldIcon,
CodeIcon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
ListTodoIcon,
type LucideIcon,
SquareCodeIcon,
StrikethroughIcon,
} from "lucide-react";
import type { Translations } from "@/utils/i18n";
/**
@@ -23,7 +34,9 @@ export function toToolbarHeadingLevel(level: number): ToolbarHeadingLevel | null
export type EditorCommandId =
| "bold"
| "italic"
| "strikethrough"
| "code"
| "codeBlock"
| "bulletList"
| "orderedList"
| "taskList"
@@ -37,7 +50,9 @@ export type EditorCommandId =
export interface ActiveFormatState {
bold: boolean;
italic: boolean;
strikethrough: boolean;
code: boolean;
codeBlock: boolean;
bulletList: boolean;
orderedList: boolean;
taskList: boolean;
@@ -48,7 +63,9 @@ export interface ActiveFormatState {
export const EMPTY_ACTIVE_FORMATS: ActiveFormatState = {
bold: false,
italic: false,
strikethrough: false,
code: false,
codeBlock: false,
bulletList: false,
orderedList: false,
taskList: false,
@@ -61,8 +78,9 @@ export interface EditorCommandContext {
url?: string;
}
/** Toolbar grouping — the toolbar builds each group by filtering on this. */
export type EditorCommandGroup = "mark" | "list" | "heading" | "link";
/** Toolbar grouping — the toolbar builds each group by filtering on this.
* `mark` = inline formatting, `block` = line/block-level (lists, code block). */
export type EditorCommandGroup = "mark" | "block" | "heading" | "link";
export interface EditorCommand {
id: EditorCommandId;
@@ -86,6 +104,12 @@ export const EDITOR_COMMANDS: EditorCommand[] = [
icon: ItalicIcon,
group: "mark",
},
{
id: "strikethrough",
labelKey: "editor.format.strikethrough",
icon: StrikethroughIcon,
group: "mark",
},
{
id: "code",
labelKey: "editor.format.code",
@@ -96,19 +120,25 @@ export const EDITOR_COMMANDS: EditorCommand[] = [
id: "bulletList",
labelKey: "editor.format.bullet-list",
icon: ListIcon,
group: "list",
group: "block",
},
{
id: "orderedList",
labelKey: "editor.format.ordered-list",
icon: ListOrderedIcon,
group: "list",
group: "block",
},
{
id: "taskList",
labelKey: "editor.format.task-list",
icon: ListTodoIcon,
group: "list",
group: "block",
},
{
id: "codeBlock",
labelKey: "editor.format.code-block",
icon: SquareCodeIcon,
group: "block",
},
{
id: "paragraph",
@@ -156,7 +186,7 @@ export function isCommandActive(active: ActiveFormatState, id: EditorCommandId):
return active.headingLevel === 2;
case "heading3":
return active.headingLevel === 3;
// bold/italic/code/bulletList/orderedList/taskList/link map 1:1 to the snapshot.
// The remaining ids (marks, blocks, link) map 1:1 to the snapshot.
default:
return active[id];
}
@@ -2,17 +2,12 @@ import { type RefObject, useEffect, useState } from "react";
import { type ActiveFormatState, EMPTY_ACTIVE_FORMATS } from "../formatting/commands";
import type { EditorController } from "../types/editorController";
// Derive the key set from the canonical empty snapshot so a command added to
// ActiveFormatState is compared automatically (all fields are primitives).
const ACTIVE_FORMAT_KEYS = Object.keys(EMPTY_ACTIVE_FORMATS) as (keyof ActiveFormatState)[];
function sameActiveFormats(a: ActiveFormatState, b: ActiveFormatState): boolean {
return (
a.bold === b.bold &&
a.italic === b.italic &&
a.code === b.code &&
a.bulletList === b.bulletList &&
a.orderedList === b.orderedList &&
a.taskList === b.taskList &&
a.link === b.link &&
a.headingLevel === b.headingLevel
);
return ACTIVE_FORMAT_KEYS.every((key) => a[key] === b[key]);
}
/**
+2
View File
@@ -173,7 +173,9 @@
"heading-3": "Heading 3",
"bold": "Bold",
"italic": "Italic",
"strikethrough": "Strikethrough",
"code": "Inline code",
"code-block": "Code block",
"bullet-list": "Bullet list",
"ordered-list": "Numbered list",
"task-list": "To-do list",