@ai-matrx/rich-editor 0.1.2 → 0.2.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/core/markdown-format.js +3 -3
  3. package/dist/core/text-insertion.d.ts +14 -0
  4. package/dist/core/text-insertion.js +55 -0
  5. package/dist/editor/RichEditor.d.ts +4 -0
  6. package/dist/editor/RichEditor.js +14 -0
  7. package/dist/editor/RichEditorContext.d.ts +16 -0
  8. package/dist/editor/RichEditorContext.js +17 -0
  9. package/dist/editor/RichEditorImpl.d.ts +137 -0
  10. package/dist/editor/RichEditorImpl.js +714 -0
  11. package/dist/editor/caretInsert.d.ts +11 -0
  12. package/dist/editor/caretInsert.js +9 -0
  13. package/dist/editor/rich-editor.css +181 -0
  14. package/dist/format/format-actions.d.ts +10 -0
  15. package/dist/format/format-actions.js +78 -0
  16. package/dist/format/useTextareaFormatting.d.ts +4 -0
  17. package/dist/format/useTextareaFormatting.js +37 -0
  18. package/dist/in-place/EditInPlace.d.ts +38 -0
  19. package/dist/in-place/EditInPlace.js +72 -0
  20. package/dist/in-place/EditInPlaceText.d.ts +18 -0
  21. package/dist/in-place/EditInPlaceText.js +159 -0
  22. package/dist/in-place/InPlaceEditor.d.ts +41 -0
  23. package/dist/in-place/InPlaceEditor.js +169 -0
  24. package/dist/islands/IslandCodeEditor.d.ts +12 -0
  25. package/dist/islands/IslandCodeEditor.js +82 -0
  26. package/dist/islands/IslandPreview.d.ts +6 -0
  27. package/dist/islands/IslandPreview.js +31 -0
  28. package/dist/islands/island-meta.d.ts +28 -0
  29. package/dist/islands/island-meta.js +176 -0
  30. package/dist/panels/FindReplacePanel.d.ts +10 -0
  31. package/dist/panels/FindReplacePanel.js +118 -0
  32. package/dist/panels/KindPicker.d.ts +4 -0
  33. package/dist/panels/KindPicker.js +95 -0
  34. package/dist/panels/OutlinePanel.d.ts +6 -0
  35. package/dist/panels/OutlinePanel.js +60 -0
  36. package/dist/panels/ShortcutsDialog.d.ts +4 -0
  37. package/dist/panels/ShortcutsDialog.js +24 -0
  38. package/dist/source/SourceEditor.d.ts +28 -0
  39. package/dist/source/SourceEditor.js +429 -0
  40. package/dist/source/cm-theme.d.ts +4 -0
  41. package/dist/source/cm-theme.js +71 -0
  42. package/dist/source/live-preview.d.ts +30 -0
  43. package/dist/source/live-preview.js +304 -0
  44. package/dist/source/markdown-language.d.ts +2 -0
  45. package/dist/source/markdown-language.js +7 -0
  46. package/dist/visual/BlockHandle.d.ts +6 -0
  47. package/dist/visual/BlockHandle.js +173 -0
  48. package/dist/visual/VisualEditor.d.ts +56 -0
  49. package/dist/visual/VisualEditor.js +312 -0
  50. package/dist/visual/auto-edit.d.ts +3 -0
  51. package/dist/visual/auto-edit.js +17 -0
  52. package/dist/visual/context-menu-caret.d.ts +10 -0
  53. package/dist/visual/context-menu-caret.js +42 -0
  54. package/dist/visual/decorations.d.ts +9 -0
  55. package/dist/visual/decorations.js +131 -0
  56. package/dist/visual/format-actions.d.ts +20 -0
  57. package/dist/visual/format-actions.js +128 -0
  58. package/dist/visual/menus/SuggestionMenu.d.ts +37 -0
  59. package/dist/visual/menus/SuggestionMenu.js +121 -0
  60. package/dist/visual/nodes/CalloutView.d.ts +2 -0
  61. package/dist/visual/nodes/CalloutView.js +45 -0
  62. package/dist/visual/nodes/InlineIslandView.d.ts +2 -0
  63. package/dist/visual/nodes/InlineIslandView.js +174 -0
  64. package/dist/visual/nodes/IslandBlockView.d.ts +2 -0
  65. package/dist/visual/nodes/IslandBlockView.js +206 -0
  66. package/dist/visual/nodes/SourceLockedView.d.ts +2 -0
  67. package/dist/visual/nodes/SourceLockedView.js +62 -0
  68. package/dist/visual/shortcut-handlers.d.ts +27 -0
  69. package/dist/visual/shortcut-handlers.js +43 -0
  70. package/dist/visual/slash-items.d.ts +18 -0
  71. package/dist/visual/slash-items.js +152 -0
  72. package/dist/visual/visual-extensions.d.ts +10 -0
  73. package/dist/visual/visual-extensions.js +246 -0
  74. package/dist/visual/visual-format-target.d.ts +9 -0
  75. package/dist/visual/visual-format-target.js +43 -0
  76. package/package.json +42 -4
