@ai-matrx/rich-editor 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+ ### ONE formatting toolbar in every editor and every mode (2026-10-07)
5
+
6
+ - `FormatButtons` is now the full standard toolbar: Undo, Redo · Text style (Normal, Heading 1–3) · Bold, Italic, Strikethrough, Inline code · Bulleted list, Numbered list, Checklist, Quote · Link · Code block, Table, Horizontal rule. Tooltips carry the chord. It never wraps: what does not fit folds into its "More" menu. Pressed state follows the editor. `variant="essential"` is the phone set (text style, B, I, list, checklist, link + More).
7
+ - The visual (Tiptap) editor registers as a format target (`registerFormatElement`), so the same buttons drive Write mode; `EditorViewHandle.formatTarget()` answers in Visual too. `editorFormatTarget(editor, onEditLink)` is the shared builder.
8
+ - The command layer gains `paragraph`, `table`, `horizontalRule`, `undo`, `redo` for every engine (text engines write markdown; undo/redo use the engine's own history).
9
+ - `RichEditor` with `chrome="full"` shows the toolbar in Visual and Source (was Source only).
10
+
11
+ Consumer action: hosts that gated `FormatButtons` to plain modes can show it in every editable mode.
12
+
13
+ ## 0.3.3
14
+
15
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
16
+ `git diff npm/rich-editor/v0.3.2..npm/rich-editor/v0.3.3 -- apps/shared/rich-editor`).
17
+ No source changes intended and no consumer action required.
18
+
3
19
  ## 0.3.2
4
20
 
