@ai-matrx/rich-editor 0.2.0 → 0.2.4

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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.4
4
+
5
+ - Visible format buttons in plain text (Arman, 2026-10-04: "The formatting buttons need to work even when we are just showing plain text"). New `format/FormatButtons` (Bold, Italic, Code, Link, Heading, List — compact icons with tooltips) run THE command layer on a FormatTarget. The editor's Source view shows them inside its existing toolbar row.
6
+ - `registerFormatElement` / `formatTargetWithin(root)`: every wired textarea (`useTextareaFormatting`) and the Source view register themselves, so a host's buttons act on the editor inside their container (focused › last focused › first). `EditorViewHandle.formatTarget()` returns the Source view's target.
7
+ - The selection toolbar's formatting strip is the same compact six; Strikethrough, Heading 1, Quote and the rest live under More.
8
+ - Edit in place: the caret placed at the double-click point now stays in view while the editor finishes drawing (tables, diagrams and code blocks grew above it and pushed it off screen — why 0.2.1's placement looked like "opened at the top"); a click at a line's end lands at that end, not the next block's start.
9
+
10
+ ### Consumer action
11
+
12
+ - matrx-frontend: render `<FormatButtons resolve={() => formatTargetWithin(container)} />` inside a plain-text editor's existing toolbar row (notes Plain and Split).
13
+
14
+ ## 0.2.3
15
+
16
+ - The outline panel and the island block pass their failure words to the kit copy (kit 0.30 `failureMessage`): a refused copy shows ONE toast, not the generic one plus ours.
17
+
18
+ ### Consumer action
19
+
20
+ none.
21
+
22
+ ## 0.2.2
23
+
24
+ - The outline panel's "copy link" and the island block's copy show their failure toast from the kit copy's `false` branch; the `catch` that held it could never run.
25
+
26
+ ### Consumer action
27
+
28
+ - None required.
29
+
3
30
  ## 0.1.3
4
31
  ### Unreleased
5
32
 
@@ -49,6 +49,7 @@ import { outlineOf } from "../core/outline.js";
49
49
  import { RichEditorContext } from "./RichEditorContext.js";
50
50
  import { VisualEditor } from "../visual/VisualEditor.js";
51
51
  import { SourceEditor } from "../source/SourceEditor.js";
52
+ import { FormatButtons } from "../format/FormatButtons.js";
52
53
  import { FindReplacePanel } from "../panels/FindReplacePanel.js";
53
54
  import { OutlinePanel } from "../panels/OutlinePanel.js";
54
55
  import { ShortcutsDialog } from "../panels/ShortcutsDialog.js";
@@ -425,6 +426,10 @@ function RichEditorImpl({
425
426
  // Desktop shows the count in the toolbar; a phone reads it from the tools menu.
426
427
  ...isMobile ? [{ id: "count", label: `${formatCount(metrics.words)} words \xB7 ${saveStatus}`, icon: Hash, run: () => toast.info(documentFacts) }] : []
427
428
  ];
429
+ const formatButtons = view === "source" && !readOnly ? /* @__PURE__ */ jsxs(Fragment, { children: [
430
+ /* @__PURE__ */ jsx("span", { className: "mx-1 h-5 w-px shrink-0 bg-border" }),
431
+ /* @__PURE__ */ jsx(FormatButtons, { resolve: () => handle.current?.formatTarget?.() ?? null })
432
+ ] }) : null;
428
433
  const micButton = mic.available && !readOnly && view !== "preview" ? /* @__PURE__ */ jsx(
429
434
  ToolbarButton,
430
435
  {
@@ -542,6 +547,7 @@ function RichEditorImpl({
542
547
  option
543
548
  );
544
549
  }) }),
550
+ formatButtons,
545
551
  !isMobile && /* @__PURE__ */ jsxs(Fragment, { children: [
546
552
  /* @__PURE__ */ jsx("span", { className: "mx-1 h-5 w-px shrink-0 bg-border" }),
547
553
  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)),
@@ -0,0 +1,22 @@
1
+ import * as React from "react";
2
+ import { type LucideIcon } from "lucide-react";
3
+ import type { FormatCommandId } from "../core/markdown-format.js";
4
+ import type { FormatTarget } from "./format-target.js";
5
+ export interface FormatButtonSpec {
6
+ command: FormatCommandId;
7
+ label: string;
8
+ icon: LucideIcon;
9
+ }
10
+ /** The compact set every plain-text toolbar shows (the rest stay on the selection toolbar and the keys). */
11
+ export declare const FORMAT_BUTTONS: readonly FormatButtonSpec[];
12
+ export interface FormatButtonsProps {
13
+ /** The editor the buttons act on, read at press time. Null = nothing to format (buttons stay, inert). */
14
+ resolve: () => FormatTarget | null;
15
+ /** Hide (render nothing) — a read-only host. */
16
+ disabled?: boolean;
17
+ className?: string;
18
+ /** "sm" (28px, the editor toolbar) or "xs" (24px, a dense strip). */
19
+ size?: "sm" | "xs";
20
+ }
21
+ export declare function FormatButtons({ resolve, disabled, className, size }: FormatButtonsProps): React.ReactElement | null;
22
+ export default FormatButtons;
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { Bold, Code, Heading2, Italic, Link2, List } from "lucide-react";
4
+ import { cn } from "@ai-matrx/design-system";
5
+ const FORMAT_BUTTONS = [
6
+ { command: "bold", label: "Bold (\u2318B)", icon: Bold },
7
+ { command: "italic", label: "Italic (\u2318I)", icon: Italic },
8
+ { command: "code", label: "Inline code (\u2318E)", icon: Code },
9
+ { command: "link", label: "Link (\u2318K)", icon: Link2 },
10
+ { command: "heading2", label: "Heading", icon: Heading2 },
11
+ { command: "bulletList", label: "Bulleted list (\u2318\u21E78)", icon: List }
12
+ ];
13
+ function FormatButtons({ resolve, disabled = false, className, size = "sm" }) {
14
+ if (disabled) return null;
15
+ return /* @__PURE__ */ jsx("div", { role: "group", "aria-label": "Formatting", "data-format-buttons": "", className: cn("flex shrink-0 items-center gap-0.5", className), children: FORMAT_BUTTONS.map(({ command, label, icon: Icon }) => /* @__PURE__ */ jsx(
16
+ "button",
17
+ {
18
+ type: "button",
19
+ "aria-label": label,
20
+ title: label,
21
+ "data-format-command": command,
22
+ "data-matrx-button": "",
23
+ onMouseDown: (event) => event.preventDefault(),
24
+ onClick: () => {
25
+ resolve()?.run(command);
26
+ },
27
+ className: cn(
28
+ "inline-flex shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground",
29
+ size === "xs" ? "h-6 w-6" : "h-7 w-7"
30
+ ),
31
+ children: /* @__PURE__ */ jsx(Icon, { className: size === "xs" ? "h-3.5 w-3.5" : "h-4 w-4" })
32
+ },
33
+ command
34
+ )) });
35
+ }
36
+ var FormatButtons_default = FormatButtons;
37
+ export {
38
+ FORMAT_BUTTONS,
39
+ FormatButtons,
40
+ FormatButtons_default as default
41
+ };
@@ -29,13 +29,13 @@ function formatTargetOf(target) {
29
29
  const FORMATS = [
30
30
  { id: "bold", command: "bold", label: "Bold (\u2318B)", icon: Bold },
31
31
  { id: "italic", command: "italic", label: "Italic (\u2318I)", icon: Italic },
32
- { id: "strike", command: "strike", label: "Strikethrough (\u2318\u21E7X)", icon: Strikethrough },
32
+ { id: "strike", command: "strike", label: "Strikethrough (\u2318\u21E7X)", icon: Strikethrough, more: true },
33
33
  { id: "code", command: "code", label: "Inline code (\u2318E)", icon: Code },
34
34
  { id: "link", command: "link", label: "Link (\u2318K)", icon: Link2 },
35
- { id: "h1", command: "heading1", label: "Heading 1", icon: Heading1 },
36
- { id: "h2", command: "heading2", label: "Heading 2", icon: Heading2 },
35
+ { id: "h1", command: "heading1", label: "Heading 1", icon: Heading1, more: true },
36
+ { id: "h2", command: "heading2", label: "Heading", icon: Heading2 },
37
37
  { id: "h3", command: "heading3", label: "Heading 3", icon: Heading3, more: true },
38
- { id: "quote", command: "quote", label: "Quote", icon: Quote },
38
+ { id: "quote", command: "quote", label: "Quote", icon: Quote, more: true },
39
39
  { id: "list", command: "bulletList", label: "Bulleted list (\u2318\u21E78)", icon: List },
40
40
  { id: "numbered", command: "orderedList", label: "Numbered list (\u2318\u21E77)", icon: ListOrdered, more: true },
41
41
  { id: "tasks", command: "taskList", label: "Checklist (\u2318\u21E79)", icon: ListChecks, more: true },
@@ -37,3 +37,7 @@ export declare function textFormatTarget(engine: "source" | "textarea", read: ()
37
37
  export declare function applyEditToTextarea(el: HTMLTextAreaElement, result: SourceEditResult): void;
38
38
  /** The textarea engine. */
39
39
  export declare function textareaFormatTarget(el: HTMLTextAreaElement): FormatTarget;
40
+ /** Register `el` as a formatting host; returns the unregister. */
41
+ export declare function registerFormatElement(el: Element, target: FormatTarget): () => void;
42
+ /** The registered editor inside `root` the person is in (focused › last focused › first). */
43
+ export declare function formatTargetWithin(root: ParentNode | null | undefined): FormatTarget | null;
@@ -70,10 +70,39 @@ function textareaFormatTarget(el) {
70
70
  }
71
71
  );
72
72
  }
73
+ const FORMAT_HOST_ATTR = "data-format-host";
74
+ const registeredTargets = /* @__PURE__ */ new WeakMap();
75
+ let lastFocused = null;
76
+ function registerFormatElement(el, target) {
77
+ registeredTargets.set(el, target);
78
+ el.setAttribute(FORMAT_HOST_ATTR, "");
79
+ const onFocus = () => {
80
+ lastFocused = el;
81
+ };
82
+ el.addEventListener("focusin", onFocus);
83
+ return () => {
84
+ el.removeEventListener("focusin", onFocus);
85
+ if (registeredTargets.get(el) === target) {
86
+ registeredTargets.delete(el);
87
+ el.removeAttribute(FORMAT_HOST_ATTR);
88
+ }
89
+ if (lastFocused === el) lastFocused = null;
90
+ };
91
+ }
92
+ function formatTargetWithin(root) {
93
+ if (!root) return null;
94
+ const hosts = Array.from(root.querySelectorAll(`[${FORMAT_HOST_ATTR}]`));
95
+ if (hosts.length === 0) return null;
96
+ const active = typeof document === "undefined" ? null : document.activeElement;
97
+ const pick = hosts.find((el) => active && (el === active || el.contains(active))) ?? (lastFocused && hosts.includes(lastFocused) ? lastFocused : null) ?? hosts[0];
98
+ return registeredTargets.get(pick) ?? null;
99
+ }
73
100
  export {
74
101
  MARKDOWN_FORMAT_HOST_KEY,
75
102
  applyEditToTextarea,
103
+ formatTargetWithin,
76
104
  markdownFormatHost,
105
+ registerFormatElement,
77
106
  textFormatTarget,
78
107
  textareaFormatTarget
79
108
  };
@@ -2,7 +2,7 @@
2
2
  import { useEffect } from "react";
3
3
  import { useSelectionZone } from "@ai-matrx/rich-content/selection-toolbar/selection-zones";
4
4
  import { formatCommandForKey, keyNameOf } from "../core/markdown-format.js";
5
- import { markdownFormatHost, textareaFormatTarget } from "./format-target.js";
5
+ import { markdownFormatHost, registerFormatElement, textareaFormatTarget } from "./format-target.js";
6
6
  function isApplePlatform() {
7
7
  if (typeof navigator === "undefined") return false;
8
8
  const platform = navigator.userAgentData?.platform ?? navigator.platform ?? "";
@@ -23,7 +23,11 @@ function useTextareaFormatting(element, enabled = true) {
23
23
  handleFormatChord(element, event);
24
24
  };
25
25
  element.addEventListener("keydown", onKeyDown);
26
- return () => element.removeEventListener("keydown", onKeyDown);
26
+ const unregister = registerFormatElement(element, textareaFormatTarget(element));
27
+ return () => {
28
+ element.removeEventListener("keydown", onKeyDown);
29
+ unregister();
30
+ };
27
31
  }, [element, enabled]);
28
32
  useSelectionZone(
29
33
  element,
@@ -39,3 +39,12 @@ export interface InPlaceAction {
39
39
  run: (text: string) => Promise<void>;
40
40
  }
41
41
  export declare function InPlaceEditor({ value, mode, write, close, caret, minHeight, expandable, startExpanded, discardDescription, editor, className, id, toolbarExtras, actions, }: InPlaceEditorProps): import("react").JSX.Element;
42
+ /**
43
+ * The caret was placed where the person double-clicked, but the editor keeps
44
+ * growing for a moment after (tables, diagrams and code blocks finish drawing
45
+ * above it), which pushed it out of view: 0.2.1 placed the caret correctly and
46
+ * the person still saw the top of the answer. For a short settle window, every
47
+ * size change brings the caret back into view — until the person scrolls,
48
+ * types or clicks, which ends it at once.
49
+ */
50
+ export declare function keepCaretInView(root: HTMLElement, settleMs?: number): () => void;
@@ -41,9 +41,13 @@ function InPlaceEditor({
41
41
  useEffect(() => {
42
42
  let tries = 0;
43
43
  let frame = 0;
44
+ let release = null;
44
45
  const place = () => {
45
46
  const c = controller.current;
46
- if (c && (caret ? c.placeCaret(caret) : (c.focus(), true))) return;
47
+ if (c && (caret ? c.placeCaret(caret) : (c.focus(), true))) {
48
+ if (caret && shellRef.current) release = keepCaretInView(shellRef.current);
49
+ return;
50
+ }
47
51
  if (++tries > 90) {
48
52
  controller.current?.focus();
49
53
  return;
@@ -51,7 +55,10 @@ function InPlaceEditor({
51
55
  frame = requestAnimationFrame(place);
52
56
  };
53
57
  frame = requestAnimationFrame(place);
54
- return () => cancelAnimationFrame(frame);
58
+ return () => {
59
+ cancelAnimationFrame(frame);
60
+ release?.();
61
+ };
55
62
  }, [caret]);
56
63
  const inputSinceReport = useRef(false);
57
64
  const onDraft = (text) => {
@@ -164,6 +171,41 @@ function InPlaceEditor({
164
171
  }
165
172
  );
166
173
  }
174
+ function keepCaretInView(root, settleMs = 2500) {
175
+ let done = false;
176
+ const caretElement = () => {
177
+ const sel = typeof window === "undefined" ? null : window.getSelection();
178
+ if (!sel || sel.rangeCount === 0) return null;
179
+ const range = sel.getRangeAt(0);
180
+ const node = range.startContainer;
181
+ const el = node.nodeType === 1 ? node : node.parentElement;
182
+ if (!el || !root.contains(el)) return null;
183
+ const r = range.getBoundingClientRect();
184
+ return { el, rect: r.height || r.width ? r : el.getBoundingClientRect() };
185
+ };
186
+ const show = () => {
187
+ if (done) return;
188
+ const at = caretElement();
189
+ if (!at) return;
190
+ if (at.rect.top < 0 || at.rect.bottom > window.innerHeight) at.el.scrollIntoView({ block: "center" });
191
+ };
192
+ const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(show);
193
+ ro?.observe(root);
194
+ const tick = window.setInterval(show, 200);
195
+ const stop = () => {
196
+ if (done) return;
197
+ done = true;
198
+ ro?.disconnect();
199
+ window.clearInterval(tick);
200
+ window.clearTimeout(timer);
201
+ for (const type of ["wheel", "touchmove", "keydown", "pointerdown"]) window.removeEventListener(type, stop, true);
202
+ };
203
+ const timer = window.setTimeout(stop, settleMs);
204
+ for (const type of ["wheel", "touchmove", "keydown", "pointerdown"]) window.addEventListener(type, stop, { capture: true, passive: true });
205
+ show();
206
+ return stop;
207
+ }
167
208
  export {
168
- InPlaceEditor
209
+ InPlaceEditor,
210
+ keepCaretInView
169
211
  };
@@ -1,4 +1,5 @@
1
- const pending = /* @__PURE__ */ new Map();
1
+ const PENDING_KEY = /* @__PURE__ */ Symbol.for("ai-matrx.rich-editor.in-place-caret-handoff");
2
+ const pending = globalThis[PENDING_KEY] ??= /* @__PURE__ */ new Map();
2
3
  function handInPlaceCaret(key, caret) {
3
4
  if (caret) pending.set(key, caret);
4
5
  else pending.delete(key);
@@ -15,12 +15,8 @@ function OutlinePanel({
15
15
  const minLevel = Math.min(...entries.map((entry) => entry.level), 6);
16
16
  const copyLink = async (entry) => {
17
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
- }
18
+ if (!await copyText(url, void 0, "Copy failed \u2014 your browser blocked clipboard access.")) return;
19
+ toast.success(`Link to \u201C${entry.text}\u201D copied.`);
24
20
  };
25
21
  return /* @__PURE__ */ jsxs("nav", { "aria-label": "Outline", className: cn("flex h-full flex-col overflow-hidden", className), children: [
26
22
  /* @__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: [
@@ -28,7 +28,7 @@ import { findMatches, replaceMatches } from "../core/find-replace.js";
28
28
  import { continueMarkupOnEnter, makeLink, setLinePrefix, toggleWrap } from "../core/source-format.js";
29
29
  import { markdownSourceLanguage } from "./markdown-language.js";
30
30
  import { formatMarkdown } from "../core/markdown-format.js";
31
- import { markdownFormatHost, textFormatTarget } from "../format/format-target.js";
31
+ import { markdownFormatHost, registerFormatElement, textFormatTarget } from "../format/format-target.js";
32
32
  import { useSelectionZone } from "@ai-matrx/rich-content/selection-toolbar/selection-zones";
33
33
  import { RICH_EDITOR_SHORTCUTS, TYPED_TRIGGERS } from "../core/shortcuts.js";
34
34
  import { variableNamesInText, variableSuggestions } from "../core/variables.js";
@@ -273,9 +273,12 @@ function SourceEditor({
273
273
  });
274
274
  };
275
275
  const [zoneElement, setZoneElement] = useState(null);
276
- useSelectionZone(zoneElement, { editable: !context.readOnly, host: markdownFormatHost(sourceFormatTarget(() => view.current)) });
276
+ const [formatTarget] = useState(() => sourceFormatTarget(() => view.current));
277
+ useSelectionZone(zoneElement, { editable: !context.readOnly, host: markdownFormatHost(formatTarget) });
278
+ useEffect(() => zoneElement ? registerFormatElement(zoneElement, formatTarget) : void 0, [zoneElement, formatTarget]);
277
279
  useImperativeHandle(handleRef, () => ({
278
280
  focus: () => view.current?.focus(),
281
+ formatTarget: () => formatTarget,
279
282
  selectedText: () => {
280
283
  const v = view.current;
281
284
  if (!v) return "";
@@ -3,6 +3,7 @@ import { type VisualLoadStats } from "../core/visual-document.js";
3
3
  import { type CaretContext } from "../core/caret-context.js";
4
4
  import type { FindOptions } from "../core/find-replace.js";
5
5
  import { type RichShellActions } from "./visual-extensions.js";
6
+ import type { FormatTarget } from "../format/format-target.js";
6
7
  export interface ViewFindState {
7
8
  count: number;
8
9
  current: number;
@@ -12,6 +13,8 @@ export interface ViewFindState {
12
13
  /** What the shell drives in whichever view is showing. */
13
14
  export interface EditorViewHandle {
14
15
  focus: () => void;
16
+ /** The plain-text engines' FormatTarget (Source view) — the toolbar's format buttons act through it. */
17
+ formatTarget?: () => FormatTarget | null;
15
18
  selectedText: () => string;
16
19
  /** Replace the selection with literal text (markdown typed by an AI stays markdown). */
17
20
  replaceSelection: (text: string) => void;
@@ -196,7 +196,7 @@ function VisualEditor({
196
196
  });
197
197
  const index = locateCaret(flat, context2);
198
198
  if (index === null) return false;
199
- const pos = index < positions.length ? positions[index] : editor.state.doc.content.size - 1;
199
+ const pos = index > 0 && positions[index - 1] !== void 0 ? positions[index - 1] + 1 : positions[index] ?? editor.state.doc.content.size - 1;
200
200
  try {
201
201
  editor.view.dispatch(editor.state.tr.setSelection(TextSelection.near(editor.state.doc.resolve(pos))).scrollIntoView());
202
202
  editor.view.focus();
@@ -49,13 +49,9 @@ function IslandBlockView({ node, updateAttributes, deleteNode, selected, editor,
49
49
  });
50
50
  };
51
51
  const copy = async () => {
52
- try {
53
- if (!await copyText(raw)) return;
54
- setCopied(true);
55
- window.setTimeout(() => setCopied(false), 1200);
56
- } catch {
57
- toast.error("Copy failed \u2014 your browser blocked clipboard access.");
58
- }
52
+ if (!await copyText(raw, void 0, "Copy failed \u2014 your browser blocked clipboard access.")) return;
53
+ setCopied(true);
54
+ window.setTimeout(() => setCopied(false), 1200);
59
55
  };
60
56
  const isFence = islandType === "fence";
61
57
  const isMath = meta.language === "math";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/rich-editor",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
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",
@@ -89,12 +89,12 @@
89
89
  "github-slugger": "^2.0.0",
90
90
  "lucide-react": "^1.48.0",
91
91
  "@ai-matrx/agents": "latest",
92
- "@ai-matrx/design-system": "latest",
92
+ "@ai-matrx/alchemy": "latest",
93
93
  "@ai-matrx/content-ir": "latest",
94
- "@ai-matrx/print": "latest",
94
+ "@ai-matrx/design-system": "latest",
95
95
  "@ai-matrx/kit": "latest",
96
- "@ai-matrx/rich-content": "latest",
97
- "@ai-matrx/alchemy": "latest"
96
+ "@ai-matrx/print": "latest",
97
+ "@ai-matrx/rich-content": "latest"
98
98
  },
99
99
  "peerDependencies": {
100
100
  "react": ">=19.0.0",