@ai-matrx/rich-editor 0.3.3 → 0.4.1
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 +17 -0
- package/dist/core/markdown-format.d.ts +1 -1
- package/dist/core/markdown-format.js +63 -2
- package/dist/editor/RichEditorImpl.d.ts +12 -2
- package/dist/editor/RichEditorImpl.js +25 -11
- package/dist/editor/SplitPanes.d.ts +11 -0
- package/dist/editor/SplitPanes.js +24 -0
- 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/in-place/InPlaceEditor.d.ts +11 -1
- package/dist/in-place/InPlaceEditor.js +6 -0
- 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,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
### Edit in place: Write, Split, Source, Preview (2026-10-08)
|
|
5
|
+
|
|
6
|
+
- Every edit-in-place editor (an AI answer in chat, window panels and the side drawer) offers Write (the visual editor, default), **Split** (the raw markdown editable on the left, the full live render on the right; stacked on a phone), Source and Preview — in the editor's own view switch, never behind a menu on desktop.
|
|
7
|
+
- `RichEditor` gains `views` (the views offered, in order) and `viewLabels` (a host's own word for a view). Hosts that pass neither are unchanged.
|
|
8
|
+
- The formatting toolbar works in Split's raw pane like in Source.
|
|
9
|
+
|
|
10
|
+
## 0.4.0
|
|
11
|
+
### ONE formatting toolbar in every editor and every mode (2026-10-07)
|
|
12
|
+
|
|
13
|
+
- `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).
|
|
14
|
+
- 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.
|
|
15
|
+
- The command layer gains `paragraph`, `table`, `horizontalRule`, `undo`, `redo` for every engine (text engines write markdown; undo/redo use the engine's own history).
|
|
16
|
+
- `RichEditor` with `chrome="full"` shows the toolbar in Visual and Source (was Source only).
|
|
17
|
+
|
|
18
|
+
Consumer action: hosts that gated `FormatButtons` to plain modes can show it in every editable mode.
|
|
19
|
+
|
|
3
20
|
## 0.3.3
|
|
4
21
|
|
|
5
22
|
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;
|
|
@@ -8,7 +8,9 @@ import type { BlockPlacement } from "../core/text-insertion.js";
|
|
|
8
8
|
import type { DeclaredVariable } from "../core/variables.js";
|
|
9
9
|
import { type ImagePolicyDeclaration } from "@ai-matrx/rich-content/levels/prose/remote-image-policy";
|
|
10
10
|
import type { AnnotationRecord } from "@ai-matrx/rich-content/rich-document/host-types";
|
|
11
|
-
export type RichEditorView = "visual" | "source" | "preview";
|
|
11
|
+
export type RichEditorView = "visual" | "split" | "source" | "preview";
|
|
12
|
+
/** The views a host gets unless it names its own (`views`). Split is offered where a host asks for it. */
|
|
13
|
+
export declare const DEFAULT_VIEWS: readonly RichEditorView[];
|
|
12
14
|
/**
|
|
13
15
|
* What a host that carries its OWN chrome (view switch, find, outline, dictation,
|
|
14
16
|
* its own right-click menu) drives the editor through. Every verb acts on the
|
|
@@ -72,6 +74,14 @@ export interface RichEditorProps {
|
|
|
72
74
|
*/
|
|
73
75
|
view?: RichEditorView;
|
|
74
76
|
onViewChange?: (view: RichEditorView) => void;
|
|
77
|
+
/**
|
|
78
|
+
* The views the switch offers, in order (default Visual, Source, Preview).
|
|
79
|
+
* Split — the raw text editable on the left, the full render live on the
|
|
80
|
+
* right — is offered by naming it here (edit-in-place: Write, Split, Source, Preview).
|
|
81
|
+
*/
|
|
82
|
+
views?: readonly RichEditorView[];
|
|
83
|
+
/** A host's own word for a view ("Write" for Visual in edit-in-place). */
|
|
84
|
+
viewLabels?: Partial<Record<RichEditorView, string>>;
|
|
75
85
|
/**
|
|
76
86
|
* "full" (default): the editor's toolbar row — views, tools, dictation, Save.
|
|
77
87
|
* "bare": no row of its own; the host's chrome carries the view switch, find,
|
|
@@ -135,4 +145,4 @@ export interface RichEditorProps {
|
|
|
135
145
|
*/
|
|
136
146
|
imagePolicy?: ImagePolicyDeclaration;
|
|
137
147
|
}
|
|
138
|
-
export default function RichEditorImpl({ value, onSave, onChange, variables, onDeclareVariable, defaultView, view: controlledView, onViewChange, chrome, hostContextMenu, controllerRef, readOnly, placeholder, surfaceName, sourceFeature, contentSource, annotationRecord, saveLabel, className, toolbarExtras, onCancel, cancelLabel, defaultOutlineOpen, onNothingToSave, imagePolicy, }: RichEditorProps): import("react").JSX.Element;
|
|
148
|
+
export default function RichEditorImpl({ value, onSave, onChange, variables, onDeclareVariable, defaultView, view: controlledView, onViewChange, views: viewsProp, viewLabels, chrome, hostContextMenu, controllerRef, readOnly, placeholder, surfaceName, sourceFeature, contentSource, annotationRecord, saveLabel, className, toolbarExtras, onCancel, cancelLabel, defaultOutlineOpen, onNothingToSave, imagePolicy, }: RichEditorProps): import("react").JSX.Element;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
Check,
|
|
8
8
|
ChevronDown,
|
|
9
9
|
Code2,
|
|
10
|
+
Columns2,
|
|
10
11
|
Eye,
|
|
11
12
|
Focus,
|
|
12
13
|
Hash,
|
|
@@ -50,6 +51,7 @@ import { RichEditorContext } from "./RichEditorContext.js";
|
|
|
50
51
|
import { VisualEditor } from "../visual/VisualEditor.js";
|
|
51
52
|
import { SourceEditor } from "../source/SourceEditor.js";
|
|
52
53
|
import { FormatButtons } from "../format/FormatButtons.js";
|
|
54
|
+
import { SplitPanes } from "./SplitPanes.js";
|
|
53
55
|
import { FindReplacePanel } from "../panels/FindReplacePanel.js";
|
|
54
56
|
import { OutlinePanel } from "../panels/OutlinePanel.js";
|
|
55
57
|
import { ShortcutsDialog } from "../panels/ShortcutsDialog.js";
|
|
@@ -59,7 +61,7 @@ import { withImagePolicy } from "@ai-matrx/rich-content/levels/prose/remote-imag
|
|
|
59
61
|
import { RecordAnnotations } from "@ai-matrx/rich-content/host/app-bindings";
|
|
60
62
|
import { annotationRecordOf } from "@ai-matrx/rich-content/host/app-bindings";
|
|
61
63
|
import { formatCount } from "@ai-matrx/kit/format";
|
|
62
|
-
const
|
|
64
|
+
const DEFAULT_VIEWS = ["visual", "source", "preview"];
|
|
63
65
|
function describeDelta(delta) {
|
|
64
66
|
const raw = delta.before ?? delta.after ?? "";
|
|
65
67
|
const block = islandMeta(delta.islandType, raw);
|
|
@@ -79,6 +81,7 @@ function describeDelta(delta) {
|
|
|
79
81
|
}
|
|
80
82
|
const VIEW_META = {
|
|
81
83
|
visual: { label: "Visual", icon: Type },
|
|
84
|
+
split: { label: "Split", icon: Columns2 },
|
|
82
85
|
source: { label: "Source", icon: Code2 },
|
|
83
86
|
preview: { label: "Preview", icon: Eye }
|
|
84
87
|
};
|
|
@@ -91,6 +94,8 @@ function RichEditorImpl({
|
|
|
91
94
|
defaultView = "visual",
|
|
92
95
|
view: controlledView,
|
|
93
96
|
onViewChange,
|
|
97
|
+
views: viewsProp,
|
|
98
|
+
viewLabels,
|
|
94
99
|
chrome = "full",
|
|
95
100
|
hostContextMenu = false,
|
|
96
101
|
controllerRef,
|
|
@@ -110,6 +115,8 @@ function RichEditorImpl({
|
|
|
110
115
|
imagePolicy
|
|
111
116
|
}) {
|
|
112
117
|
const isMobile = useIsMobile();
|
|
118
|
+
const VIEWS = viewsProp?.length ? viewsProp : DEFAULT_VIEWS;
|
|
119
|
+
const viewLabel = (option) => viewLabels?.[option] ?? VIEW_META[option].label;
|
|
113
120
|
const [stored, setStored] = useState(value);
|
|
114
121
|
const [current, setCurrent] = useState(value);
|
|
115
122
|
const [view, setView] = useState(controlledView ?? defaultView);
|
|
@@ -404,18 +411,19 @@ function RichEditorImpl({
|
|
|
404
411
|
},
|
|
405
412
|
`source-${mountKey}`
|
|
406
413
|
);
|
|
407
|
-
const
|
|
414
|
+
const rendered = (content) => /* @__PURE__ */ jsx(
|
|
408
415
|
RichDocument,
|
|
409
416
|
{
|
|
410
417
|
imagePolicy: "inherit",
|
|
411
|
-
content
|
|
418
|
+
content,
|
|
412
419
|
source: contentSource,
|
|
413
420
|
enableContextMenu: true,
|
|
414
421
|
hideCopyButton: true,
|
|
415
422
|
allowFullScreenEditor: false,
|
|
416
423
|
actions: { exclude: ["edit", "delete-message"] }
|
|
417
424
|
}
|
|
418
|
-
)
|
|
425
|
+
);
|
|
426
|
+
const editingBody = hostContextMenu ? /* @__PURE__ */ jsx("div", { className: "h-full min-h-0", children: viewEditor }) : /* @__PURE__ */ jsx(
|
|
419
427
|
EditableContextMenu,
|
|
420
428
|
{
|
|
421
429
|
sourceFeature,
|
|
@@ -433,6 +441,10 @@ function RichEditorImpl({
|
|
|
433
441
|
children: /* @__PURE__ */ jsx("div", { className: "h-full min-h-0", children: viewEditor })
|
|
434
442
|
}
|
|
435
443
|
);
|
|
444
|
+
const body = view === "preview" ? /* @__PURE__ */ jsx("div", { className: "h-full overflow-y-auto", children: /* @__PURE__ */ jsx("div", { className: "mx-auto max-w-3xl px-6 py-6 sm:px-10", children: rendered(current) }) }) : view === "split" ? (
|
|
445
|
+
// Split: the raw text (Source, raw) on the left, the live render of the same text on the right.
|
|
446
|
+
/* @__PURE__ */ jsx(SplitPanes, { stacked: isMobile, raw: editingBody, render: rendered(deferred) })
|
|
447
|
+
) : editingBody;
|
|
436
448
|
const CurrentViewIcon = VIEW_META[view].icon;
|
|
437
449
|
const tools = [
|
|
438
450
|
{ id: "find", label: "Find & replace (\u2318F)", icon: Search, active: findMode !== null, disabled: view === "preview", run: () => setFindMode((mode) => mode ? null : "find") },
|
|
@@ -452,10 +464,11 @@ function RichEditorImpl({
|
|
|
452
464
|
ro.observe(row);
|
|
453
465
|
return () => ro.disconnect();
|
|
454
466
|
}, [focusMode, chrome]);
|
|
455
|
-
const
|
|
456
|
-
const
|
|
467
|
+
const editable = view !== "preview" && !readOnly;
|
|
468
|
+
const compactTools = isMobile || editable && toolbarRowWidth > 0 && toolbarRowWidth < 1180;
|
|
469
|
+
const formatButtons = editable ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
457
470
|
/* @__PURE__ */ jsx("span", { className: "mx-1 h-5 w-px shrink-0 bg-border" }),
|
|
458
|
-
/* @__PURE__ */ jsx(FormatButtons, { resolve: () => handle.current?.formatTarget?.() ?? null })
|
|
471
|
+
/* @__PURE__ */ jsx(FormatButtons, { className: "flex-1", resolve: () => handle.current?.formatTarget?.() ?? null })
|
|
459
472
|
] }) : null;
|
|
460
473
|
const micButton = mic.available && !readOnly && view !== "preview" ? /* @__PURE__ */ jsx(
|
|
461
474
|
ToolbarButton,
|
|
@@ -534,7 +547,7 @@ function RichEditorImpl({
|
|
|
534
547
|
"button",
|
|
535
548
|
{
|
|
536
549
|
type: "button",
|
|
537
|
-
"aria-label": `View: ${
|
|
550
|
+
"aria-label": `View: ${viewLabel(view)}`,
|
|
538
551
|
title: "Switch view",
|
|
539
552
|
className: "flex h-8 shrink-0 items-center gap-1 rounded-md border border-border bg-background/60 px-2 text-xs font-medium text-primary",
|
|
540
553
|
children: [
|
|
@@ -547,7 +560,7 @@ function RichEditorImpl({
|
|
|
547
560
|
const Icon = VIEW_META[option].icon;
|
|
548
561
|
return /* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => switchView(option), children: [
|
|
549
562
|
/* @__PURE__ */ jsx(Icon, { className: "mr-2 h-4 w-4" }),
|
|
550
|
-
/* @__PURE__ */ jsx("span", { className: "flex-1", children:
|
|
563
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1", children: viewLabel(option) }),
|
|
551
564
|
view === option && /* @__PURE__ */ jsx(Check, { className: "ml-2 h-4 w-4 text-primary" })
|
|
552
565
|
] }, option);
|
|
553
566
|
}) })
|
|
@@ -559,7 +572,7 @@ function RichEditorImpl({
|
|
|
559
572
|
{
|
|
560
573
|
type: "button",
|
|
561
574
|
role: "tab",
|
|
562
|
-
title:
|
|
575
|
+
title: viewLabel(option),
|
|
563
576
|
"aria-selected": view === option,
|
|
564
577
|
onClick: () => switchView(option),
|
|
565
578
|
className: cn(
|
|
@@ -568,7 +581,7 @@ function RichEditorImpl({
|
|
|
568
581
|
),
|
|
569
582
|
children: [
|
|
570
583
|
/* @__PURE__ */ jsx(Icon, { className: "h-3.5 w-3.5" }),
|
|
571
|
-
|
|
584
|
+
viewLabel(option)
|
|
572
585
|
]
|
|
573
586
|
},
|
|
574
587
|
option
|
|
@@ -743,5 +756,6 @@ function ToolbarButton({
|
|
|
743
756
|
);
|
|
744
757
|
}
|
|
745
758
|
export {
|
|
759
|
+
DEFAULT_VIEWS,
|
|
746
760
|
RichEditorImpl as default
|
|
747
761
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export interface SplitPanesProps {
|
|
3
|
+
/** The editable raw text (the Source engine). */
|
|
4
|
+
raw: ReactNode;
|
|
5
|
+
/** The live render of the same text. */
|
|
6
|
+
render: ReactNode;
|
|
7
|
+
/** Stack top/bottom instead of side by side (phones). */
|
|
8
|
+
stacked?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function SplitPanes({ raw, render, stacked }: SplitPanesProps): import("react").JSX.Element;
|
|
11
|
+
export default SplitPanes;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "@ai-matrx/design-system";
|
|
4
|
+
function SplitPanes({ raw, render, stacked = false }) {
|
|
5
|
+
return /* @__PURE__ */ jsxs(
|
|
6
|
+
"div",
|
|
7
|
+
{
|
|
8
|
+
"data-split-view": "",
|
|
9
|
+
className: cn(
|
|
10
|
+
"grid h-full min-h-0",
|
|
11
|
+
stacked ? "grid-cols-1 grid-rows-2 divide-y divide-border" : "grid-cols-2 divide-x divide-border"
|
|
12
|
+
),
|
|
13
|
+
children: [
|
|
14
|
+
/* @__PURE__ */ jsx("section", { "aria-label": "Raw text", "data-split-pane": "raw", className: "min-h-0 min-w-0 overflow-hidden", children: raw }),
|
|
15
|
+
/* @__PURE__ */ jsx("section", { "aria-label": "Rendered", "data-split-pane": "render", className: "min-h-0 min-w-0 overflow-y-auto", children: /* @__PURE__ */ jsx("div", { className: "px-4 py-4 sm:px-6", children: render }) })
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
var SplitPanes_default = SplitPanes;
|
|
21
|
+
export {
|
|
22
|
+
SplitPanes,
|
|
23
|
+
SplitPanes_default as default
|
|
24
|
+
};
|
|
@@ -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;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
-
import { type RichEditorProps } from "../editor/RichEditor.js";
|
|
2
|
+
import { type RichEditorProps, type RichEditorView } from "../editor/RichEditor.js";
|
|
3
3
|
import type { CaretContext } from "../core/caret-context.js";
|
|
4
4
|
import { type InPlaceSaveMode } from "./in-place-session.js";
|
|
5
5
|
export type InPlaceEditorPassThrough = Omit<RichEditorProps, "value" | "onSave" | "onChange" | "onCancel" | "onNothingToSave" | "cancelLabel" | "controllerRef">;
|
|
@@ -33,6 +33,16 @@ export interface InPlaceEditorProps {
|
|
|
33
33
|
*/
|
|
34
34
|
actions?: readonly InPlaceAction[];
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The modes of every edit-in-place editor, in this order (Arman, 2026-10-08:
|
|
38
|
+
* "Default: Write. Next option: Split (plain text on the left and full render
|
|
39
|
+
* on the right). Then, whatever else we need. But you cannot gobble them up and
|
|
40
|
+
* hide the things we need."). Write is the visual editor and always opens
|
|
41
|
+
* first (no per-person memory exists for this); Split shows the raw string
|
|
42
|
+
* next to its render. All four sit in the editor's own toolbar row.
|
|
43
|
+
*/
|
|
44
|
+
export declare const IN_PLACE_VIEWS: readonly RichEditorView[];
|
|
45
|
+
export declare const IN_PLACE_VIEW_LABELS: Partial<Record<RichEditorView, string>>;
|
|
36
46
|
export interface InPlaceAction {
|
|
37
47
|
id: string;
|
|
38
48
|
label: string;
|
|
@@ -8,6 +8,8 @@ import { cn } from "@ai-matrx/design-system";
|
|
|
8
8
|
import { ConfirmDialog } from "@ai-matrx/rich-content/host/app-bindings";
|
|
9
9
|
import RichEditor from "../editor/RichEditor.js";
|
|
10
10
|
import { createInPlaceSession } from "./in-place-session.js";
|
|
11
|
+
const IN_PLACE_VIEWS = ["visual", "split", "source", "preview"];
|
|
12
|
+
const IN_PLACE_VIEW_LABELS = { visual: "Write" };
|
|
11
13
|
function InPlaceEditor({
|
|
12
14
|
value,
|
|
13
15
|
mode = "explicit",
|
|
@@ -129,6 +131,8 @@ function InPlaceEditor({
|
|
|
129
131
|
{
|
|
130
132
|
defaultView: "visual",
|
|
131
133
|
defaultOutlineOpen: false,
|
|
134
|
+
views: IN_PLACE_VIEWS,
|
|
135
|
+
viewLabels: IN_PLACE_VIEW_LABELS,
|
|
132
136
|
...editor,
|
|
133
137
|
controllerRef: controller,
|
|
134
138
|
value: openedOn,
|
|
@@ -206,6 +210,8 @@ function keepCaretInView(root, settleMs = 2500) {
|
|
|
206
210
|
return stop;
|
|
207
211
|
}
|
|
208
212
|
export {
|
|
213
|
+
IN_PLACE_VIEWS,
|
|
214
|
+
IN_PLACE_VIEW_LABELS,
|
|
209
215
|
InPlaceEditor,
|
|
210
216
|
keepCaretInView
|
|
211
217
|
};
|
|
@@ -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.1",
|
|
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",
|
|
@@ -93,9 +93,9 @@
|
|
|
93
93
|
"@ai-matrx/agents": "latest",
|
|
94
94
|
"@ai-matrx/alchemy": "latest",
|
|
95
95
|
"@ai-matrx/content-ir": "latest",
|
|
96
|
-
"@ai-matrx/design-system": "latest",
|
|
97
96
|
"@ai-matrx/kit": "latest",
|
|
98
97
|
"@ai-matrx/print": "latest",
|
|
98
|
+
"@ai-matrx/design-system": "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",
|