@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,95 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useState } from "react";
4
+ import {
5
+ Command,
6
+ CommandEmpty,
7
+ CommandGroup,
8
+ CommandInput,
9
+ CommandItem,
10
+ CommandList,
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogHeader,
15
+ DialogTitle
16
+ } from "@ai-matrx/design-system";
17
+ import { Shapes } from "lucide-react";
18
+ import { kindCanonicalExample, searchKindDefinitions } from "@ai-matrx/rich-content/host/app-bindings";
19
+ import { kindMarkdown } from "../core/commands.js";
20
+ import { ErrorAlchemyMenu } from "@ai-matrx/rich-content/host/ErrorActions";
21
+ import { asClause } from "@ai-matrx/kit/text";
22
+ const PICKER_LIMIT = 60;
23
+ function asRecord(value) {
24
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
25
+ }
26
+ async function searchKinds(query) {
27
+ return await searchKindDefinitions(query);
28
+ }
29
+ async function exampleFor(row) {
30
+ return asRecord(await kindCanonicalExample(row));
31
+ }
32
+ function KindPicker({
33
+ open,
34
+ onResolve
35
+ }) {
36
+ const [query, setQuery] = useState("");
37
+ const [rows, setRows] = useState([]);
38
+ const [state, setState] = useState("idle");
39
+ const [message, setMessage] = useState(null);
40
+ const [highlighted, setHighlighted] = useState("");
41
+ useEffect(() => {
42
+ if (!open) return;
43
+ let cancelled = false;
44
+ setState("loading");
45
+ const handle = window.setTimeout(() => {
46
+ searchKinds(query).then((found) => {
47
+ if (cancelled) return;
48
+ setRows(found);
49
+ setHighlighted(found[0]?.id ?? "");
50
+ setState("idle");
51
+ }).catch((error) => {
52
+ if (cancelled) return;
53
+ setState("error");
54
+ setMessage(error instanceof Error ? error.message : String(error));
55
+ });
56
+ }, 150);
57
+ return () => {
58
+ cancelled = true;
59
+ window.clearTimeout(handle);
60
+ };
61
+ }, [open, query]);
62
+ const choose = async (row) => {
63
+ try {
64
+ onResolve(kindMarkdown(row.kind, await exampleFor(row)));
65
+ } catch (error) {
66
+ setState("error");
67
+ setMessage(error instanceof Error ? error.message : String(error));
68
+ }
69
+ };
70
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: (next) => !next && onResolve(null), children: /* @__PURE__ */ jsxs(DialogContent, { className: "max-w-lg p-0", children: [
71
+ /* @__PURE__ */ jsxs(DialogHeader, { className: "px-4 pt-4", children: [
72
+ /* @__PURE__ */ jsxs(DialogTitle, { className: "flex items-center gap-2", children: [
73
+ /* @__PURE__ */ jsx(Shapes, { className: "h-4 w-4" }),
74
+ " Insert a structured block"
75
+ ] }),
76
+ /* @__PURE__ */ jsx(DialogDescription, { children: "Pick a kind from the shape registry. It is inserted with its example data as a protected block you edit in its own editor." })
77
+ ] }),
78
+ /* @__PURE__ */ jsxs(Command, { shouldFilter: false, value: highlighted, onValueChange: setHighlighted, className: "border-t border-border", children: [
79
+ /* @__PURE__ */ jsx(CommandInput, { value: query, onValueChange: setQuery, placeholder: "Search kinds \u2014 flashcards, checklist, timeline\u2026" }),
80
+ /* @__PURE__ */ jsxs(CommandList, { className: "max-h-[50dvh]", children: [
81
+ state === "error" && /* @__PURE__ */ jsxs("div", { className: "px-4 py-3 text-sm text-destructive", children: [
82
+ "The kind list could not load: ",
83
+ asClause(message),
84
+ ". Close this and try again.",
85
+ /* @__PURE__ */ jsx(ErrorAlchemyMenu, {})
86
+ ] }),
87
+ state !== "error" && /* @__PURE__ */ jsx(CommandEmpty, { children: state === "loading" ? "Searching the shape registry\u2026" : "No kind matches that search." }),
88
+ /* @__PURE__ */ jsx(CommandGroup, { children: rows.map((row) => /* @__PURE__ */ jsx(CommandItem, { value: row.id, onSelect: () => void choose(row), children: /* @__PURE__ */ jsx("span", { className: "flex-1 truncate", children: row.label }) }, row.id)) })
89
+ ] })
90
+ ] })
91
+ ] }) });
92
+ }
93
+ export {
94
+ KindPicker
95
+ };
@@ -0,0 +1,6 @@
1
+ import type { OutlineEntry } from "../core/outline.js";
2
+ export declare function OutlinePanel({ entries, onJump, className, }: {
3
+ entries: readonly OutlineEntry[];
4
+ onJump: (entry: OutlineEntry) => void;
5
+ className?: string;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,60 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useClipboard } from "@ai-matrx/kit/clipboard";
4
+ import { Link2, ListTree } from "lucide-react";
5
+ import { toast } from "@ai-matrx/rich-content/host/toast";
6
+ import { cn } from "@ai-matrx/design-system";
7
+ function OutlinePanel({
8
+ entries,
9
+ onJump,
10
+ className
11
+ }) {
12
+ const { copyText } = useClipboard({
13
+ notify: (message, kind) => kind === "error" ? toast.error(message) : toast.success(message)
14
+ });
15
+ const minLevel = Math.min(...entries.map((entry) => entry.level), 6);
16
+ const copyLink = async (entry) => {
17
+ const url = `${window.location.origin}${window.location.pathname}${window.location.search}#${entry.slug}`;
18
+ try {
19
+ if (!await copyText(url)) return;
20
+ toast.success(`Link to \u201C${entry.text}\u201D copied.`);
21
+ } catch {
22
+ toast.error("Copy failed \u2014 your browser blocked clipboard access.");
23
+ }
24
+ };
25
+ return /* @__PURE__ */ jsxs("nav", { "aria-label": "Outline", className: cn("flex h-full flex-col overflow-hidden", className), children: [
26
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5 px-3 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: [
27
+ /* @__PURE__ */ jsx(ListTree, { className: "h-3.5 w-3.5" }),
28
+ " Outline"
29
+ ] }),
30
+ /* @__PURE__ */ jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-1 pb-4", children: [
31
+ entries.length === 0 && /* @__PURE__ */ jsx("p", { className: "px-2 text-xs text-muted-foreground", children: "Headings you add appear here. Type \u201C# \u201D at the start of a line, or use / \u2192 Heading." }),
32
+ entries.map((entry, index) => /* @__PURE__ */ jsxs("div", { className: "group/outline flex items-center", children: [
33
+ /* @__PURE__ */ jsx(
34
+ "button",
35
+ {
36
+ type: "button",
37
+ onClick: () => onJump(entry),
38
+ className: "min-w-0 flex-1 truncate rounded px-2 py-1 text-left text-sm text-foreground/80 hover:bg-muted hover:text-foreground",
39
+ style: { paddingLeft: `${0.5 + (entry.level - minLevel) * 0.75}rem` },
40
+ title: entry.text,
41
+ children: entry.text || "(empty heading)"
42
+ }
43
+ ),
44
+ /* @__PURE__ */ jsx(
45
+ "button",
46
+ {
47
+ type: "button",
48
+ "aria-label": `Copy link to ${entry.text}`,
49
+ onClick: () => void copyLink(entry),
50
+ className: "rounded p-1 text-muted-foreground opacity-0 hover:bg-muted hover:text-foreground group-hover/outline:opacity-100 focus:opacity-100",
51
+ children: /* @__PURE__ */ jsx(Link2, { className: "h-3.5 w-3.5" })
52
+ }
53
+ )
54
+ ] }, `${entry.slug}-${index}`))
55
+ ] })
56
+ ] });
57
+ }
58
+ export {
59
+ OutlinePanel
60
+ };
@@ -0,0 +1,4 @@
1
+ export declare function ShortcutsDialog({ open, onOpenChange }: {
2
+ open: boolean;
3
+ onOpenChange: (open: boolean) => void;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@ai-matrx/design-system";
4
+ import { RICH_EDITOR_SHORTCUTS, formatKeys } from "../core/shortcuts.js";
5
+ const GROUPS = ["Text", "Blocks", "Insert", "Document", "Views"];
6
+ function ShortcutsDialog({ open, onOpenChange }) {
7
+ const apple = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
8
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "max-h-[85dvh] max-w-2xl overflow-y-auto", children: [
9
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
10
+ /* @__PURE__ */ jsx(DialogTitle, { children: "Keyboard shortcuts" }),
11
+ /* @__PURE__ */ jsx(DialogDescription, { children: "The same keys work in the visual and source views. Where Google Docs and Notion differ, both work." })
12
+ ] }),
13
+ /* @__PURE__ */ jsx("div", { className: "grid gap-4 sm:grid-cols-2", children: GROUPS.map((group) => /* @__PURE__ */ jsxs("section", { children: [
14
+ /* @__PURE__ */ jsx("h3", { className: "mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: group }),
15
+ /* @__PURE__ */ jsx("ul", { className: "space-y-1", children: RICH_EDITOR_SHORTCUTS.filter((spec) => spec.group === group).map((spec) => /* @__PURE__ */ jsxs("li", { className: "flex items-center justify-between gap-2 text-sm", children: [
16
+ /* @__PURE__ */ jsx("span", { children: spec.label }),
17
+ /* @__PURE__ */ jsx("span", { className: "flex shrink-0 gap-1", children: spec.keys.map((key) => /* @__PURE__ */ jsx("kbd", { className: "rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[11px]", children: formatKeys(key, apple) }, key)) })
18
+ ] }, spec.id)) })
19
+ ] }, group)) })
20
+ ] }) });
21
+ }
22
+ export {
23
+ ShortcutsDialog
24
+ };
@@ -0,0 +1,28 @@
1
+ import { type Ref } from "react";
2
+ import type { EditorViewHandle } from "../visual/VisualEditor.js";
3
+ import type { RichShellActions } from "../visual/visual-extensions.js";
4
+ export interface SourceEditorProps {
5
+ initialText: string;
6
+ onChange: (text: string) => void;
7
+ shell: RichShellActions;
8
+ placeholder?: string;
9
+ focusMode: boolean;
10
+ renderIslands: boolean;
11
+ handleRef: Ref<EditorViewHandle>;
12
+ /**
13
+ * CONTROLLED text. When the host replaces the whole document (a template or
14
+ * sample loaded, an agent write, Clear), the editor follows. The editor's own
15
+ * keystrokes echo back as the same string and are ignored cheaply.
16
+ */
17
+ value?: string;
18
+ /** "document" (default): the centred reading column. "pane": fills a tool pane edge to edge. */
19
+ layout?: "document" | "pane";
20
+ /** The editor's scroll container scrolled (a host that syncs another pane). */
21
+ onScroll?: () => void;
22
+ /**
23
+ * RAW: the bytes as plain monospace text — no live-preview decorations, no
24
+ * markdown styling. The same editor (history, keymap, paste), just undressed.
25
+ */
26
+ raw?: boolean;
27
+ }
28
+ export declare function SourceEditor({ initialText, onChange, shell, placeholder, focusMode, renderIslands, handleRef, value, layout, onScroll, raw, }: SourceEditorProps): import("react").JSX.Element;
@@ -0,0 +1,429 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useEffectEvent, useImperativeHandle, useRef, useState, useSyncExternalStore } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { EditorSelection, EditorState, Prec } from "@codemirror/state";
6
+ import { EditorView, keymap, placeholder as cmPlaceholder } from "@codemirror/view";
7
+ import {
8
+ defaultKeymap,
9
+ history,
10
+ historyKeymap,
11
+ redoDepth,
12
+ undoDepth,
13
+ indentWithTab,
14
+ moveLineDown,
15
+ moveLineUp,
16
+ redo,
17
+ undo
18
+ } from "@codemirror/commands";
19
+ import { autocompletion } from "@codemirror/autocomplete";
20
+ import { getSchema } from "@tiptap/core";
21
+ import { cn } from "@ai-matrx/design-system";
22
+ import { toast } from "@ai-matrx/rich-content/host/toast";
23
+ import { createRichEditorExtensions } from "../core/extensions.js";
24
+ import { locateCaret } from "../core/caret-context.js";
25
+ import { htmlToMarkdown } from "../core/html-to-markdown.js";
26
+ import { mergedCellsNotice, normalizePastedHtml } from "../core/paste-html.js";
27
+ import { findMatches, replaceMatches } from "../core/find-replace.js";
28
+ import { continueMarkupOnEnter, makeLink, setLinePrefix, toggleWrap } from "../core/source-format.js";
29
+ import { markdownSourceLanguage } from "./markdown-language.js";
30
+ import { formatMarkdown } from "../core/markdown-format.js";
31
+ import { markdownFormatHost, textFormatTarget } from "../format/format-target.js";
32
+ import { useSelectionZone } from "@ai-matrx/rich-content/selection-toolbar/selection-zones";
33
+ import { RICH_EDITOR_SHORTCUTS, TYPED_TRIGGERS } from "../core/shortcuts.js";
34
+ import { variableNamesInText, variableSuggestions } from "../core/variables.js";
35
+ import { islandsReleasedBetweenTexts } from "../core/history-approval.js";
36
+ import { IslandPreview } from "../islands/IslandPreview.js";
37
+ import { useRichEditorContext } from "../editor/RichEditorContext.js";
38
+ import { richEditorTheme, richHighlight } from "./cm-theme.js";
39
+ import {
40
+ IslandPortalRegistry,
41
+ livePreviewExtensions,
42
+ findField,
43
+ setFindHighlights,
44
+ setIslandRendering
45
+ } from "./live-preview.js";
46
+ import { blockBoundary } from "../core/text-insertion.js";
47
+ const RAW_THEME = EditorView.theme({
48
+ ".cm-content, .cm-line": {
49
+ fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
50
+ fontSize: "13px"
51
+ }
52
+ });
53
+ const PASTE_SCHEMA = getSchema(createRichEditorExtensions());
54
+ function applyResult(view, result) {
55
+ view.dispatch({
56
+ changes: result.changes,
57
+ selection: EditorSelection.single(result.anchor, result.head),
58
+ scrollIntoView: true,
59
+ userEvent: "input.format"
60
+ });
61
+ return true;
62
+ }
63
+ function wrap(marker) {
64
+ return (view) => {
65
+ const { from, to } = view.state.selection.main;
66
+ return applyResult(view, toggleWrap(view.state.doc.toString(), from, to, marker));
67
+ };
68
+ }
69
+ function command(id) {
70
+ return (view) => {
71
+ if (view.state.readOnly) return false;
72
+ const { from, to } = view.state.selection.main;
73
+ const result = formatMarkdown(view.state.doc.toString(), from, to, id);
74
+ return result.changes.length ? applyResult(view, result) : true;
75
+ };
76
+ }
77
+ function sourceFormatTarget(getView) {
78
+ return textFormatTarget(
79
+ "source",
80
+ () => {
81
+ const v = getView();
82
+ if (!v) return null;
83
+ const { from, to } = v.state.selection.main;
84
+ return { text: v.state.doc.toString(), from, to, editable: !v.state.readOnly };
85
+ },
86
+ (result) => {
87
+ const v = getView();
88
+ if (v) applyResult(v, result);
89
+ }
90
+ );
91
+ }
92
+ function prefix(value) {
93
+ return (view) => {
94
+ const { from, to } = view.state.selection.main;
95
+ return applyResult(view, setLinePrefix(view.state.doc.toString(), from, to, value));
96
+ };
97
+ }
98
+ function SourceEditor({
99
+ initialText,
100
+ onChange,
101
+ shell,
102
+ placeholder,
103
+ focusMode,
104
+ renderIslands,
105
+ handleRef,
106
+ value,
107
+ layout = "document",
108
+ onScroll,
109
+ raw = false
110
+ }) {
111
+ const context = useRichEditorContext();
112
+ const host = useRef(null);
113
+ const scroller = useRef(null);
114
+ const lastEmitted = useRef(initialText);
115
+ const view = useRef(null);
116
+ const [registry] = useState(() => new IslandPortalRegistry());
117
+ const portals = useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot);
118
+ const findState = useRef({ matches: [], index: -1 });
119
+ const emitChange = useEffectEvent((text) => onChange(text));
120
+ const getShell = useEffectEvent(() => shell);
121
+ const getVariables = useEffectEvent(() => context.variables);
122
+ const approveIsland = useEffectEvent((raw2) => context.approveIsland(raw2));
123
+ const initial = useEffectEvent(() => initialText);
124
+ useEffect(() => {
125
+ if (!host.current) return;
126
+ const verbs = {
127
+ bold: command("bold"),
128
+ italic: command("italic"),
129
+ strike: command("strike"),
130
+ code: command("code"),
131
+ link: command("link"),
132
+ paragraph: prefix(null),
133
+ heading1: command("heading1"),
134
+ heading2: command("heading2"),
135
+ heading3: command("heading3"),
136
+ heading4: prefix("#### "),
137
+ heading5: prefix("##### "),
138
+ heading6: prefix("###### "),
139
+ orderedList: command("orderedList"),
140
+ bulletList: command("bulletList"),
141
+ taskList: command("taskList"),
142
+ quote: command("quote"),
143
+ moveUp: moveLineUp,
144
+ moveDown: moveLineDown,
145
+ codeBlock: command("codeBlock"),
146
+ inlineMath: wrap("$"),
147
+ undo,
148
+ redo,
149
+ find: () => (getShell().find(), true),
150
+ replace: () => (getShell().replace(), true),
151
+ save: () => (getShell().save(), true),
152
+ wordCount: () => (getShell().showWordCount(), true),
153
+ outline: () => (getShell().toggleOutline(), true),
154
+ focus: () => (getShell().toggleFocus(), true),
155
+ cycleView: () => (getShell().cycleView(), true),
156
+ help: () => (getShell().showHelp(), true)
157
+ };
158
+ const bindings = RICH_EDITOR_SHORTCUTS.flatMap(
159
+ (spec) => spec.keys.filter((key) => !TYPED_TRIGGERS.has(key) && verbs[spec.id]).map((key) => ({ key, run: verbs[spec.id], preventDefault: true }))
160
+ );
161
+ const variableCompletion = (completion) => {
162
+ const match = completion.matchBefore(/\{\{[^{}\n]*/);
163
+ if (!match) return null;
164
+ const typed = match.text.slice(2);
165
+ const inDocument = variableNamesInText(completion.state.doc.toString());
166
+ const options = variableSuggestions(getVariables() ?? [], inDocument, typed).map((suggestion) => ({
167
+ label: `{{${suggestion.name}}}`,
168
+ detail: suggestion.source === "new" ? "new variable" : suggestion.source === "document" ? "in this document" : suggestion.type,
169
+ info: suggestion.description,
170
+ apply: `{{${suggestion.name}}}`
171
+ }));
172
+ return { from: match.from, options, validFor: /^\{\{[^{}\n]*$/ };
173
+ };
174
+ const instance = new EditorView({
175
+ parent: host.current,
176
+ state: EditorState.create({
177
+ doc: initial(),
178
+ extensions: [
179
+ history(),
180
+ Prec.high(keymap.of(bindings)),
181
+ keymap.of([
182
+ {
183
+ key: "Enter",
184
+ run: (v) => {
185
+ const { from, to } = v.state.selection.main;
186
+ if (from !== to) return false;
187
+ const result = continueMarkupOnEnter(v.state.doc.toString(), from);
188
+ return result ? applyResult(v, result) : false;
189
+ }
190
+ },
191
+ indentWithTab,
192
+ ...defaultKeymap,
193
+ ...historyKeymap
194
+ ]),
195
+ ...raw ? [] : [markdownSourceLanguage, richHighlight],
196
+ richEditorTheme,
197
+ ...raw ? [RAW_THEME] : [],
198
+ EditorView.lineWrapping,
199
+ cmPlaceholder(placeholder ?? "Write\u2026"),
200
+ EditorView.editable.of(!context.readOnly),
201
+ EditorView.contentAttributes.of({ "aria-label": "Document source", spellcheck: "true" }),
202
+ autocompletion({ override: [variableCompletion], activateOnTyping: true }),
203
+ ...raw ? [findField] : [livePreviewExtensions({ registry, getVariables: () => getVariables() })],
204
+ EditorView.domEventHandlers({
205
+ paste: (event, v) => {
206
+ const html = event.clipboardData?.getData("text/html");
207
+ const images = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith("image/"));
208
+ if (images.length) {
209
+ event.preventDefault();
210
+ for (const image of images) {
211
+ void getShell().uploadImage(image).then((markdownImage) => {
212
+ if (!markdownImage) return;
213
+ const { from: from2, to: to2 } = v.state.selection.main;
214
+ v.dispatch({ changes: { from: from2, to: to2, insert: markdownImage } });
215
+ });
216
+ }
217
+ return true;
218
+ }
219
+ if (!html || !html.trim()) return false;
220
+ event.preventDefault();
221
+ const notice = mergedCellsNotice(normalizePastedHtml(html).mergedCellsSplit);
222
+ if (notice) toast.info(notice);
223
+ const converted = htmlToMarkdown(html, PASTE_SCHEMA);
224
+ const { from, to } = v.state.selection.main;
225
+ v.dispatch({
226
+ changes: { from, to, insert: converted },
227
+ selection: { anchor: from + converted.length },
228
+ userEvent: "input.paste"
229
+ });
230
+ return true;
231
+ }
232
+ }),
233
+ EditorView.updateListener.of((update) => {
234
+ if (!update.docChanged) return;
235
+ const text = update.state.doc.toString();
236
+ if (update.transactions.some((tr) => tr.isUserEvent("undo") || tr.isUserEvent("redo"))) {
237
+ for (const raw2 of islandsReleasedBetweenTexts(update.startState.doc.toString(), text)) approveIsland(raw2);
238
+ }
239
+ lastEmitted.current = text;
240
+ emitChange(text);
241
+ })
242
+ ]
243
+ })
244
+ });
245
+ view.current = instance;
246
+ return () => {
247
+ instance.destroy();
248
+ view.current = null;
249
+ };
250
+ }, [registry, context.readOnly, placeholder, raw]);
251
+ useEffect(() => {
252
+ view.current?.dispatch({ effects: setIslandRendering.of(renderIslands) });
253
+ }, [renderIslands]);
254
+ useEffect(() => {
255
+ const v = view.current;
256
+ if (value === void 0 || !v || value === lastEmitted.current) return;
257
+ if (v.state.doc.toString() === value) {
258
+ lastEmitted.current = value;
259
+ return;
260
+ }
261
+ lastEmitted.current = value;
262
+ v.dispatch({
263
+ changes: { from: 0, to: v.state.doc.length, insert: value },
264
+ selection: { anchor: Math.min(v.state.selection.main.head, value.length) },
265
+ userEvent: "input.replace"
266
+ });
267
+ }, [value]);
268
+ const highlight = (current) => {
269
+ view.current?.dispatch({
270
+ effects: setFindHighlights.of(
271
+ findState.current.matches.map((match, index) => ({ from: match.start, to: match.end, current: index === current }))
272
+ )
273
+ });
274
+ };
275
+ const [zoneElement, setZoneElement] = useState(null);
276
+ useSelectionZone(zoneElement, { editable: !context.readOnly, host: markdownFormatHost(sourceFormatTarget(() => view.current)) });
277
+ useImperativeHandle(handleRef, () => ({
278
+ focus: () => view.current?.focus(),
279
+ selectedText: () => {
280
+ const v = view.current;
281
+ if (!v) return "";
282
+ const { from, to } = v.state.selection.main;
283
+ return v.state.sliceDoc(from, to);
284
+ },
285
+ replaceSelection: (text) => {
286
+ const v = view.current;
287
+ if (!v) return;
288
+ const { from, to } = v.state.selection.main;
289
+ v.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from, head: from + text.length } });
290
+ },
291
+ insertText: (text, where) => {
292
+ const v = view.current;
293
+ if (!v) return false;
294
+ const { from, to } = v.state.selection.main;
295
+ const at = blockBoundary(v.state.doc.toString(), from, to, where);
296
+ const insert = where === "before" ? `${text}
297
+
298
+ ` : `
299
+
300
+ ${text}`;
301
+ v.dispatch({ changes: { from: at, insert } });
302
+ return true;
303
+ },
304
+ flush: () => view.current?.state.doc.toString() ?? initialText,
305
+ historyDepth: () => {
306
+ const v = view.current;
307
+ return v ? { undo: undoDepth(v.state), redo: redoDepth(v.state) } : { undo: 0, redo: 0 };
308
+ },
309
+ placeCaret: (context2) => {
310
+ const v = view.current;
311
+ if (!v) return false;
312
+ const index = locateCaret(v.state.doc.toString(), context2);
313
+ if (index === null) return false;
314
+ v.dispatch({ selection: { anchor: index }, effects: EditorView.scrollIntoView(index, { y: "center" }) });
315
+ v.focus();
316
+ return true;
317
+ },
318
+ scrollToHeading: (_slug, offset) => {
319
+ const v = view.current;
320
+ if (!v) return;
321
+ v.dispatch({ selection: { anchor: offset }, effects: EditorView.scrollIntoView(offset, { y: "start" }) });
322
+ v.focus();
323
+ },
324
+ find: (query, options, step) => {
325
+ const v = view.current;
326
+ if (!v) return { count: 0, current: -1, skippedProtected: 0, error: null };
327
+ const result = findMatches(v.state.doc.toString(), query, options);
328
+ const cursor = v.state.selection.main.from;
329
+ let index = result.matches.findIndex((match2) => match2.start >= cursor);
330
+ if (index === -1) index = result.matches.length ? 0 : -1;
331
+ const previous = findState.current;
332
+ if (step && previous.index >= 0 && result.matches.length) {
333
+ index = (previous.index + step + result.matches.length) % result.matches.length;
334
+ }
335
+ findState.current = { matches: result.matches, index };
336
+ highlight(index);
337
+ const match = result.matches[index];
338
+ if (match && step) {
339
+ v.dispatch({ selection: { anchor: match.start, head: match.end }, scrollIntoView: true });
340
+ }
341
+ return { count: result.matches.length, current: index, skippedProtected: result.skippedProtected, error: result.error };
342
+ },
343
+ replaceCurrent: (query, replacement, options) => {
344
+ const v = view.current;
345
+ const match = findState.current.matches[findState.current.index];
346
+ if (!v || !match) return [];
347
+ const text = v.state.doc.toString();
348
+ const found = findMatches(text, query, options).matches.find((candidate) => candidate.start === match.start);
349
+ if (!found) return [];
350
+ const next = replaceMatches(text, [found], query, replacement, options);
351
+ const insert = next.slice(found.start, next.length - (text.length - found.end));
352
+ v.dispatch({ changes: { from: found.start, to: found.end, insert } });
353
+ return found.islandRaw ? [found.islandRaw] : [];
354
+ },
355
+ replaceAll: (query, replacement, options) => {
356
+ const v = view.current;
357
+ if (!v) return [];
358
+ const text = v.state.doc.toString();
359
+ const { matches } = findMatches(text, query, options);
360
+ const changes = matches.map((match) => {
361
+ const next = replaceMatches(text, [match], query, replacement, options);
362
+ return { from: match.start, to: match.end, insert: next.slice(match.start, next.length - (text.length - match.end)) };
363
+ });
364
+ v.dispatch({ changes });
365
+ return matches.flatMap((match) => match.islandRaw ? [match.islandRaw] : []);
366
+ },
367
+ clearFind: () => {
368
+ findState.current = { matches: [], index: -1 };
369
+ highlight(-1);
370
+ },
371
+ editLink: (href) => {
372
+ const v = view.current;
373
+ if (!v || !href) return;
374
+ const { from, to } = v.state.selection.main;
375
+ applyResult(v, makeLink(v.state.doc.toString(), from, to, href));
376
+ },
377
+ currentLink: () => null,
378
+ scroller: () => scroller.current,
379
+ topLine: () => {
380
+ const v = view.current;
381
+ const el = scroller.current;
382
+ if (!v || !el) return 0;
383
+ const y = el.getBoundingClientRect().top - v.documentTop;
384
+ if (y <= 0) return 0;
385
+ const block = v.lineBlockAtHeight(y);
386
+ const index = v.state.doc.lineAt(block.from).number - 1;
387
+ return index + (block.height > 0 ? Math.min(1, Math.max(0, (y - block.top) / block.height)) : 0);
388
+ },
389
+ scrollToLine: (lineIndex) => {
390
+ const v = view.current;
391
+ if (!v) return;
392
+ const index = Math.min(Math.max(0, Math.floor(lineIndex)), v.state.doc.lines - 1);
393
+ const line = v.state.doc.line(index + 1);
394
+ const at = line.from + Math.round((lineIndex - index) * line.length);
395
+ v.dispatch({ effects: EditorView.scrollIntoView(at, { y: "start" }) });
396
+ }
397
+ }));
398
+ return /* @__PURE__ */ jsxs(
399
+ "div",
400
+ {
401
+ ref: scroller,
402
+ onScroll,
403
+ className: cn("rich-editor-source relative h-full overflow-y-auto", focusMode && "rich-editor-focus"),
404
+ children: [
405
+ /* @__PURE__ */ jsx(
406
+ "div",
407
+ {
408
+ ref: (node) => {
409
+ host.current = node;
410
+ setZoneElement(node);
411
+ },
412
+ className: layout === "pane" ? "min-h-full" : "mx-auto min-h-full max-w-3xl pb-[40dvh]",
413
+ "data-testid": "rich-editor-source"
414
+ }
415
+ ),
416
+ portals.map(
417
+ (portal, index) => createPortal(
418
+ /* @__PURE__ */ jsx("div", { className: "cm-rich-island-body pointer-events-none", children: /* @__PURE__ */ jsx(IslandPreview, { raw: portal.raw, islandType: portal.islandType }) }),
419
+ portal.element,
420
+ `island-${index}`
421
+ )
422
+ )
423
+ ]
424
+ }
425
+ );
426
+ }
427
+ export {
428
+ SourceEditor
429
+ };
@@ -0,0 +1,4 @@
1
+ export declare const richEditorTheme: import("@codemirror/state").Extension;
2
+ /** Code-shaped editors (islands) use the mono face. */
3
+ export declare const monoTheme: import("@codemirror/state").Extension;
4
+ export declare const richHighlight: import("@codemirror/state").Extension;