@@ -0,0 +1,714 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import "./rich-editor.css";
4
+ import { useDeferredValue, useEffect, useImperativeHandle, useRef, useState } from "react";
5
+ import {
6
+ AlignLeft,
7
+ Check,
8
+ ChevronDown,
9
+ Code2,
10
+ Eye,
11
+ Focus,
12
+ Hash,
13
+ Keyboard,
14
+ ListTree,
15
+ Loader2,
16
+ Mic,
17
+ MicOff,
18
+ Minimize2,
19
+ MoreHorizontal,
20
+ Save,
21
+ Search,
22
+ Type
23
+ } from "lucide-react";
24
+ import { ConfirmDialog } from "@ai-matrx/rich-content/host/app-bindings";
25
+ import { TextInputDialog } from "@ai-matrx/rich-content/host/app-bindings";
26
+ import {
27
+ DropdownMenu,
28
+ DropdownMenuContent,
29
+ DropdownMenuItem,
30
+ DropdownMenuTrigger,
31
+ Sheet,
32
+ SheetContent,
33
+ SheetHeader,
34
+ SheetTitle,
35
+ useIsMobile
36
+ } from "@ai-matrx/design-system";
37
+ import { toast } from "@ai-matrx/rich-content/host/toast";
38
+ import { cn } from "@ai-matrx/design-system";
39
+ import { EditableContextMenu } from "@ai-matrx/rich-content/host/app-bindings";
40
+ import { buildApplicationScopeFromMenuContext } from "@ai-matrx/rich-content/host/app-bindings";
41
+ import { useMicField } from "@ai-matrx/rich-content/host/app-bindings";
42
+ import { useFileUpload } from "@ai-matrx/rich-content/host/app-bindings";
43
+ import { fileUrls } from "@ai-matrx/rich-content/host/app-bindings";
44
+ import { RichDocument } from "@ai-matrx/rich-content/rich-document/RichDocument";
45
+ import { planSave } from "../core/save-plan.js";
46
+ import { reconcileHostValue } from "../core/host-value.js";
47
+ import { measureText } from "../core/text-metrics.js";
48
+ import { outlineOf } from "../core/outline.js";
49
+ import { RichEditorContext } from "./RichEditorContext.js";
50
+ import { VisualEditor } from "../visual/VisualEditor.js";
51
+ import { SourceEditor } from "../source/SourceEditor.js";
52
+ import { FindReplacePanel } from "../panels/FindReplacePanel.js";
53
+ import { OutlinePanel } from "../panels/OutlinePanel.js";
54
+ import { ShortcutsDialog } from "../panels/ShortcutsDialog.js";
55
+ import { KindPicker } from "../panels/KindPicker.js";
56
+ import { islandMeta, inlineIslandLabel } from "../islands/island-meta.js";
57
+ import { withImagePolicy } from "@ai-matrx/rich-content/levels/prose/remote-image-policy";
58
+ import { RecordAnnotations } from "@ai-matrx/rich-content/host/app-bindings";
59
+ import { annotationRecordOf } from "@ai-matrx/rich-content/host/app-bindings";
60
+ import { formatCount } from "@ai-matrx/kit/format";
61
+ const VIEWS = ["visual", "source", "preview"];
62
+ function describeDelta(delta) {
63
+ const raw = delta.before ?? delta.after ?? "";
64
+ const block = islandMeta(delta.islandType, raw);
65
+ const excerpt = raw.replace(/\s+/g, " ").trim();
66
+ const quoted = `\u201C${excerpt.length > 40 ? `${excerpt.slice(0, 40)}\u2026` : excerpt}\u201D`;
67
+ const name = raw.startsWith("{{") ? raw : !block.generic ? block.label : `${inlineIslandLabel(delta.islandType)} ${quoted}`;
68
+ switch (delta.kind) {
69
+ case "removed":
70
+ return `${name} would be removed`;
71
+ case "changed":
72
+ return `${name} would be changed`;
73
+ case "swallowed":
74
+ return `${name} would stop being protected \u2014 something above it (an unclosed code block or tag) now swallows it`;
75
+ default:
76
+ return `${name} would be added`;
77
+ }
78
+ }
79
+ const VIEW_META = {
80
+ visual: { label: "Visual", icon: Type },
81
+ source: { label: "Source", icon: Code2 },
82
+ preview: { label: "Preview", icon: Eye }
83
+ };
84
+ function RichEditorImpl({
85
+ value,
86
+ onSave,
87
+ onChange,
88
+ variables = null,
89
+ onDeclareVariable,
90
+ defaultView = "visual",
91
+ view: controlledView,
92
+ onViewChange,
93
+ chrome = "full",
94
+ hostContextMenu = false,
95
+ controllerRef,
96
+ readOnly = false,
97
+ placeholder,
98
+ surfaceName = "matrx-user/markdown-studio",
99
+ sourceFeature = "documents",
100
+ contentSource = { type: "raw" },
101
+ annotationRecord,
102
+ saveLabel = "Save",
103
+ className,
104
+ toolbarExtras,
105
+ onCancel,
106
+ cancelLabel = "Cancel",
107
+ defaultOutlineOpen,
108
+ onNothingToSave,
109
+ imagePolicy
110
+ }) {
111
+ const isMobile = useIsMobile();
112
+ const [stored, setStored] = useState(value);
113
+ const [current, setCurrent] = useState(value);
114
+ const [view, setView] = useState(controlledView ?? defaultView);
115
+ const [mountKey, setMountKey] = useState(0);
116
+ const [findMode, setFindMode] = useState(null);
117
+ const [outlineChoice, setOutlineChoice] = useState(
118
+ defaultOutlineOpen ?? (chrome === "bare" ? false : null)
119
+ );
120
+ const outlineOpen = outlineChoice ?? !isMobile;
121
+ const setOutlineOpen = (next) => setOutlineChoice(typeof next === "function" ? next(outlineOpen) : next);
122
+ const [focusMode, setFocusMode] = useState(false);
123
+ const [helpOpen, setHelpOpen] = useState(false);
124
+ const [renderIslands, setRenderIslands] = useState(true);
125
+ const [loadStats, setLoadStats] = useState(null);
126
+ const [pendingPlan, setPendingPlan] = useState(null);
127
+ const [saving, setSaving] = useState(false);
128
+ const [saveState, setSaveState] = useState(null);
129
+ const [linkDialog, setLinkDialog] = useState({ open: false, href: "" });
130
+ const [kindRequest, setKindRequest] = useState(null);
131
+ const approved = useRef(/* @__PURE__ */ new Set());
132
+ const handle = useRef(null);
133
+ const imageInput = useRef(null);
134
+ const { upload } = useFileUpload();
135
+ const lastValue = useRef(value);
136
+ const ownSaves = useRef(/* @__PURE__ */ new Set());
137
+ useEffect(() => {
138
+ if (value === lastValue.current) return;
139
+ let draft = current;
140
+ try {
141
+ draft = handle.current?.flush() ?? current;
142
+ } catch {
143
+ }
144
+ const action = reconcileHostValue({ value, lastValue: lastValue.current, stored, draft, ownSaves: ownSaves.current });
145
+ lastValue.current = value;
146
+ if (action === "ignore") return;
147
+ setStored(value);
148
+ if (action === "adopt-stored") return;
149
+ if (action === "keep-draft") {
150
+ toast.warning("This document changed while you were editing. Your edits are kept \u2014 Save writes them over the new version.");
151
+ return;
152
+ }
153
+ ownSaves.current.clear();
154
+ setCurrent(value);
155
+ approved.current.clear();
156
+ setMountKey((key) => key + 1);
157
+ }, [value]);
158
+ const deferred = useDeferredValue(current);
159
+ const metrics = measureText(deferred);
160
+ const outline = outlineOf(deferred);
161
+ const dirty = current !== stored;
162
+ const updateCurrent = (text) => {
163
+ setCurrent(text);
164
+ onChange?.(text);
165
+ };
166
+ const flush = () => {
167
+ const text = handle.current?.flush() ?? current;
168
+ if (text !== current) updateCurrent(text);
169
+ return text;
170
+ };
171
+ const setText = (text) => {
172
+ updateCurrent(text);
173
+ setMountKey((key) => key + 1);
174
+ };
175
+ const switchView = (next) => {
176
+ if (next === view) return;
177
+ try {
178
+ flush();
179
+ } catch (error) {
180
+ toast.error(error instanceof Error ? error.message : String(error));
181
+ if (controlledView !== void 0) onViewChange?.(view);
182
+ return;
183
+ }
184
+ handle.current?.clearFind();
185
+ setView(next);
186
+ setMountKey((key) => key + 1);
187
+ if (next !== controlledView) onViewChange?.(next);
188
+ };
189
+ useEffect(() => {
190
+ if (controlledView !== void 0 && controlledView !== view) switchView(controlledView);
191
+ }, [controlledView]);
192
+ const doSave = async (plan) => {
193
+ if (!onSave) return;
194
+ setSaving(true);
195
+ ownSaves.current.add(plan.text);
196
+ try {
197
+ const readBack = await onSave(plan.text, plan);
198
+ const storedNow = typeof readBack === "string" ? readBack : plan.text;
199
+ const verified = typeof readBack === "string" ? readBack === plan.text : null;
200
+ setStored(storedNow);
201
+ lastValue.current = storedNow;
202
+ approved.current.clear();
203
+ setSaveState({ at: /* @__PURE__ */ new Date(), verified });
204
+ if (verified === false) {
205
+ toast.error("Saved, but what the database holds differs from the editor. Reload to see exactly what was stored.");
206
+ }
207
+ } catch (error) {
208
+ toast.error(`Not saved: ${error instanceof Error ? error.message : String(error)}. Your text is still here \u2014 try again.`);
209
+ } finally {
210
+ setSaving(false);
211
+ }
212
+ };
213
+ const save = () => {
214
+ if (!onSave || readOnly || saving) return;
215
+ let text;
216
+ try {
217
+ text = flush();
218
+ } catch (error) {
219
+ toast.error(error instanceof Error ? error.message : String(error));
220
+ return;
221
+ }
222
+ const plan = planSave(stored, text, { approvedIslands: approved.current });
223
+ if (!plan.changed) {
224
+ if (onNothingToSave) onNothingToSave();
225
+ else toast.info("Nothing changed \u2014 the stored text is already exactly this.");
226
+ return;
227
+ }
228
+ if (plan.needsConsent.length || plan.error) {
229
+ setPendingPlan(plan);
230
+ return;
231
+ }
232
+ void doSave(plan);
233
+ };
234
+ const pickImage = () => imageInput.current?.click();
235
+ const uploadImage = async (file) => {
236
+ try {
237
+ const uploaded = await upload({ kind: "file", file }, { folderPath: "Editor images" });
238
+ const alt = file.name.replace(/\.[^.]+$/, "").replace(/[[\]]/g, "");
239
+ return `![${alt}](${fileUrls(uploaded.fileId).inline})`;
240
+ } catch (error) {
241
+ toast.error(`The image did not upload: ${error instanceof Error ? error.message : String(error)}`);
242
+ return null;
243
+ }
244
+ };
245
+ const shellRef = useRef(null);
246
+ const liveShell = {
247
+ save,
248
+ find: () => setFindMode("find"),
249
+ replace: () => setFindMode("replace"),
250
+ toggleOutline: () => setOutlineOpen((open) => !open),
251
+ toggleFocus: () => setFocusMode((on) => !on),
252
+ exitFocus: () => {
253
+ if (!focusMode) return false;
254
+ setFocusMode(false);
255
+ return true;
256
+ },
257
+ cycleView: () => switchView(VIEWS[(VIEWS.indexOf(view) + 1) % VIEWS.length] ?? "visual"),
258
+ showHelp: () => setHelpOpen(true),
259
+ showWordCount: () => toast.info(`${formatCount(metrics.words)} words \xB7 ${formatCount(metrics.characters)} characters \xB7 ${metrics.readingMinutes} min read`),
260
+ editLink: () => setLinkDialog({ open: true, href: handle.current?.currentLink() ?? "https://" }),
261
+ pickKind: () => new Promise((resolve) => setKindRequest(() => resolve)),
262
+ pickImage,
263
+ uploadImage,
264
+ variables: () => variables,
265
+ declareVariable: (name) => onDeclareVariable?.(name),
266
+ approveIsland: (raw) => approved.current.add(raw)
267
+ };
268
+ useEffect(() => {
269
+ shellRef.current = liveShell;
270
+ });
271
+ const [shell] = useState(() => ({
272
+ save: () => shellRef.current?.save(),
273
+ find: () => shellRef.current?.find(),
274
+ replace: () => shellRef.current?.replace(),
275
+ toggleOutline: () => shellRef.current?.toggleOutline(),
276
+ toggleFocus: () => shellRef.current?.toggleFocus(),
277
+ exitFocus: () => shellRef.current?.exitFocus() ?? false,
278
+ cycleView: () => shellRef.current?.cycleView(),
279
+ showHelp: () => shellRef.current?.showHelp(),
280
+ showWordCount: () => shellRef.current?.showWordCount(),
281
+ editLink: () => shellRef.current?.editLink(),
282
+ pickKind: () => shellRef.current?.pickKind() ?? Promise.resolve(null),
283
+ pickImage: () => shellRef.current?.pickImage(),
284
+ uploadImage: (file) => shellRef.current?.uploadImage(file) ?? Promise.resolve(null),
285
+ variables: () => shellRef.current?.variables() ?? null,
286
+ declareVariable: (name) => shellRef.current?.declareVariable(name),
287
+ approveIsland: (raw) => shellRef.current?.approveIsland(raw)
288
+ }));
289
+ const contextValue = {
290
+ variables,
291
+ approveIsland: (raw) => approved.current.add(raw),
292
+ uploadImage,
293
+ pickKind: shell.pickKind,
294
+ onDeclareVariable,
295
+ readOnly
296
+ };
297
+ const mic = useMicField({
298
+ label: "Editor",
299
+ getValue: () => "",
300
+ writeValue: () => void 0,
301
+ onTranscriptionComplete: (text) => {
302
+ if (text.trim()) handle.current?.replaceSelection(text.trim());
303
+ }
304
+ // No toast here: useMicField shows the one honest error (with its troubleshooting action).
305
+ });
306
+ const getApplicationScope = () => {
307
+ const selected = handle.current?.selectedText() ?? "";
308
+ return buildApplicationScopeFromMenuContext({
309
+ selectedText: selected,
310
+ selectionRange: null,
311
+ contextData: { content: current, active_text: selected || current }
312
+ });
313
+ };
314
+ const jump = (entry) => {
315
+ if (view === "preview") switchView("visual");
316
+ window.setTimeout(() => handle.current?.scrollToHeading(entry.slug, entry.offset), view === "preview" ? 200 : 0);
317
+ if (isMobile) setOutlineOpen(false);
318
+ };
319
+ useImperativeHandle(
320
+ controllerRef,
321
+ () => ({
322
+ focus: () => handle.current?.focus(),
323
+ selectedText: () => handle.current?.selectedText() ?? "",
324
+ replaceSelection: (text) => handle.current?.replaceSelection(text),
325
+ insertText: (text, where) => handle.current?.insertText(text, where) ?? false,
326
+ setText,
327
+ flush,
328
+ historyDepth: () => handle.current?.historyDepth() ?? { undo: 0, redo: 0 },
329
+ openFind: (withReplace = false) => setFindMode(withReplace ? "replace" : "find"),
330
+ placeCaret: (context) => handle.current?.placeCaret(context) ?? false,
331
+ jumpToOffset: (offset) => {
332
+ const entries = outlineOf(flush());
333
+ let entry;
334
+ for (const candidate of entries) {
335
+ if (candidate.offset > offset) break;
336
+ entry = candidate;
337
+ }
338
+ entry ??= entries[0];
339
+ if (entry) jump(entry);
340
+ }
341
+ })
342
+ );
343
+ const lockedCount = (loadStats?.lockedBlocks ?? 0) + (loadStats?.lockedChildren ?? 0);
344
+ const documentFacts = [
345
+ `${formatCount(metrics.words)} words \xB7 ${formatCount(metrics.characters)} characters \xB7 ${metrics.readingMinutes} min read`,
346
+ metrics.islands > 0 ? `${metrics.islands} protected (kinds, XML sections, code, math and variables are never rewritten by the editor)` : null,
347
+ view === "visual" && lockedCount > 0 ? `${lockedCount} kept as written (edit with its pencil or in Source)` : null
348
+ ].filter(Boolean).join("\n");
349
+ const saveStatus = dirty ? "Unsaved changes" : saveState ? `Saved ${saveState.at.toLocaleTimeString()}${saveState.verified ? " \xB7 verified byte-for-byte" : ""}` : (
350
+ // read-gate-exempt: save status of the document in this editor (nothing edited), not an empty read
351
+ "No changes"
352
+ );
353
+ const derivedRecord = contentSource.readOnly ? null : annotationRecordOf(contentSource);
354
+ const recordHere = annotationRecord !== void 0 ? annotationRecord : derivedRecord ? { ...derivedRecord, body: stored } : null;
355
+ const visualRecord = recordHere && !readOnly && current === recordHere.body && current.trim() ? {
356
+ token: recordHere.token,
357
+ id: recordHere.id,
358
+ title: recordHere.title ?? "",
359
+ body: recordHere.body,
360
+ contentVersion: Math.max(1, recordHere.contentVersion ?? 1),
361
+ ...recordHere.href ? { href: recordHere.href } : {}
362
+ } : null;
363
+ const viewEditor = view === "visual" ? /* @__PURE__ */ jsx(RecordAnnotations, { record: visualRecord, className: "h-full min-h-0", children: /* @__PURE__ */ jsx(
364
+ VisualEditor,
365
+ {
366
+ initialText: current,
367
+ onChange: updateCurrent,
368
+ onLoadStats: setLoadStats,
369
+ shell,
370
+ placeholder,
371
+ focusMode,
372
+ handleRef: handle
373
+ },
374
+ `visual-${mountKey}`
375
+ ) }) : /* @__PURE__ */ jsx(
376
+ SourceEditor,
377
+ {
378
+ initialText: current,
379
+ onChange: updateCurrent,
380
+ shell,
381
+ placeholder,
382
+ focusMode,
383
+ renderIslands,
384
+ handleRef: handle,
385
+ raw: true
386
+ },
387
+ `source-${mountKey}`
388
+ );
389
+ 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: /* @__PURE__ */ jsx(
390
+ RichDocument,
391
+ {
392
+ imagePolicy: "inherit",
393
+ content: current,
394
+ source: contentSource,
395
+ enableContextMenu: true,
396
+ hideCopyButton: true,
397
+ allowFullScreenEditor: false,
398
+ actions: { exclude: ["edit", "delete-message"] }
399
+ }
400
+ ) }) }) : hostContextMenu ? /* @__PURE__ */ jsx("div", { className: "h-full min-h-0", children: viewEditor }) : /* @__PURE__ */ jsx(
401
+ EditableContextMenu,
402
+ {
403
+ sourceFeature,
404
+ surfaceName,
405
+ getApplicationScope,
406
+ insertAtCaret: (text) => {
407
+ if (!handle.current) return false;
408
+ handle.current.replaceSelection(text);
409
+ return true;
410
+ },
411
+ onTextReplace: setText,
412
+ onTextInsertBefore: (text) => handle.current?.insertText(text, "before"),
413
+ onTextInsertAfter: (text) => handle.current?.insertText(text, "after"),
414
+ onSave: onSave ? save : void 0,
415
+ children: /* @__PURE__ */ jsx("div", { className: "h-full min-h-0", children: viewEditor })
416
+ }
417
+ );
418
+ const CurrentViewIcon = VIEW_META[view].icon;
419
+ const tools = [
420
+ { id: "find", label: "Find & replace (\u2318F)", icon: Search, active: findMode !== null, disabled: view === "preview", run: () => setFindMode((mode) => mode ? null : "find") },
421
+ { id: "outline", label: "Outline (\u2318\u2325H)", icon: ListTree, active: outlineOpen, run: () => setOutlineOpen((open) => !open) },
422
+ { id: "focus", label: "Focus mode (\u2318\u21E7F)", icon: Focus, active: focusMode, disabled: view === "preview", run: () => setFocusMode((on) => !on) },
423
+ ...view === "source" ? [{ id: "islands", label: renderIslands ? "Show protected blocks as source" : "Render protected blocks", icon: AlignLeft, active: renderIslands, run: () => setRenderIslands((on) => !on) }] : [],
424
+ { id: "shortcuts", label: "Keyboard shortcuts (\u2318/)", icon: Keyboard, run: () => setHelpOpen(true) },
425
+ // Desktop shows the count in the toolbar; a phone reads it from the tools menu.
426
+ ...isMobile ? [{ id: "count", label: `${formatCount(metrics.words)} words \xB7 ${saveStatus}`, icon: Hash, run: () => toast.info(documentFacts) }] : []
427
+ ];
428
+ const micButton = mic.available && !readOnly && view !== "preview" ? /* @__PURE__ */ jsx(
429
+ ToolbarButton,
430
+ {
431
+ label: mic.isRecording ? "Stop dictation" : mic.isTranscribing ? "Finishing the transcript\u2026" : "Dictate at the cursor",
432
+ active: mic.isRecording,
433
+ onClick: () => void mic.handleVoiceClick(),
434
+ children: mic.isTranscribing ? /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : mic.isRecording ? /* @__PURE__ */ jsx(MicOff, { className: "h-4 w-4 text-destructive" }) : /* @__PURE__ */ jsx(Mic, { className: "h-4 w-4" })
435
+ }
436
+ ) : null;
437
+ const cancelButton = onCancel ? /* @__PURE__ */ jsx(
438
+ "button",
439
+ {
440
+ type: "button",
441
+ onClick: onCancel,
442
+ disabled: saving,
443
+ className: "flex h-8 items-center rounded-md px-3 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
444
+ title: dirty ? "Leave without saving \u2014 your unsaved changes are dropped" : "Close the editor",
445
+ children: cancelLabel
446
+ }
447
+ ) : null;
448
+ const saveButton = onSave && !readOnly ? /* @__PURE__ */ jsxs(
449
+ "button",
450
+ {
451
+ type: "button",
452
+ onClick: save,
453
+ disabled: saving,
454
+ className: cn(
455
+ "flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium",
456
+ dirty ? "bg-primary text-primary-foreground hover:bg-primary/90" : "border border-border text-muted-foreground"
457
+ ),
458
+ title: `${saveStatus}. Save (\u2318S) \u2014 only what you changed is written`,
459
+ children: [
460
+ saving ? /* @__PURE__ */ jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ jsx(Save, { className: "h-3.5 w-3.5" }),
461
+ dirty ? saveLabel : "Saved"
462
+ ]
463
+ }
464
+ ) : null;
465
+ const editor = /* @__PURE__ */ jsxs(RichEditorContext.Provider, { value: contextValue, children: [
466
+ /* @__PURE__ */ jsxs(
467
+ "div",
468
+ {
469
+ className: cn("relative flex h-full min-h-0 flex-col bg-textured", className),
470
+ "data-testid": "rich-editor",
471
+ "data-rich-editor": "",
472
+ onKeyDown: (event) => {
473
+ if (event.key === "Escape" && focusMode && !event.defaultPrevented) {
474
+ event.preventDefault();
475
+ setFocusMode(false);
476
+ }
477
+ },
478
+ children: [
479
+ focusMode && /* @__PURE__ */ jsxs("div", { className: "matrx-touch-targets absolute right-2 top-2 z-20 flex items-center gap-1 rounded-lg border border-border bg-card/90 p-1 shadow-sm backdrop-blur", children: [
480
+ /* @__PURE__ */ jsxs(
481
+ "button",
482
+ {
483
+ type: "button",
484
+ onClick: () => setFocusMode(false),
485
+ className: "flex h-8 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
486
+ title: "Exit focus mode (Esc or \u2318\u21E7F)",
487
+ children: [
488
+ /* @__PURE__ */ jsx(Minimize2, { className: "h-3.5 w-3.5" }),
489
+ "Exit focus"
490
+ ]
491
+ }
492
+ ),
493
+ saveButton
494
+ ] }),
495
+ /* @__PURE__ */ jsxs("div", { className: "relative z-20 shrink-0", children: [
496
+ !focusMode && chrome === "full" && /* @__PURE__ */ jsxs("div", { className: "matrx-touch-targets flex min-h-10 items-center gap-1 border-b border-border bg-card/80 px-2 py-1 backdrop-blur", children: [
497
+ /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-1 overflow-x-auto", children: [
498
+ isMobile ? (
499
+ // A phone gets the view as one compact menu, so every control fits.
500
+ /* @__PURE__ */ jsxs(DropdownMenu, { children: [
501
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
502
+ "button",
503
+ {
504
+ type: "button",
505
+ "aria-label": `View: ${VIEW_META[view].label}`,
506
+ title: "Switch view",
507
+ 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",
508
+ children: [
509
+ /* @__PURE__ */ jsx(CurrentViewIcon, { className: "h-3.5 w-3.5" }),
510
+ /* @__PURE__ */ jsx(ChevronDown, { className: "h-3 w-3 text-muted-foreground" })
511
+ ]
512
+ }
513
+ ) }),
514
+ /* @__PURE__ */ jsx(DropdownMenuContent, { align: "start", className: "w-44", children: VIEWS.map((option) => {
515
+ const Icon = VIEW_META[option].icon;
516
+ return /* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => switchView(option), children: [
517
+ /* @__PURE__ */ jsx(Icon, { className: "mr-2 h-4 w-4" }),
518
+ /* @__PURE__ */ jsx("span", { className: "flex-1", children: VIEW_META[option].label }),
519
+ view === option && /* @__PURE__ */ jsx(Check, { className: "ml-2 h-4 w-4 text-primary" })
520
+ ] }, option);
521
+ }) })
522
+ ] })
523
+ ) : /* @__PURE__ */ jsx("div", { role: "tablist", "aria-label": "View", className: "flex shrink-0 items-center rounded-md border border-border bg-background/60 p-0.5", children: VIEWS.map((option) => {
524
+ const Icon = VIEW_META[option].icon;
525
+ return /* @__PURE__ */ jsxs(
526
+ "button",
527
+ {
528
+ type: "button",
529
+ role: "tab",
530
+ title: VIEW_META[option].label,
531
+ "aria-selected": view === option,
532
+ onClick: () => switchView(option),
533
+ className: cn(
534
+ "flex h-7 items-center gap-1 rounded px-2 text-xs font-medium text-muted-foreground",
535
+ view === option && "bg-primary/10 text-primary-ink"
536
+ ),
537
+ children: [
538
+ /* @__PURE__ */ jsx(Icon, { className: "h-3.5 w-3.5" }),
539
+ VIEW_META[option].label
540
+ ]
541
+ },
542
+ option
543
+ );
544
+ }) }),
545
+ !isMobile && /* @__PURE__ */ jsxs(Fragment, { children: [
546
+ /* @__PURE__ */ jsx("span", { className: "mx-1 h-5 w-px shrink-0 bg-border" }),
547
+ tools.map((tool) => /* @__PURE__ */ jsx(ToolbarButton, { label: tool.label, active: tool.active, onClick: tool.run, disabled: tool.disabled, children: /* @__PURE__ */ jsx(tool.icon, { className: "h-4 w-4" }) }, tool.id)),
548
+ micButton
549
+ ] })
550
+ ] }),
551
+ /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [
552
+ isMobile && micButton,
553
+ isMobile && /* @__PURE__ */ jsxs(DropdownMenu, { children: [
554
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
555
+ "button",
556
+ {
557
+ type: "button",
558
+ "aria-label": "More tools",
559
+ title: "More tools",
560
+ className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground",
561
+ children: /* @__PURE__ */ jsx(MoreHorizontal, { className: "h-4 w-4" })
562
+ }
563
+ ) }),
564
+ /* @__PURE__ */ jsx(DropdownMenuContent, { align: "end", className: "w-60", children: tools.map((tool) => /* @__PURE__ */ jsxs(DropdownMenuItem, { disabled: tool.disabled, onSelect: tool.run, children: [
565
+ /* @__PURE__ */ jsx(tool.icon, { className: "mr-2 h-4 w-4" }),
566
+ /* @__PURE__ */ jsx("span", { className: "flex-1", children: tool.label }),
567
+ tool.active && /* @__PURE__ */ jsx(Check, { className: "ml-2 h-4 w-4 text-primary" })
568
+ ] }, tool.id)) })
569
+ ] }),
570
+ !isMobile && /* @__PURE__ */ jsxs("span", { className: "shrink-0 whitespace-nowrap px-1 text-[11px] tabular-nums text-muted-foreground", title: documentFacts, children: [
571
+ formatCount(metrics.words),
572
+ " words"
573
+ ] }),
574
+ toolbarExtras,
575
+ cancelButton,
576
+ saveButton
577
+ ] })
578
+ ] }),
579
+ findMode && view !== "preview" && /* @__PURE__ */ jsx(
580
+ FindReplacePanel,
581
+ {
582
+ showReplace: findMode === "replace",
583
+ onFind: (query, options, step) => handle.current?.find(query, options, step) ?? { count: 0, current: -1, skippedProtected: 0, error: null },
584
+ onReplace: (query, replacement, options) => {
585
+ for (const raw of handle.current?.replaceCurrent(query, replacement, options) ?? []) approved.current.add(raw);
586
+ },
587
+ onReplaceAll: (query, replacement, options) => {
588
+ const before = handle.current?.find(query, options).count ?? 0;
589
+ for (const raw of handle.current?.replaceAll(query, replacement, options) ?? []) approved.current.add(raw);
590
+ return before;
591
+ },
592
+ onClose: () => {
593
+ handle.current?.clearFind();
594
+ setFindMode(null);
595
+ handle.current?.focus();
596
+ }
597
+ }
598
+ )
599
+ ] }),
600
+ /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1", children: [
601
+ outlineOpen && !focusMode && !isMobile && /* @__PURE__ */ jsx("aside", { className: "hidden w-56 shrink-0 border-r border-border bg-card/40 md:block", children: /* @__PURE__ */ jsx(OutlinePanel, { entries: outline, onJump: jump }) }),
602
+ /* @__PURE__ */ jsxs("div", { className: "relative min-h-0 min-w-0 flex-1", children: [
603
+ body,
604
+ mic.isRecording && mic.liveTranscript && /* @__PURE__ */ jsxs(
605
+ "div",
606
+ {
607
+ className: "pointer-events-none absolute inset-x-3 bottom-3 z-20 mx-auto max-w-xl truncate rounded-lg border border-border bg-popover/95 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur",
608
+ "aria-live": "polite",
609
+ children: [
610
+ /* @__PURE__ */ jsx(Mic, { className: "mr-1.5 inline h-3 w-3 text-destructive", "aria-hidden": true }),
611
+ mic.liveTranscript
612
+ ]
613
+ }
614
+ )
615
+ ] })
616
+ ] })
617
+ ]
618
+ }
619
+ ),
620
+ isMobile && /* @__PURE__ */ jsx(Sheet, { open: outlineOpen, onOpenChange: setOutlineOpen, children: /* @__PURE__ */ jsxs(SheetContent, { side: "bottom", className: "h-[70dvh] p-0 pb-safe", children: [
621
+ /* @__PURE__ */ jsx(SheetHeader, { className: "sr-only", children: /* @__PURE__ */ jsx(SheetTitle, { children: "Outline" }) }),
622
+ /* @__PURE__ */ jsx(OutlinePanel, { entries: outline, onJump: jump })
623
+ ] }) }),
624
+ /* @__PURE__ */ jsx(ShortcutsDialog, { open: helpOpen, onOpenChange: setHelpOpen }),
625
+ /* @__PURE__ */ jsx(
626
+ KindPicker,
627
+ {
628
+ open: kindRequest !== null,
629
+ onResolve: (markdown) => {
630
+ kindRequest?.(markdown);
631
+ setKindRequest(null);
632
+ }
633
+ }
634
+ ),
635
+ /* @__PURE__ */ jsx(
636
+ TextInputDialog,
637
+ {
638
+ open: linkDialog.open,
639
+ onOpenChange: (open) => setLinkDialog((state) => ({ ...state, open })),
640
+ title: "Link",
641
+ description: "Paste the address. Leave it empty to remove the link.",
642
+ placeholder: "https://",
643
+ defaultValue: linkDialog.href,
644
+ confirmLabel: "Apply",
645
+ onConfirm: (href) => {
646
+ handle.current?.editLink(href.trim() && href.trim() !== "https://" ? href.trim() : null);
647
+ setLinkDialog({ open: false, href: "" });
648
+ }
649
+ }
650
+ ),
651
+ /* @__PURE__ */ jsx(
652
+ "input",
653
+ {
654
+ ref: imageInput,
655
+ type: "file",
656
+ accept: "image/*",
657
+ className: "hidden",
658
+ onChange: (event) => {
659
+ const file = event.target.files?.[0];
660
+ event.target.value = "";
661
+ if (!file) return;
662
+ void uploadImage(file).then((markdown) => {
663
+ if (markdown) handle.current?.replaceSelection(markdown);
664
+ });
665
+ }
666
+ }
667
+ ),
668
+ /* @__PURE__ */ jsx(
669
+ ConfirmDialog,
670
+ {
671
+ open: pendingPlan !== null,
672
+ onOpenChange: (open) => !open && setPendingPlan(null),
673
+ title: "This save changes protected content",
674
+ description: pendingPlan?.error ?? "Kinds, XML sections, code, math and {{variables}} are protected. You did not change these through their own editors, so here is exactly what saving would do:",
675
+ content: pendingPlan && /* @__PURE__ */ jsx("ul", { className: "max-h-60 list-disc space-y-1 overflow-y-auto pl-5 text-sm", children: pendingPlan.needsConsent.map((delta, index) => /* @__PURE__ */ jsx("li", { children: describeDelta(delta) }, `${delta.at}-${index}`)) }),
676
+ confirmLabel: "Save anyway",
677
+ cancelLabel: "Go back and fix it",
678
+ onConfirm: async () => {
679
+ const plan = pendingPlan;
680
+ setPendingPlan(null);
681
+ if (plan) await doSave(plan);
682
+ }
683
+ }
684
+ )
685
+ ] });
686
+ return /* @__PURE__ */ jsx(Fragment, { children: withImagePolicy(imagePolicy, editor) });
687
+ }
688
+ function ToolbarButton({
689
+ label,
690
+ active,
691
+ disabled,
692
+ onClick,
693
+ children
694
+ }) {
695
+ return /* @__PURE__ */ jsx(
696
+ "button",
697
+ {
698
+ type: "button",
699
+ title: label,
700
+ "aria-label": label,
701
+ "aria-pressed": active,
702
+ disabled,
703
+ onClick,
704
+ className: cn(
705
+ "flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40",
706
+ active && "bg-primary/10 text-primary-ink"
707
+ ),
708
+ children
709
+ }
710
+ );
711
+ }
712
+ export {
713
+ RichEditorImpl as default
714
+ };