@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3
4
+ ### Unreleased
5
+
6
+ ### Shared fence parsing
7
+
8
+ - Code-block formatting uses Content IR's canonical fence opener. Invalid
9
+ backtick info is not mistaken for a block that should be unwrapped.
10
+
11
+ ### Consumer action
12
+
13
+ - None required. Formatting commands and selection contracts are unchanged.
14
+
3
15
  ## 0.1.2
4
16
 
5
17
  The packed-tarball canary now uses the public source-edit helper and proves both resulting markdown
@@ -22,3 +34,4 @@ approval, host value, block insert, shortcuts), `in-place/{in-place-session,care
22
34
  ### Consumer action
23
35
 
24
36
  - matrx-frontend: import `@ai-matrx/rich-editor/core/<module>` instead of `@/components/rich-editor/core/<module>`.
37
+
@@ -1,3 +1,4 @@
1
+ import { fenceOpenerOf } from "@ai-matrx/content-ir/source";
1
2
  const FORMAT_COMMAND_IDS = [
2
3
  "bold",
3
4
  "italic",
@@ -217,12 +218,11 @@ function quoteCommand(text, from, to) {
217
218
  const head = from === to ? anchor : mapPos(to, ordered, 1);
218
219
  return { changes: ordered, anchor, head };
219
220
  }
220
- const FENCE = /^ {0,3}(`{3,}|~{3,})/;
221
221
  function codeBlockCommand(text, from, to) {
222
222
  const lines = linesTouched(text, from, to);
223
223
  const first = lines[0];
224
224
  const last = lines[lines.length - 1];
225
- if (lines.length >= 2 && FENCE.test(first.text) && FENCE.test(last.text)) {
225
+ if (lines.length >= 2 && fenceOpenerOf(first.text) && fenceOpenerOf(last.text)) {
226
226
  const changes2 = [
227
227
  { from: first.start, to: Math.min(first.end + 1, text.length), insert: "" },
228
228
  { from: last.start - 1, to: last.end, insert: "" }
@@ -236,7 +236,7 @@ function codeBlockCommand(text, from, to) {
236
236
  const nextStart = last.end + 1;
237
237
  const nextBreak = text.indexOf("\n", nextStart);
238
238
  const nextLine = nextStart <= text.length ? text.slice(nextStart, nextBreak === -1 ? text.length : nextBreak) : "";
239
- if (prevEnd >= 0 && FENCE.test(prevLine) && nextStart <= text.length && FENCE.test(nextLine) && nextLine.trim().replace(/[`~]/g, "") === "") {
239
+ if (prevEnd >= 0 && fenceOpenerOf(prevLine) && nextStart <= text.length && fenceOpenerOf(nextLine) && nextLine.trim().replace(/[`~]/g, "") === "") {
240
240
  const nextEnd = nextBreak === -1 ? text.length : nextBreak;
241
241
  const changes2 = [
242
242
  { from: prevStart, to: first.start, insert: "" },
@@ -0,0 +1,14 @@
1
+ export declare function insertTextAtTextareaCursor(textarea: HTMLTextAreaElement, text: string, onValueChange?: (nextValue: string) => void): boolean;
2
+ export declare function insertTextByRef(textareaRef: HTMLTextAreaElement | null, text: string): boolean;
3
+ /**
4
+ * WHERE A BLOCK GOES IN PLAIN TEXT — never inside a word.
5
+ *
6
+ * A block (a reference fence, a pasted section) inserted at a caret that sits
7
+ * inside a word split it: "of" became "o" + block + "f" (G5 review,
8
+ * 2026-10-02). A block belongs on its own line, so it lands at the END of the
9
+ * caret's line ("after") or the START of it ("before"). A non-empty selection
10
+ * is replaced where it is — the person chose that range. Every plain-text
11
+ * surface (textarea, the rich editor's source view, a contentEditable text
12
+ * node) uses this one rule.
13
+ */
14
+ export declare function blockBoundary(text: string, from: number, to: number, where?: "before" | "after"): number;
@@ -0,0 +1,55 @@
1
+ function insertTextAtTextareaCursor(textarea, text, onValueChange) {
2
+ try {
3
+ if (!textarea) {
4
+ return false;
5
+ }
6
+ const start = textarea.selectionStart;
7
+ const end = textarea.selectionEnd;
8
+ const currentValue = textarea.value;
9
+ const newValue = currentValue.substring(0, start) + text + currentValue.substring(end);
10
+ if (typeof onValueChange === "function") {
11
+ onValueChange(newValue);
12
+ textarea.focus();
13
+ requestAnimationFrame(() => {
14
+ const newCursorPos2 = start + text.length;
15
+ textarea.setSelectionRange(newCursorPos2, newCursorPos2);
16
+ });
17
+ return true;
18
+ }
19
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
20
+ window.HTMLTextAreaElement.prototype,
21
+ "value"
22
+ )?.set;
23
+ if (nativeInputValueSetter) {
24
+ nativeInputValueSetter.call(textarea, newValue);
25
+ } else {
26
+ textarea.value = newValue;
27
+ }
28
+ const newCursorPos = start + text.length;
29
+ textarea.setSelectionRange(newCursorPos, newCursorPos);
30
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
31
+ textarea.dispatchEvent(new Event("change", { bubbles: true }));
32
+ textarea.focus();
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+ function insertTextByRef(textareaRef, text) {
39
+ if (!textareaRef) return false;
40
+ return insertTextAtTextareaCursor(textareaRef, text);
41
+ }
42
+ function blockBoundary(text, from, to, where = "after") {
43
+ if (where === "after") {
44
+ const newline2 = text.indexOf("\n", to);
45
+ return newline2 === -1 ? text.length : newline2;
46
+ }
47
+ if (from <= 0) return 0;
48
+ const newline = text.lastIndexOf("\n", from - 1);
49
+ return newline === -1 ? 0 : newline + 1;
50
+ }
51
+ export {
52
+ blockBoundary,
53
+ insertTextAtTextareaCursor,
54
+ insertTextByRef
55
+ };
@@ -0,0 +1,4 @@
1
+ import type { RichEditorProps } from "./RichEditorImpl.js";
2
+ export type { RichEditorProps, RichEditorView, RichEditorController } from "./RichEditorImpl.js";
3
+ declare const RichEditor: import("react").ComponentType<RichEditorProps>;
4
+ export default RichEditor;
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { clientLazy } from "@ai-matrx/rich-content/utils/client-lazy";
4
+ import { Loader2 } from "lucide-react";
5
+ const RichEditor = clientLazy(() => import("./RichEditorImpl.js"), {
6
+ loading: () => /* @__PURE__ */ jsxs("div", { className: "flex h-full min-h-40 items-center justify-center gap-2 text-sm text-muted-foreground", children: [
7
+ /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
8
+ " Opening the editor\u2026"
9
+ ] })
10
+ });
11
+ var RichEditor_default = RichEditor;
12
+ export {
13
+ RichEditor_default as default
14
+ };
@@ -0,0 +1,16 @@
1
+ import type { DeclaredVariable } from "../core/variables.js";
2
+ export interface RichEditorContextValue {
3
+ /** Variables the surface declares; null when it declares none (a note). */
4
+ variables: readonly DeclaredVariable[] | null;
5
+ /** Record that the person changed or removed this island ON PURPOSE (its own editor, its Remove). */
6
+ approveIsland: (raw: string) => void;
7
+ /** Upload an image and return the markdown that shows it, or null when it failed (the uploader said why). */
8
+ uploadImage: (file: File) => Promise<string | null>;
9
+ /** Ask the person for a kind to insert; resolves with the kind JSON's markdown, or null. */
10
+ pickKind: () => Promise<string | null>;
11
+ /** A new variable the person created from the {{ menu. */
12
+ onDeclareVariable?: (name: string) => void;
13
+ readOnly: boolean;
14
+ }
15
+ export declare const RichEditorContext: import("react").Context<RichEditorContextValue>;
16
+ export declare function useRichEditorContext(): RichEditorContextValue;
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { createContext, useContext } from "react";
3
+ const noop = () => void 0;
4
+ const RichEditorContext = createContext({
5
+ variables: null,
6
+ approveIsland: noop,
7
+ uploadImage: async () => null,
8
+ pickKind: async () => null,
9
+ readOnly: false
10
+ });
11
+ function useRichEditorContext() {
12
+ return useContext(RichEditorContext);
13
+ }
14
+ export {
15
+ RichEditorContext,
16
+ useRichEditorContext
17
+ };
@@ -0,0 +1,137 @@
1
+ import "./rich-editor.css";
2
+ import { type ReactNode, type Ref } from "react";
3
+ import type { SourceFeature } from "@ai-matrx/agents/generated/source-attribution";
4
+ import type { ContentSource } from "@ai-matrx/rich-content/rich-document/types";
5
+ import { type SavePlan } from "../core/save-plan.js";
6
+ import type { CaretContext } from "../core/caret-context.js";
7
+ import type { DeclaredVariable } from "../core/variables.js";
8
+ import { type ImagePolicyDeclaration } from "@ai-matrx/rich-content/levels/prose/remote-image-policy";
9
+ import type { AnnotationRecord } from "@ai-matrx/rich-content/rich-document/host-types";
10
+ export type RichEditorView = "visual" | "source" | "preview";
11
+ /**
12
+ * What a host that carries its OWN chrome (view switch, find, outline, dictation,
13
+ * its own right-click menu) drives the editor through. Every verb acts on the
14
+ * view that is showing.
15
+ */
16
+ export interface RichEditorController {
17
+ focus: () => void;
18
+ /** The selected text of the showing view ("" when nothing is selected). */
19
+ selectedText: () => string;
20
+ /** Put text at the caret, replacing the selection (markdown stays markdown). */
21
+ replaceSelection: (text: string) => void;
22
+ /** Insert a new block before / after the selection; false when it could not. */
23
+ insertText: (text: string, where: "before" | "after") => boolean;
24
+ /**
25
+ * Replace the WHOLE text (the context menu's full-content `onTextReplace`
26
+ * contract: Cut / Paste / an AI result's Replace / an agent's
27
+ * widget_text_replace all hand over the entire new value).
28
+ */
29
+ setText: (text: string) => void;
30
+ /** The text now, with any pending keystrokes delivered through onChange first. */
31
+ flush: () => string;
32
+ /**
33
+ * Steps the showing view's own undo history holds (zero in Preview and right
34
+ * after a mount). A host with a record-level history (notes) answers ⌘Z
35
+ * itself when this is zero, so undo survives a remount.
36
+ */
37
+ historyDepth: () => {
38
+ undo: number;
39
+ redo: number;
40
+ };
41
+ /** Open the editor's own find (and replace) bar — it skips protected content. */
42
+ openFind: (withReplace?: boolean) => void;
43
+ /** Scroll to the heading that starts at (or nearest before) this source offset. */
44
+ jumpToOffset: (offset: number) => void;
45
+ /**
46
+ * Edit in place: put the caret where the person double-clicked the rendered
47
+ * text (`core/caret-context.ts`). False until the view is ready or when the
48
+ * text is not found — the caller retries or leaves the caret at the start.
49
+ */
50
+ placeCaret: (context: CaretContext) => boolean;
51
+ }
52
+ export interface RichEditorProps {
53
+ /** The stored text — what the save gate compares against. */
54
+ value: string;
55
+ /**
56
+ * Store the text. Resolve with the text AS STORED (read back) and the editor
57
+ * proves it byte-for-byte; resolve with nothing to trust the write.
58
+ */
59
+ onSave?: (text: string, plan: SavePlan) => Promise<string | void>;
60
+ /** Every change to the working text. */
61
+ onChange?: (text: string) => void;
62
+ /** Variables the surface declares; omit for a surface without variables (a note). */
63
+ variables?: readonly DeclaredVariable[] | null;
64
+ onDeclareVariable?: (name: string) => void;
65
+ /** The view it opens in: source for prompts/templates/skills, visual for prose. */
66
+ defaultView?: RichEditorView;
67
+ /**
68
+ * The view, held by the host (a host whose own control switches views). The
69
+ * editor delivers pending text before it switches, and reports every switch it
70
+ * makes itself (a shortcut, a refused switch) through `onViewChange`.
71
+ */
72
+ view?: RichEditorView;
73
+ onViewChange?: (view: RichEditorView) => void;
74
+ /**
75
+ * "full" (default): the editor's toolbar row — views, tools, dictation, Save.
76
+ * "bare": no row of its own; the host's chrome carries the view switch, find,
77
+ * outline and dictation (a host that saves by itself — autosave — has no Save).
78
+ * Shortcuts (⌘F, ⌘⌥H, ⌘⇧F, ⌘/) keep working either way.
79
+ */
80
+ chrome?: "full" | "bare";
81
+ /**
82
+ * The host wraps the editor in its OWN right-click menu (its surface, scope and
83
+ * record rows) — the editor then mounts none, so one menu answers. Drive text
84
+ * edits from that menu through `controllerRef`.
85
+ */
86
+ hostContextMenu?: boolean;
87
+ /** Receives the controller (see RichEditorController). */
88
+ controllerRef?: Ref<RichEditorController>;
89
+ readOnly?: boolean;
90
+ placeholder?: string;
91
+ /** The right-click menu's surface + attribution. */
92
+ surfaceName?: string;
93
+ sourceFeature?: SourceFeature;
94
+ /** The preview's action source (RichDocument). */
95
+ contentSource?: ContentSource;
96
+ /**
97
+ * The SAVED RECORD this buffer is, with its saved bytes, for Comment in the Visual view (chair
98
+ * ruling 2026-09-26: Comment appears when the buffer is a saved record, and is absent for an
99
+ * unsaved buffer). Derived from `contentSource` (a note, a chat answer, a saved working
100
+ * document; body = the last stored text) unless the host names it — the studio does, because
101
+ * its buffer is a copy of the loaded document. `null` turns it off. It applies only while the
102
+ * working text is byte-identical to `body`: the anchors index into those bytes.
103
+ */
104
+ annotationRecord?: (AnnotationRecord & {
105
+ body: string;
106
+ }) | null;
107
+ /** Label for the save button. */
108
+ saveLabel?: string;
109
+ className?: string;
110
+ /** Extra controls on the right of the toolbar (host actions). */
111
+ toolbarExtras?: ReactNode;
112
+ /**
113
+ * Leave the editor (an in-place editor going back to its reading view). Shows
114
+ * a Cancel button beside Save; the host decides what to do with unsaved text.
115
+ */
116
+ onCancel?: () => void;
117
+ /** Label for the cancel button. */
118
+ cancelLabel?: string;
119
+ /**
120
+ * Open with the outline beside the text (true) or closed (false). Default:
121
+ * open on desktop, closed on phones. A narrow host (a chat column) passes false.
122
+ */
123
+ defaultOutlineOpen?: boolean;
124
+ /**
125
+ * Save was pressed with nothing changed: the stored text is already exactly
126
+ * this. When given, it replaces the "Nothing changed" notice, so an in-place
127
+ * host can simply return to its reading view.
128
+ */
129
+ onNothingToSave?: () => void;
130
+ /**
131
+ * WHO WROTE the content shown — "self" | "other" | "ai" (or "inherit").
132
+ * Decides whether remote images load by themselves; forwarded to the
133
+ * renderer (components/rich-content/prose/remote-image-policy.tsx).
134
+ */
135
+ imagePolicy?: ImagePolicyDeclaration;
136
+ }
137
+ 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;