5
21
  Automatic changed-only republish (docs/metadata drift since the last tag — see
@@ -1,5 +1,5 @@
1
1
  import type { SourceEditResult } from "./source-format.js";
2
- export type FormatCommandId = "bold" | "italic" | "strike" | "code" | "link" | "heading1" | "heading2" | "heading3" | "bulletList" | "orderedList" | "taskList" | "quote" | "codeBlock";
2
+ export type FormatCommandId = "bold" | "italic" | "strike" | "code" | "link" | "heading1" | "heading2" | "heading3" | "bulletList" | "orderedList" | "taskList" | "quote" | "codeBlock" | "paragraph" | "table" | "horizontalRule" | "undo" | "redo";
3
3
  export declare const FORMAT_COMMAND_IDS: readonly FormatCommandId[];
4
4
  export declare function isFormatCommandId(id: string): id is FormatCommandId;
5
5
  /** Run one formatting command on markdown text. Returns the minimal change. */
@@ -12,7 +12,12 @@ const FORMAT_COMMAND_IDS = [
12
12
  "orderedList",
13
13
  "taskList",
14
14
  "quote",
15
- "codeBlock"
15
+ "codeBlock",
16
+ "paragraph",
17
+ "table",
18
+ "horizontalRule",
19
+ "undo",
20
+ "redo"
16
21
  ];
17
22
  function isFormatCommandId(id) {
18
23
  return FORMAT_COMMAND_IDS.includes(id);
@@ -379,8 +384,50 @@ function formatMarkdown(text, from, to, command) {
379
384
  return quoteCommand(text, a, b);
380
385
  case "codeBlock":
381
386
  return codeBlockCommand(text, a, b);
387
+ case "paragraph":
388
+ return paragraphCommand(text, a, b);
389
+ case "table":
390
+ return insertBlockAfter(text, a, b, "| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| | | |\n| | | |", 2, 8);
391
+ case "horizontalRule":
392
+ return insertBlockAfter(text, a, b, "---", null, 0);
393
+ case "undo":
394
+ case "redo":
395
+ return { changes: [], anchor: a, head: b };
382
396
  }
383
397
  }
398
+ function paragraphCommand(text, from, to) {
399
+ const changes = [];
400
+ for (const line of linesTouched(text, from, to)) {
401
+ const base = quoteLength(line.text);
402
+ const m = /^( {0,3})(#{1,6}[ \t]+)/.exec(line.text.slice(base));
403
+ if (m) changes.push({ from: line.start + base + m[1].length, to: line.start + base + m[0].length, insert: "" });
404
+ }
405
+ const ordered = sorted(changes);
406
+ const anchor = mapPos(from, ordered, -1);
407
+ return { changes: ordered, anchor, head: from === to ? anchor : mapPos(to, ordered, -1) };
408
+ }
409
+ function insertBlockAfter(text, from, to, block, caretFrom, caretTo) {
410
+ const lines = linesTouched(text, from, to);
411
+ const last = lines[lines.length - 1];
412
+ const lineEmpty = last.text.trim() === "";
413
+ let at = last.end;
414
+ if (!lineEmpty) {
415
+ while (at < text.length) {
416
+ const nextEnd = text.indexOf("\n", at + 1);
417
+ const next = text.slice(at + 1, nextEnd === -1 ? text.length : nextEnd);
418
+ if (next.trim() === "") break;
419
+ at = nextEnd === -1 ? text.length : nextEnd;
420
+ }
421
+ }
422
+ const before = lineEmpty ? last.start === 0 || text.slice(0, last.start).endsWith("\n\n") ? "" : "\n" : "\n\n";
423
+ const rest = text.slice(at);
424
+ const after = rest.startsWith("\n\n") ? "" : rest.startsWith("\n") ? "\n" : "\n\n";
425
+ const insert = `${before}${block}${after}`;
426
+ const blockStart = at + before.length;
427
+ const anchor = caretFrom === null ? at + insert.length : blockStart + caretFrom;
428
+ const head = caretFrom === null ? anchor : blockStart + caretFrom + caretTo;
429
+ return { changes: [{ from: at, to: at, insert }], anchor, head };
430
+ }
384
431
  function isFormatActive(text, from, to, command) {
385
432
  const a = Math.max(0, Math.min(from, to, text.length));
386
433
  const b = Math.min(text.length, Math.max(from, to));
@@ -406,7 +453,15 @@ function isFormatActive(text, from, to, command) {
406
453
  return content.length > 0 && content.every((l) => /^ {0,3}>/.test(l.text));
407
454
  }
408
455
  case "codeBlock":
456
+ case "table":
457
+ case "horizontalRule":
458
+ case "undo":
459
+ case "redo":
409
460
  return false;
461
+ case "paragraph": {
462
+ const content = linesTouched(text, a, b).filter((l) => l.text.trim() !== "");
463
+ return content.length > 0 && content.every((l) => lineKindOf(l.text)?.kind !== "heading");
464
+ }
410
465
  default: {
411
466
  const want = command === "bulletList" ? { kind: "bullet" } : command === "orderedList" ? { kind: "ordered" } : command === "taskList" ? { kind: "task" } : { kind: "heading", level: Number(command.slice(-1)) };
412
467
  const content = linesTouched(text, a, b).filter((l) => l.text.trim() !== "");
@@ -427,7 +482,13 @@ const FORMAT_COMMAND_KEYS = {
427
482
  bulletList: ["Mod-Shift-8"],
428
483
  taskList: ["Mod-Shift-9"],
429
484
  quote: ["Mod-Shift-."],
430
- codeBlock: ["Mod-Alt-c"]
485
+ codeBlock: ["Mod-Alt-c"],
486
+ paragraph: ["Mod-Alt-0"],
487
+ table: [],
488
+ horizontalRule: [],
489
+ // The engine's own history answers ⌘Z / ⌘⇧Z; a host never rebinds them.
490
+ undo: [],
491
+ redo: []
431
492
  };
432
493
  function keyNameOf(event, apple) {
433
494
  const mod = apple ? event.metaKey || event.ctrlKey : event.ctrlKey;
@@ -452,10 +452,11 @@ function RichEditorImpl({
452
452
  ro.observe(row);
453
453
  return () => ro.disconnect();
454
454
  }, [focusMode, chrome]);
455
- const compactTools = isMobile || view === "source" && !readOnly && toolbarRowWidth > 0 && toolbarRowWidth < 980;
456
- const formatButtons = view === "source" && !readOnly ? /* @__PURE__ */ jsxs(Fragment, { children: [
455
+ const editable = view !== "preview" && !readOnly;
456
+ const compactTools = isMobile || editable && toolbarRowWidth > 0 && toolbarRowWidth < 1180;
457
+ const formatButtons = editable ? /* @__PURE__ */ jsxs(Fragment, { children: [
457
458
  /* @__PURE__ */ jsx("span", { className: "mx-1 h-5 w-px shrink-0 bg-border" }),
458
- /* @__PURE__ */ jsx(FormatButtons, { resolve: () => handle.current?.formatTarget?.() ?? null })
459
+ /* @__PURE__ */ jsx(FormatButtons, { className: "flex-1", resolve: () => handle.current?.formatTarget?.() ?? null })
459
460
  ] }) : null;
460
461
  const micButton = mic.available && !readOnly && view !== "preview" ? /* @__PURE__ */ jsx(
461
462
  ToolbarButton,
@@ -1,14 +1,20 @@
1
1
  import * as React from "react";
2
2
  import { type LucideIcon } from "lucide-react";
3
- import type { FormatCommandId } from "../core/markdown-format.js";
3
+ import { type FormatCommandId } from "../core/markdown-format.js";
4
4
  import type { FormatTarget } from "./format-target.js";
5
5
  export interface FormatButtonSpec {
6
6
  command: FormatCommandId;
7
7
  label: string;
8
8
  icon: LucideIcon;
9
9
  }
10
- /** The compact set every plain-text toolbar shows (the rest stay on the selection toolbar and the keys). */
10
+ /** The text styles of the "Text style" menu. */
11
+ export declare const BLOCK_STYLES: readonly FormatButtonSpec[];
12
+ /** The toolbar, in its groups (a divider between each). The text-style menu sits after history. */
13
+ export declare const FORMAT_GROUPS: readonly (readonly FormatButtonSpec[])[];
14
+ /** Every button the toolbar shows, in order (the text-style menu aside). */
11
15
  export declare const FORMAT_BUTTONS: readonly FormatButtonSpec[];
16
+ /** The phone set (Arman, 2026-10-07): text style, B, I, list, checklist, link — the rest under More. */
17
+ export declare const ESSENTIAL_COMMANDS: readonly FormatCommandId[];
12
18
  export interface FormatButtonsProps {
13
19
  /** The editor the buttons act on, read at press time. Null = nothing to format (buttons stay, inert). */
14
20
  resolve: () => FormatTarget | null;
@@ -17,6 +23,8 @@ export interface FormatButtonsProps {
17
23
  className?: string;
18
24
  /** "sm" (28px, the editor toolbar) or "xs" (24px, a dense strip). */
19
25
  size?: "sm" | "xs";
26
+ /** "full" (default): the whole toolbar, folding into More by width. "essential": the phone set + More. */
27
+ variant?: "full" | "essential";
20
28
  }
21
- export declare function FormatButtons({ resolve, disabled, className, size }: FormatButtonsProps): React.ReactElement | null;
29
+ export declare function FormatButtons({ resolve, disabled, className, size, variant }: FormatButtonsProps): React.ReactElement | null;
22
30
  export default FormatButtons;
@@ -1,41 +1,243 @@
1
1
  "use client";
2
- import { jsx } from "react/jsx-runtime";
3
- import { Bold, Code, Heading2, Italic, Link2, List } from "lucide-react";
4
- import { cn } from "@ai-matrx/design-system";
5
- const FORMAT_BUTTONS = [
6
- { command: "bold", label: "Bold (\u2318B)", icon: Bold },
7
- { command: "italic", label: "Italic (\u2318I)", icon: Italic },
8
- { command: "code", label: "Inline code (\u2318E)", icon: Code },
9
- { command: "link", label: "Link (\u2318K)", icon: Link2 },
10
- { command: "heading2", label: "Heading", icon: Heading2 },
11
- { command: "bulletList", label: "Bulleted list (\u2318\u21E78)", icon: List }
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import {
5
+ Bold,
6
+ Check,
7
+ ChevronDown,
8
+ Code,
9
+ Heading1,
10
+ Heading2,
11
+ Heading3,
12
+ Italic,
13
+ Link2,
14
+ List,
15
+ ListChecks,
16
+ ListOrdered,
17
+ Minus,
18
+ MoreHorizontal,
19
+ Pilcrow,
20
+ Quote,
21
+ Redo2,
22
+ SquareCode,
23
+ Strikethrough,
24
+ Table,
25
+ Undo2
26
+ } from "lucide-react";
27
+ import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@ai-matrx/design-system";
28
+ import { FORMAT_COMMAND_KEYS } from "../core/markdown-format.js";
29
+ function chordLabel(key) {
30
+ if (!key) return "";
31
+ return key.split("-").map((part) => part === "Mod" ? "\u2318" : part === "Shift" ? "\u21E7" : part === "Alt" ? "\u2325" : part.toUpperCase()).join("");
32
+ }
33
+ const CHORDS = { undo: "\u2318Z", redo: "\u2318\u21E7Z" };
34
+ function withChord(name, command) {
35
+ const chord = CHORDS[command] ?? chordLabel(FORMAT_COMMAND_KEYS[command][0]);
36
+ return chord ? `${name} (${chord})` : name;
37
+ }
38
+ function spec(command, name, icon) {
39
+ return { command, label: withChord(name, command), icon };
40
+ }
41
+ const BLOCK_STYLES = [
42
+ spec("paragraph", "Normal text", Pilcrow),
43
+ spec("heading1", "Heading 1", Heading1),
44
+ spec("heading2", "Heading 2", Heading2),
45
+ spec("heading3", "Heading 3", Heading3)
12
46
  ];
13
- function FormatButtons({ resolve, disabled = false, className, size = "sm" }) {
47
+ const BLOCK_SHORT = { paragraph: "Normal", heading1: "Heading 1", heading2: "Heading 2", heading3: "Heading 3" };
48
+ const FORMAT_GROUPS = [
49
+ [spec("undo", "Undo", Undo2), spec("redo", "Redo", Redo2)],
50
+ [spec("bold", "Bold", Bold), spec("italic", "Italic", Italic), spec("strike", "Strikethrough", Strikethrough), spec("code", "Inline code", Code)],
51
+ [spec("bulletList", "Bulleted list", List), spec("orderedList", "Numbered list", ListOrdered), spec("taskList", "Checklist", ListChecks), spec("quote", "Quote", Quote)],
52
+ [spec("link", "Link", Link2)],
53
+ [spec("codeBlock", "Code block", SquareCode), spec("table", "Table", Table), spec("horizontalRule", "Horizontal rule", Minus)]
54
+ ];
55
+ const FORMAT_BUTTONS = FORMAT_GROUPS.flat();
56
+ const ESSENTIAL_COMMANDS = ["bold", "italic", "bulletList", "taskList", "link"];
57
+ function slotsOf(variant) {
58
+ if (variant === "essential") {
59
+ return [{ kind: "style" }, ...ESSENTIAL_COMMANDS.map((command) => ({ kind: "button", spec: FORMAT_BUTTONS.find((b) => b.command === command) }))];
60
+ }
61
+ const out = [];
62
+ FORMAT_GROUPS.forEach((group, index) => {
63
+ if (index > 0) out.push({ kind: "divider" });
64
+ out.push(...group.map((b) => ({ kind: "button", spec: b })));
65
+ if (index === 0) out.push({ kind: "divider" }, { kind: "style" });
66
+ });
67
+ return out;
68
+ }
69
+ function usePressedTick(resolve, root) {
70
+ const [tick, setTick] = React.useState(0);
71
+ const resolveRef = React.useRef(resolve);
72
+ resolveRef.current = resolve;
73
+ React.useEffect(() => {
74
+ if (typeof document === "undefined") return void 0;
75
+ let frame = 0;
76
+ let target = null;
77
+ let off;
78
+ const bump = () => {
79
+ if (frame) return;
80
+ frame = requestAnimationFrame(() => {
81
+ frame = 0;
82
+ const next = resolveRef.current();
83
+ if (next !== target) {
84
+ off?.();
85
+ target = next;
86
+ off = next?.subscribe?.(bump);
87
+ }
88
+ setTick((t) => t + 1);
89
+ });
90
+ };
91
+ const events = ["selectionchange", "focusin", "input", "keyup", "mouseup"];
92
+ for (const name of events) document.addEventListener(name, bump);
93
+ bump();
94
+ return () => {
95
+ for (const name of events) document.removeEventListener(name, bump);
96
+ if (frame) cancelAnimationFrame(frame);
97
+ off?.();
98
+ };
99
+ }, [root]);
100
+ return tick;
101
+ }
102
+ function FormatButtons({ resolve, disabled = false, className, size = "sm", variant = "full" }) {
103
+ const rootRef = React.useRef(null);
104
+ const [width, setWidth] = React.useState(0);
105
+ React.useEffect(() => {
106
+ const el = rootRef.current;
107
+ if (!el || typeof ResizeObserver === "undefined") return void 0;
108
+ const ro = new ResizeObserver(() => setWidth(el.getBoundingClientRect().width));
109
+ ro.observe(el);
110
+ return () => ro.disconnect();
111
+ }, []);
112
+ usePressedTick(resolve, rootRef);
14
113
  if (disabled) return null;
15
- return /* @__PURE__ */ jsx("div", { role: "group", "aria-label": "Formatting", "data-format-buttons": "", className: cn("flex shrink-0 items-center gap-0.5", className), children: FORMAT_BUTTONS.map(({ command, label, icon: Icon }) => /* @__PURE__ */ jsx(
16
- "button",
114
+ const target = resolve();
115
+ const isActive = (command) => {
116
+ try {
117
+ return target?.isActive(command) ?? false;
118
+ } catch {
119
+ return false;
120
+ }
121
+ };
122
+ const run = (command) => {
123
+ resolve()?.run(command);
124
+ };
125
+ const button = size === "xs" ? 26 : 30;
126
+ const styleWidth = variant === "essential" ? size === "xs" ? 40 : 44 : 102;
127
+ const widthOf = (slot) => slot.kind === "button" ? button : slot.kind === "style" ? styleWidth : 11;
128
+ const all = slotsOf(variant);
129
+ let shown = all;
130
+ let folded = variant === "essential" ? slotsOf("full").filter((s) => s.kind === "button" && !ESSENTIAL_COMMANDS.includes(s.spec.command)) : [];
131
+ if (width > 0) {
132
+ const total = all.reduce((sum, s) => sum + widthOf(s), 0) + (folded.length ? button : 0);
133
+ if (total > width + 4) {
134
+ const budget = width - button;
135
+ let used = 0;
136
+ let count = 0;
137
+ for (const slot of all) {
138
+ if (used + widthOf(slot) > budget) break;
139
+ used += widthOf(slot);
140
+ count += 1;
141
+ }
142
+ shown = all.slice(0, count);
143
+ folded = [...all.slice(count), ...folded];
144
+ }
145
+ }
146
+ while (shown.length && shown[shown.length - 1].kind === "divider") shown = shown.slice(0, -1);
147
+ const foldedButtons = folded.filter((s) => s.kind === "button");
148
+ const styleFolded = folded.some((s) => s.kind === "style");
149
+ const iconClass = size === "xs" ? "h-3.5 w-3.5" : "h-4 w-4";
150
+ const buttonClass = cn(
151
+ "inline-flex shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground",
152
+ size === "xs" ? "h-6 w-6" : "h-7 w-7"
153
+ );
154
+ const currentStyle = BLOCK_STYLES.find((s) => s.command !== "paragraph" && isActive(s.command)) ?? BLOCK_STYLES[0];
155
+ const keepEditorFocus = (event) => event.preventDefault();
156
+ return /* @__PURE__ */ jsxs(
157
+ "div",
17
158
  {
18
- type: "button",
19
- "aria-label": label,
20
- title: label,
21
- "data-format-command": command,
22
- "data-matrx-button": "",
23
- onMouseDown: (event) => event.preventDefault(),
24
- onClick: () => {
25
- resolve()?.run(command);
26
- },
27
- className: cn(
28
- "inline-flex shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground",
29
- size === "xs" ? "h-6 w-6" : "h-7 w-7"
30
- ),
31
- children: /* @__PURE__ */ jsx(Icon, { className: size === "xs" ? "h-3.5 w-3.5" : "h-4 w-4" })
32
- },
33
- command
34
- )) });
159
+ ref: rootRef,
160
+ role: "toolbar",
161
+ "aria-label": "Formatting",
162
+ "data-format-buttons": "",
163
+ className: cn("flex min-w-0 flex-nowrap items-center gap-0.5 overflow-hidden", className),
164
+ children: [
165
+ shown.map((slot, index) => {
166
+ if (slot.kind === "divider") return /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "mx-1 h-5 w-px shrink-0 bg-border" }, `d${index}`);
167
+ if (slot.kind === "style") {
168
+ const StyleIcon = currentStyle.icon;
169
+ return /* @__PURE__ */ jsxs(DropdownMenu, { children: [
170
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
171
+ "button",
172
+ {
173
+ type: "button",
174
+ "aria-label": `Text style: ${BLOCK_SHORT[currentStyle.command]}`,
175
+ title: "Text style (\u2318\u23250\u20133)",
176
+ "data-format-style": "",
177
+ "data-matrx-button": "",
178
+ className: cn(
179
+ "inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground",
180
+ size === "xs" ? "h-6" : "h-7",
181
+ variant === "full" && "w-[100px] justify-between"
182
+ ),
183
+ children: [
184
+ variant === "essential" ? /* @__PURE__ */ jsx(StyleIcon, { className: iconClass }) : /* @__PURE__ */ jsx("span", { className: "truncate", children: BLOCK_SHORT[currentStyle.command] }),
185
+ /* @__PURE__ */ jsx(ChevronDown, { className: "h-3 w-3 shrink-0" })
186
+ ]
187
+ }
188
+ ) }),
189
+ /* @__PURE__ */ jsx(DropdownMenuContent, { align: "start", className: "w-48", onCloseAutoFocus: keepEditorFocus, children: BLOCK_STYLES.map((style) => /* @__PURE__ */ jsxs(DropdownMenuItem, { "data-format-command": style.command, onSelect: () => run(style.command), children: [
190
+ /* @__PURE__ */ jsx(style.icon, { className: "mr-2 h-4 w-4" }),
191
+ /* @__PURE__ */ jsx("span", { className: "flex-1", children: style.label }),
192
+ currentStyle.command === style.command && /* @__PURE__ */ jsx(Check, { className: "ml-2 h-4 w-4 text-primary" })
193
+ ] }, style.command)) })
194
+ ] }, "style");
195
+ }
196
+ const { command, label, icon: Icon } = slot.spec;
197
+ const pressed = command !== "undo" && command !== "redo" && isActive(command);
198
+ return /* @__PURE__ */ jsx(
199
+ "button",
200
+ {
201
+ type: "button",
202
+ "aria-label": label,
203
+ title: label,
204
+ "aria-pressed": command === "undo" || command === "redo" ? void 0 : pressed,
205
+ "data-format-command": command,
206
+ "data-matrx-button": "",
207
+ onMouseDown: (event) => event.preventDefault(),
208
+ onClick: () => run(command),
209
+ className: cn(buttonClass, pressed && "bg-primary/10 text-primary-ink"),
210
+ children: /* @__PURE__ */ jsx(Icon, { className: iconClass })
211
+ },
212
+ command
213
+ );
214
+ }),
215
+ (foldedButtons.length > 0 || styleFolded) && /* @__PURE__ */ jsxs(DropdownMenu, { children: [
216
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx("button", { type: "button", "aria-label": "More formatting", title: "More formatting", "data-format-more": "", "data-matrx-button": "", className: buttonClass, children: /* @__PURE__ */ jsx(MoreHorizontal, { className: iconClass }) }) }),
217
+ /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", className: "w-56", onCloseAutoFocus: keepEditorFocus, children: [
218
+ styleFolded && BLOCK_STYLES.map((style) => /* @__PURE__ */ jsxs(DropdownMenuItem, { "data-format-command": style.command, onSelect: () => run(style.command), children: [
219
+ /* @__PURE__ */ jsx(style.icon, { className: "mr-2 h-4 w-4" }),
220
+ /* @__PURE__ */ jsx("span", { className: "flex-1", children: style.label }),
221
+ currentStyle.command === style.command && /* @__PURE__ */ jsx(Check, { className: "ml-2 h-4 w-4 text-primary" })
222
+ ] }, style.command)),
223
+ styleFolded && foldedButtons.length > 0 && /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
224
+ foldedButtons.map(({ spec: b }) => /* @__PURE__ */ jsxs(DropdownMenuItem, { "data-format-command": b.command, onSelect: () => run(b.command), children: [
225
+ /* @__PURE__ */ jsx(b.icon, { className: "mr-2 h-4 w-4" }),
226
+ /* @__PURE__ */ jsx("span", { className: "flex-1", children: b.label }),
227
+ b.command !== "undo" && b.command !== "redo" && isActive(b.command) && /* @__PURE__ */ jsx(Check, { className: "ml-2 h-4 w-4 text-primary" })
228
+ ] }, b.command))
229
+ ] })
230
+ ] })
231
+ ]
232
+ }
233
+ );
35
234
  }
36
235
  var FormatButtons_default = FormatButtons;
37
236
  export {
237
+ BLOCK_STYLES,
238
+ ESSENTIAL_COMMANDS,
38
239
  FORMAT_BUTTONS,
240
+ FORMAT_GROUPS,
39
241
  FormatButtons,
40
242
  FormatButtons_default as default
41
243
  };
@@ -26,7 +26,12 @@ export interface TextSelectionState {
26
26
  editable: boolean;
27
27
  }
28
28
  /** Any engine whose document is markdown text. `apply` writes the minimal change. */
29
- export declare function textFormatTarget(engine: "source" | "textarea", read: () => TextSelectionState | null, apply: (result: SourceEditResult, text: string) => void, subscribe?: (onChange: () => void) => () => void): FormatTarget;
29
+ export declare function textFormatTarget(engine: "source" | "textarea", read: () => TextSelectionState | null, apply: (result: SourceEditResult, text: string) => void, subscribe?: (onChange: () => void) => () => void,
30
+ /** The engine's own undo history (CodeMirror's, the browser's for a textarea). */
31
+ history?: {
32
+ undo: () => boolean;
33
+ redo: () => boolean;
34
+ }): FormatTarget;
30
35
  /**
31
36
  * Write a text edit into a <textarea> so the browser's own undo takes it back
32
37
  * and React's onChange sees it: the changes collapse to ONE replaced span (the
@@ -5,12 +5,13 @@ function markdownFormatHost(target) {
5
5
  const host = { kind: "markdown-format", target };
6
6
  return { [MARKDOWN_FORMAT_HOST_KEY]: host };
7
7
  }
8
- function textFormatTarget(engine, read, apply, subscribe) {
8
+ function textFormatTarget(engine, read, apply, subscribe, history) {
9
9
  return {
10
10
  engine,
11
11
  run: (command) => {
12
12
  const state = read();
13
13
  if (!state || !state.editable) return false;
14
+ if (command === "undo" || command === "redo") return history ? history[command]() : false;
14
15
  const result = formatMarkdown(state.text, state.from, state.to, command);
15
16
  if (result.changes.length === 0) return false;
16
17
  apply(result, state.text);
@@ -67,9 +68,22 @@ function textareaFormatTarget(el) {
67
68
  el.removeEventListener("input", onChange);
68
69
  el.removeEventListener("select", onChange);
69
70
  };
71
+ },
72
+ {
73
+ // The browser's own history: it holds every edit the buttons wrote (execCommand insertText).
74
+ undo: () => textareaHistory(el, "undo"),
75
+ redo: () => textareaHistory(el, "redo")
70
76
  }
71
77
  );
72
78
  }
79
+ function textareaHistory(el, verb) {
80
+ el.focus({ preventScroll: true });
81
+ try {
82
+ return typeof document.execCommand === "function" && document.execCommand(verb, false);
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
73
87
  const FORMAT_HOST_ATTR = "data-format-host";
74
88
  const registeredTargets = /* @__PURE__ */ new WeakMap();
75
89
  let lastFocused = null;
@@ -87,6 +87,17 @@ function sourceFormatTarget(getView) {
87
87
  (result) => {
88
88
  const v = getView();
89
89
  if (v) applyResult(v, result);
90
+ },
91
+ void 0,
92
+ {
93
+ undo: () => {
94
+ const v = getView();
95
+ return v ? (v.focus(), undo(v)) : false;
96
+ },
97
+ redo: () => {
98
+ const v = getView();
99
+ return v ? (v.focus(), redo(v)) : false;
100
+ }
90
101
  }
91
102
  );
92
103
  }
