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

View file

@ -1,4 +1,5 @@
import { syntaxTree } from "@codemirror/language"; import { syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view"; import type { EditorView } from "@codemirror/view";
import { import {
type ActiveFormatState, type ActiveFormatState,
@ -10,17 +11,19 @@ import {
import type { FormattingController } from "../types/editorController"; import type { FormattingController } from "../types/editorController";
import { leadingWhitespace, selectedLineNumbers } from "./listIndent"; import { leadingWhitespace, selectedLineNumbers } from "./listIndent";
type MarkCommand = "bold" | "italic" | "code"; type MarkCommand = "bold" | "italic" | "strikethrough" | "code";
type ListCommand = "bulletList" | "orderedList" | "taskList"; type ListCommand = "bulletList" | "orderedList" | "taskList";
// One row per inline mark: the markdown token plus the syntax-tree wrapper and // One row per inline mark: the markdown token plus the syntax-tree wrapper and
// delimiter node names (verified empirically against the Lezer markdown parser: // 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 // 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. // 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 }> = { const MARKS: Record<MarkCommand, { token: string; wrapper: string; delimiter: string }> = {
bold: { token: "**", wrapper: "StrongEmphasis", delimiter: "EmphasisMark" }, bold: { token: "**", wrapper: "StrongEmphasis", delimiter: "EmphasisMark" },
italic: { token: "*", wrapper: "Emphasis", delimiter: "EmphasisMark" }, italic: { token: "*", wrapper: "Emphasis", delimiter: "EmphasisMark" },
strikethrough: { token: "~~", wrapper: "Strikethrough", delimiter: "StrikethroughMark" },
code: { token: "`", wrapper: "InlineCode", delimiter: "CodeMark" }, code: { token: "`", wrapper: "InlineCode", delimiter: "CodeMark" },
}; };
const MARK_COMMANDS = Object.keys(MARKS) as MarkCommand[]; 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 // real parsed markup (e.g. between the two `*` of a bold delimiter), not to a
// dangling empty pair. // dangling empty pair.
const DELIMITER_NODES = new Set(MARK_COMMANDS.map((c) => MARKS[c].delimiter)); 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: "- [ ] " }; 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 { * Delimiter child ranges of the nearest `wrapper` ancestor at the selection,
const { wrapper, delimiter } = MARKS[command]; * 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 { from, to, head } = view.state.selection.main;
const tree = syntaxTree(view.state); 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][] = const probes: [number, -1 | 1][] =
from === to from === to
? [[head, -1]] ? [[head, -1]]
@ -120,21 +143,21 @@ function findMarkDelimiters(view: EditorView, command: MarkCommand): { from: num
for (const n of ancestors(tree, pos, side)) { for (const n of ancestors(tree, pos, side)) {
if (n.name !== wrapper) continue; if (n.name !== wrapper) continue;
const marks = childRanges(n, delimiter); const marks = childRanges(n, delimiter);
if (marks.length >= 2) return marks; if (marks.length >= minMarks) return marks;
} }
} }
return null; return null;
} }
/** /**
* Toggle an inline mark (bold/italic/code). When the selection already sits in * Toggle an inline mark (bold/italic/strikethrough/code). When the selection
* the corresponding mark, strip the surrounding delimiter nodes instead of * already sits in the corresponding mark, strip the surrounding delimiter
* nesting a new pair. Deleting the actual delimiter child ranges handles the * nodes instead of nesting a new pair. Deleting the actual delimiter child
* differing delimiter lengths (`**` vs `*` vs `` ` ``) automatically. * ranges handles the differing delimiter lengths (`**` vs `` ` ``) automatically.
*/ */
function toggleMark(view: EditorView, command: MarkCommand) { function toggleMark(view: EditorView, command: MarkCommand) {
const { token } = MARKS[command]; const { token, wrapper, delimiter } = MARKS[command];
const marks = findMarkDelimiters(view, command); const marks = findWrappedDelimiters(view, wrapper, delimiter, 2);
if (marks) { if (marks) {
const opening = marks[0]; const opening = marks[0];
const closing = marks[marks.length - 1]; const closing = marks[marks.length - 1];
@ -147,11 +170,10 @@ function toggleMark(view: EditorView, command: MarkCommand) {
return; return;
} }
// Empty pair: a cursor sitting between freshly inserted delimiters (`**|**`). // Empty pair: a cursor sitting between freshly inserted delimiters (`**|**`).
// Markdown never parses empty emphasis (bare `****` is a horizontal rule or // Markdown never parses an empty mark as that mark (bare `****` is a
// plain text), so the tree probe above can't see it — check the text instead, // horizontal rule, `~~~~` a tilde code fence, `` `` `` plain text), so the
// but only when the adjacent tokens are NOT real parsed delimiters (otherwise // tree probe above can't see it — check the text instead. Without this,
// an italic click between the `*`s of a bold delimiter would destroy it). // re-clicking the button keeps nesting new pairs.
// Without this, re-clicking the button keeps nesting new pairs.
const { from, to } = view.state.selection.main; const { from, to } = view.state.selection.main;
if ( if (
from === to && from === to &&
@ -159,13 +181,24 @@ function toggleMark(view: EditorView, command: MarkCommand) {
to + token.length <= view.state.doc.length && to + token.length <= view.state.doc.length &&
view.state.sliceDoc(from - token.length, to + token.length) === token + token 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 tree = syntaxTree(view.state);
const inRealDelimiter = DELIMITER_NODES.has(tree.resolve(from, -1).name) || DELIMITER_NODES.has(tree.resolve(to, 1).name); const blocking = (n: TreeNode) => {
if (!inRealDelimiter) { 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({ view.dispatch({
changes: [ changes: [
{ from: from - token.length, to: from, insert: "" }, { from: delFrom, to: from, insert: "" },
{ from: to, to: to + token.length, insert: "" }, { from: to, to: delTo, insert: "" },
], ],
}); });
return; return;
@ -174,6 +207,50 @@ function toggleMark(view: EditorView, command: MarkCommand) {
wrapSelection(view, token); 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 * 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 * 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) { function toggleListLine(view: EditorView, command: ListCommand) {
const { state } = view; const { state } = view;
const lines = selectedLineNumbers(view) const lines = selectedLineNumbers(view).map((n) => state.doc.line(n));
.sort((a, b) => a - b)
.map((n) => state.doc.line(n));
const nonBlank = lines.filter((line) => line.text.trim() !== ""); const nonBlank = lines.filter((line) => line.text.trim() !== "");
const targets = lines.length === 1 || nonBlank.length === 0 ? lines : nonBlank; const targets = lines.length === 1 || nonBlank.length === 0 ? lines : nonBlank;
const infos = targets.map((line) => lineListInfo(line.text)); const infos = targets.map((line) => lineListInfo(line.text));
@ -245,6 +320,7 @@ export function createFormattingController(view: EditorView, listeners: Set<() =
return { return {
run(command: EditorCommandId, ctx?: EditorCommandContext) { run(command: EditorCommandId, ctx?: EditorCommandContext) {
if (isMarkCommand(command)) return toggleMark(view, command); if (isMarkCommand(command)) return toggleMark(view, command);
if (command === "codeBlock") return toggleCodeBlock(view);
if (command === "bulletList" || command === "orderedList" || command === "taskList") { if (command === "bulletList" || command === "orderedList" || command === "taskList") {
return toggleListLine(view, command); return toggleListLine(view, command);
} }
@ -272,6 +348,10 @@ export function createFormattingController(view: EditorView, listeners: Set<() =
const mark = WRAPPER_TO_MARK[n.name]; const mark = WRAPPER_TO_MARK[n.name];
if (mark) active[mark] = true; if (mark) active[mark] = true;
else if (n.name === "Link") active.link = 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 // Line modes (lists, headings) come from the same line inspection the
// toggles use, keeping highlight and toggle behavior in lockstep. // toggles use, keeping highlight and toggle behavior in lockstep.

View file

@ -11,7 +11,7 @@ const LIST_PREFIX = /^\s*(?:[-*+]|\d+[.)])\s+/;
export const leadingWhitespace = (text: string): number => text.length - text.trimStart().length; 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[] { export function selectedLineNumbers(view: EditorView): number[] {
const { doc, selection } = view.state; const { doc, selection } = view.state;
const nums = new Set<number>(); const nums = new Set<number>();
@ -21,7 +21,7 @@ export function selectedLineNumbers(view: EditorView): number[] {
nums.add(n); nums.add(n);
} }
} }
return [...nums]; return [...nums].sort((a, b) => a - b);
} }
/** Preceding lines, nearest first, stopping at the first blank line (list end). */ /** Preceding lines, nearest first, stopping at the first blank line (list end). */

View file

@ -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 { type ComponentPropsWithoutRef, forwardRef, type MouseEventHandler, type RefObject, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; 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 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 // Paragraph + headings render as a single icon dropdown (a closed set); the
// trigger glyph reflects the current block level. // trigger glyph reflects the current block level.
const HEADING_COMMANDS = EDITOR_COMMANDS.filter((command) => command.group === "heading"); 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(); 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 * controls, every one derived from the shared command catalog
* (formatting/commands.ts), so adding a verb there surfaces it here automatically. * (formatting/commands.ts), so adding a verb there surfaces it here automatically.
* Groups are separated by thin vertical dividers. Responsive: below * Groups are separated by thin vertical dividers. Responsive: below
* COMPACT_TOOLBAR_WIDTH the list controls fold into a "more" menu while marks stay * COMPACT_TOOLBAR_WIDTH the block controls fold into a "more" menu while marks
* inline. In focus mode an exit button is pushed to the far edge. * stay inline. In focus mode an exit button is pushed to the far edge.
*/ */
export function FormattingToolbar({ controllerRef, onExit, className }: FormattingToolbarProps) { export function FormattingToolbar({ controllerRef, onExit, className }: FormattingToolbarProps) {
const t = useTranslate(); const t = useTranslate();
@ -84,11 +84,11 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
onClick: () => run(command.id), onClick: () => run(command.id),
}); });
// Pilcrow for paragraph, else the matching Hn glyph. Deeper levels (H4–H6) // Type glyph for paragraph, else the matching Hn glyph. Deeper levels (H4–H6)
// aren't toolbar-addressable and report as null, i.e. the pilcrow. // aren't toolbar-addressable and report as null, i.e. the Type glyph.
const HeadingGlyph = active.headingLevel === null ? PilcrowIcon : HEADING_LEVEL_ICONS[active.headingLevel]; const HeadingGlyph = active.headingLevel === null ? TypeIcon : HEADING_LEVEL_ICONS[active.headingLevel];
const markButtons = MARK_COMMANDS.map(toButton); const markButtons = MARK_COMMANDS.map(toButton);
const listButtons = LIST_COMMANDS.map(toButton); const blockButtons = BLOCK_COMMANDS.map(toButton);
return ( return (
<div <div
@ -124,7 +124,7 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
<SegmentButton Icon={MoreHorizontalIcon} label={t("editor.format.more")} /> <SegmentButton Icon={MoreHorizontalIcon} label={t("editor.format.more")} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" onCloseAutoFocus={returnFocusToEditor}> <DropdownMenuContent align="start" onCloseAutoFocus={returnFocusToEditor}>
{listButtons.map((button) => ( {blockButtons.map((button) => (
<DropdownMenuItem key={button.label} onClick={button.onClick}> <DropdownMenuItem key={button.label} onClick={button.onClick}>
{button.label} {button.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -132,7 +132,7 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
listButtons.map((button) => <SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />) blockButtons.map((button) => <SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />)
)} )}
{onExit && ( {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() { function Divider() {
return <span aria-hidden="true" className="w-px h-5 bg-border mx-1.5 shrink-0" />; return <span aria-hidden="true" className="w-px h-5 bg-border mx-1.5 shrink-0" />;
} }

View file

@ -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"; import type { Translations } from "@/utils/i18n";
/** /**
@ -23,7 +34,9 @@ export function toToolbarHeadingLevel(level: number): ToolbarHeadingLevel | null
export type EditorCommandId = export type EditorCommandId =
| "bold" | "bold"
| "italic" | "italic"
| "strikethrough"
| "code" | "code"
| "codeBlock"
| "bulletList" | "bulletList"
| "orderedList" | "orderedList"
| "taskList" | "taskList"
@ -37,7 +50,9 @@ export type EditorCommandId =
export interface ActiveFormatState { export interface ActiveFormatState {
bold: boolean; bold: boolean;
italic: boolean; italic: boolean;
strikethrough: boolean;
code: boolean; code: boolean;
codeBlock: boolean;
bulletList: boolean; bulletList: boolean;
orderedList: boolean; orderedList: boolean;
taskList: boolean; taskList: boolean;
@ -48,7 +63,9 @@ export interface ActiveFormatState {
export const EMPTY_ACTIVE_FORMATS: ActiveFormatState = { export const EMPTY_ACTIVE_FORMATS: ActiveFormatState = {
bold: false, bold: false,
italic: false, italic: false,
strikethrough: false,
code: false, code: false,
codeBlock: false,
bulletList: false, bulletList: false,
orderedList: false, orderedList: false,
taskList: false, taskList: false,
@ -61,8 +78,9 @@ export interface EditorCommandContext {
url?: string; url?: string;
} }
/** Toolbar grouping — the toolbar builds each group by filtering on this. */ /** Toolbar grouping — the toolbar builds each group by filtering on this.
export type EditorCommandGroup = "mark" | "list" | "heading" | "link"; * `mark` = inline formatting, `block` = line/block-level (lists, code block). */
export type EditorCommandGroup = "mark" | "block" | "heading" | "link";
export interface EditorCommand { export interface EditorCommand {
id: EditorCommandId; id: EditorCommandId;
@ -86,6 +104,12 @@ export const EDITOR_COMMANDS: EditorCommand[] = [
icon: ItalicIcon, icon: ItalicIcon,
group: "mark", group: "mark",
}, },
{
id: "strikethrough",
labelKey: "editor.format.strikethrough",
icon: StrikethroughIcon,
group: "mark",
},
{ {
id: "code", id: "code",
labelKey: "editor.format.code", labelKey: "editor.format.code",
@ -96,19 +120,25 @@ export const EDITOR_COMMANDS: EditorCommand[] = [
id: "bulletList", id: "bulletList",
labelKey: "editor.format.bullet-list", labelKey: "editor.format.bullet-list",
icon: ListIcon, icon: ListIcon,
group: "list", group: "block",
}, },
{ {
id: "orderedList", id: "orderedList",
labelKey: "editor.format.ordered-list", labelKey: "editor.format.ordered-list",
icon: ListOrderedIcon, icon: ListOrderedIcon,
group: "list", group: "block",
}, },
{ {
id: "taskList", id: "taskList",
labelKey: "editor.format.task-list", labelKey: "editor.format.task-list",
icon: ListTodoIcon, icon: ListTodoIcon,
group: "list", group: "block",
},
{
id: "codeBlock",
labelKey: "editor.format.code-block",
icon: SquareCodeIcon,
group: "block",
}, },
{ {
id: "paragraph", id: "paragraph",
@ -156,7 +186,7 @@ export function isCommandActive(active: ActiveFormatState, id: EditorCommandId):
return active.headingLevel === 2; return active.headingLevel === 2;
case "heading3": case "heading3":
return active.headingLevel === 3; 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: default:
return active[id]; return active[id];
} }

View file

@ -2,17 +2,12 @@ import { type RefObject, useEffect, useState } from "react";
import { type ActiveFormatState, EMPTY_ACTIVE_FORMATS } from "../formatting/commands"; import { type ActiveFormatState, EMPTY_ACTIVE_FORMATS } from "../formatting/commands";
import type { EditorController } from "../types/editorController"; 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 { function sameActiveFormats(a: ActiveFormatState, b: ActiveFormatState): boolean {
return ( return ACTIVE_FORMAT_KEYS.every((key) => a[key] === b[key]);
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
);
} }
/** /**

View file

@ -173,7 +173,9 @@
"heading-3": "Heading 3", "heading-3": "Heading 3",
"bold": "Bold", "bold": "Bold",
"italic": "Italic", "italic": "Italic",
"strikethrough": "Strikethrough",
"code": "Inline code", "code": "Inline code",
"code-block": "Code block",
"bullet-list": "Bullet list", "bullet-list": "Bullet list",
"ordered-list": "Numbered list", "ordered-list": "Numbered list",
"task-list": "To-do list", "task-list": "To-do list",