@ai-matrx/rich-editor 0.3.3 → 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 +10 -0
- package/dist/core/markdown-format.d.ts +1 -1
- package/dist/core/markdown-format.js +63 -2
- package/dist/editor/RichEditorImpl.js +4 -3
- package/dist/format/FormatButtons.d.ts +11 -3
- package/dist/format/FormatButtons.js +232 -30
- package/dist/format/format-target.d.ts +6 -1
- package/dist/format/format-target.js +15 -1
- package/dist/source/SourceEditor.js +11 -0
- package/dist/visual/VisualEditor.d.ts +1 -1
- package/dist/visual/VisualEditor.js +13 -2
- package/dist/visual/format-actions.d.ts +7 -0
- package/dist/visual/format-actions.js +5 -3
- package/dist/visual/visual-format-target.js +6 -1
- package/package.json +15 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.3.3
|
|
4
14
|
|
|
5
15
|
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
|
|
456
|
-
const
|
|
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
|
|
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
|
|
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
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
"data-format-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
48
|
-
|
|
47
|
+
return visual ? editorFormatTarget(visual.editor, visual.onEditLink) : null;
|
|
48
|
+
}
|
|
49
|
+
function editorFormatTarget(editor, onEditLink) {
|
|
49
50
|
return visualFormatTarget(editor, {
|
|
50
|
-
link: () => (
|
|
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
|
+
"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",
|
|
96
95
|
"@ai-matrx/design-system": "latest",
|
|
97
96
|
"@ai-matrx/kit": "latest",
|
|
98
97
|
"@ai-matrx/print": "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",
|