@@ -4,7 +4,7 @@ import { type VisualLoadStats } from "../core/visual-document.js";
4
4
  import { type CaretCarry, type CaretContext } from "../core/caret-context.js";
5
5
  import type { FindOptions } from "../core/find-replace.js";
6
6
  import { type RichShellActions } from "./visual-extensions.js";
7
- import type { FormatTarget } from "../format/format-target.js";
7
+ import { type FormatTarget } from "../format/format-target.js";
8
8
  export interface ViewFindState {
9
9
  count: number;
10
10
  current: number;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useEffect, useImperativeHandle, useRef, useState } from "react";
3
+ import { useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
4
4
  import { redoDepth, undoDepth } from "@tiptap/pm/history";
5
5
  import { EditorContent, useEditor } from "@tiptap/react";
6
6
  import { getSchema } from "@tiptap/core";
@@ -22,7 +22,8 @@ import { createVisualExtensions } from "./visual-extensions.js";
22
22
  import { findHighlightKey } from "./decorations.js";
23
23
  import { BlockHandle } from "./BlockHandle.js";
24
24
  import { useSelectionZone } from "@ai-matrx/rich-content/selection-toolbar/selection-zones";
25
- import { RICH_EDITOR_HOST_KEY, islandSelectionOf, tableAnchorOf } from "./format-actions.js";
25
+ import { registerFormatElement } from "../format/format-target.js";
26
+ import { RICH_EDITOR_HOST_KEY, editorFormatTarget, islandSelectionOf, tableAnchorOf } from "./format-actions.js";
26
27
  import { useRichEditorContext } from "../editor/RichEditorContext.js";
27
28
  const HEADLESS_SCHEMA = getSchema(createRichEditorExtensions());
28
29
  const REPORT_DELAY_MS = 120;
@@ -150,8 +151,17 @@ function VisualEditor({
150
151
  )
151
152
  );
152
153
  };
154
+ const shellRef = useRef(shell);
155
+ shellRef.current = shell;
156
+ const formatTarget = useMemo(() => editor ? editorFormatTarget(editor, () => shellRef.current.editLink()) : null, [editor]);
157
+ const [formatZone, setFormatZone] = useState(null);
158
+ useEffect(() => {
159
+ if (!formatZone || !formatTarget || context.readOnly) return void 0;
160
+ return registerFormatElement(formatZone, formatTarget);
161
+ }, [formatZone, formatTarget, context.readOnly]);
153
162
  useImperativeHandle(handleRef, () => ({
154
163
  focus: () => editor?.commands.focus(),
164
+ formatTarget: () => formatTarget,
155
165
  selectedText: () => {
156
166
  if (!editor) return "";
157
167
  const { from, to } = editor.state.selection;
@@ -311,6 +321,7 @@ function VisualEditor({
311
321
  ref: (node) => {
312
322
  container.current = node;
313
323
  setZoneElement(node);
324
+ setFormatZone(node);
314
325
  },
315
326
  className: cn("rich-editor-visual relative h-full overflow-y-auto", focusMode && "rich-editor-focus"),
316
327
  "data-testid": "rich-editor-visual",
@@ -1,5 +1,6 @@
1
1
  import type { Editor } from "@tiptap/core";
2
2
  import type { ActionProvider } from "@ai-matrx/alchemy/actions";
3
+ import type { FormatTarget } from "../format/format-target.js";
3
4
  export declare const RICH_EDITOR_HOST_KEY = "richEditor";
4
5
  export interface RichEditorSelectionHost {
5
6
  kind: "rich-editor";
@@ -16,5 +17,11 @@ export declare function islandSelectionOf(editor: Editor): {
16
17
  } | null;
17
18
  /** The table the caret is in, as a box (the toolbar's caret-mode anchor), or null. */
18
19
  export declare function tableAnchorOf(editor: Editor): DOMRect | null;
20
+ /**
21
+ * THE visual editor as a FormatTarget, with the verbs its own shell implements
22
+ * (link field, checklist, code-block island) — the selection toolbar and the
23
+ * visible toolbar (`FormatButtons`, through `registerFormatElement`) share it.
24
+ */
25
+ export declare function editorFormatTarget(editor: Editor, onEditLink: () => void): FormatTarget;
19
26
  /** The formatting provider (declared on load; the toolbar root registers it). */
20
27
  export declare const richEditorFormatProvider: ActionProvider;
@@ -44,10 +44,11 @@ function editorOf(target) {
44
44
  }
45
45
  function visualTargetOf(target) {
46
46
  const visual = editorOf(target);
47
- if (!visual) return null;
48
- const { editor } = visual;
47
+ return visual ? editorFormatTarget(visual.editor, visual.onEditLink) : null;
48
+ }
49
+ function editorFormatTarget(editor, onEditLink) {
49
50
  return visualFormatTarget(editor, {
50
- link: () => (visual.onEditLink(), true),
51
+ link: () => (onEditLink(), true),
51
52
  taskList: () => toggleTaskList(editor),
52
53
  codeBlock: () => {
53
54
  markAutoEdit(editor, insertCodeBlock(editor));
@@ -122,6 +123,7 @@ const richEditorFormatProvider = {
122
123
  declareSelectionProvider(richEditorFormatProvider);
123
124
  export {
124
125
  RICH_EDITOR_HOST_KEY,
126
+ editorFormatTarget,
125
127
  islandSelectionOf,
126
128
  richEditorFormatProvider,
127
129
  tableAnchorOf
@@ -13,7 +13,12 @@ const VISUAL = {
13
13
  orderedList: { run: (e) => e.chain().focus().toggleOrderedList().run(), active: (e) => e.isActive("orderedList") },
14
14
  taskList: { run: () => false, active: (e) => e.isActive("taskList") },
15
15
  quote: { run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive("blockquote") },
16
- codeBlock: { run: () => false, active: () => false }
16
+ codeBlock: { run: () => false, active: () => false },
17
+ paragraph: { run: (e) => e.chain().focus().setParagraph().run(), active: (e) => e.isActive("paragraph") },
18
+ table: { run: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), active: (e) => e.isActive("table") },
19
+ horizontalRule: { run: (e) => e.chain().focus().setHorizontalRule().run(), active: () => false },
20
+ undo: { run: (e) => e.chain().focus().undo().run(), active: () => false },
21
+ redo: { run: (e) => e.chain().focus().redo().run(), active: () => false }
17
22
  };
18
23
  function visualFormatTarget(editor, overrides = {}) {
19
24
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/rich-editor",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "The AI Matrx editor: Visual (Tiptap), Source (CodeMirror), Preview, the markdown formatting command layer, and edit-in-place — over @ai-matrx/rich-content. Consumers that only render never install it.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -90,12 +90,12 @@
90
90
  "@tiptap/suggestion": "^3.31.3",
91
91
  "github-slugger": "^2.0.0",
92
92
  "lucide-react": "^1.48.0",
93
- "@ai-matrx/agents": "latest",
94
93
  "@ai-matrx/alchemy": "latest",
95
94
  "@ai-matrx/content-ir": "latest",
95
+ "@ai-matrx/design-system": "latest",
96
96
  "@ai-matrx/kit": "latest",
97
97
  "@ai-matrx/print": "latest",
98
- "@ai-matrx/design-system": "latest",
98
+ "@ai-matrx/agents": "latest",
99
99
  "@ai-matrx/rich-content": "latest"
100
100
  },
101
101
  "peerDependencies": {
@@ -126,6 +126,19 @@
126
126
  "engines": {
127
127
  "node": ">=20"
128
128
  },
129
+ "matrx": {
130
+ "charter": "Visual, source and preview editing over rich-content.",
131
+ "owns": [
132
+ "Tiptap editor",
133
+ "CodeMirror editor",
134
+ "formatting commands",
135
+ "edit in place"
136
+ ],
137
+ "mustNotOwn": [
138
+ "rendering (rich-content)"
139
+ ],
140
+ "layer": 8
141
+ },
129
142
  "scripts": {
130
143
  "build": "pnpm --filter @ai-matrx/rich-content build && node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup && node -e \"require('node:fs').cpSync('src/editor/rich-editor.css','dist/editor/rich-editor.css')\" && node ./scripts/tsc-own.mjs -p tsconfig.build.json && node ./scripts/finish-build.mjs",
131
144
  "check:package": "pnpm build && publint && pnpm verify:modules-tarball && pnpm verify:tarball && pnpm check:host-boundary",