@ai-matrx/rich-editor 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/core/text-insertion.d.ts +14 -0
  2. package/dist/core/text-insertion.js +55 -0
  3. package/dist/editor/RichEditor.d.ts +4 -0
  4. package/dist/editor/RichEditor.js +14 -0
  5. package/dist/editor/RichEditorContext.d.ts +16 -0
  6. package/dist/editor/RichEditorContext.js +17 -0
  7. package/dist/editor/RichEditorImpl.d.ts +137 -0
  8. package/dist/editor/RichEditorImpl.js +714 -0
  9. package/dist/editor/caretInsert.d.ts +11 -0
  10. package/dist/editor/caretInsert.js +9 -0
  11. package/dist/editor/rich-editor.css +181 -0
  12. package/dist/format/format-actions.d.ts +10 -0
  13. package/dist/format/format-actions.js +78 -0
  14. package/dist/format/useTextareaFormatting.d.ts +4 -0
  15. package/dist/format/useTextareaFormatting.js +37 -0
  16. package/dist/in-place/EditInPlace.d.ts +38 -0
  17. package/dist/in-place/EditInPlace.js +72 -0
  18. package/dist/in-place/EditInPlaceText.d.ts +18 -0
  19. package/dist/in-place/EditInPlaceText.js +159 -0
  20. package/dist/in-place/InPlaceEditor.d.ts +41 -0
  21. package/dist/in-place/InPlaceEditor.js +169 -0
  22. package/dist/in-place/caret-handoff.js +2 -1
  23. package/dist/islands/IslandCodeEditor.d.ts +12 -0
  24. package/dist/islands/IslandCodeEditor.js +82 -0
  25. package/dist/islands/IslandPreview.d.ts +6 -0
  26. package/dist/islands/IslandPreview.js +31 -0
  27. package/dist/islands/island-meta.d.ts +28 -0
  28. package/dist/islands/island-meta.js +176 -0
  29. package/dist/panels/FindReplacePanel.d.ts +10 -0
  30. package/dist/panels/FindReplacePanel.js +118 -0
  31. package/dist/panels/KindPicker.d.ts +4 -0
  32. package/dist/panels/KindPicker.js +95 -0
  33. package/dist/panels/OutlinePanel.d.ts +6 -0
  34. package/dist/panels/OutlinePanel.js +60 -0
  35. package/dist/panels/ShortcutsDialog.d.ts +4 -0
  36. package/dist/panels/ShortcutsDialog.js +24 -0
  37. package/dist/source/SourceEditor.d.ts +28 -0
  38. package/dist/source/SourceEditor.js +429 -0
  39. package/dist/source/cm-theme.d.ts +4 -0
  40. package/dist/source/cm-theme.js +71 -0
  41. package/dist/source/live-preview.d.ts +30 -0
  42. package/dist/source/live-preview.js +304 -0
  43. package/dist/source/markdown-language.d.ts +2 -0
  44. package/dist/source/markdown-language.js +7 -0
  45. package/dist/visual/BlockHandle.d.ts +6 -0
  46. package/dist/visual/BlockHandle.js +173 -0
  47. package/dist/visual/VisualEditor.d.ts +56 -0
  48. package/dist/visual/VisualEditor.js +312 -0
  49. package/dist/visual/auto-edit.d.ts +3 -0
  50. package/dist/visual/auto-edit.js +17 -0
  51. package/dist/visual/context-menu-caret.d.ts +10 -0
  52. package/dist/visual/context-menu-caret.js +42 -0
  53. package/dist/visual/decorations.d.ts +9 -0
  54. package/dist/visual/decorations.js +131 -0
  55. package/dist/visual/format-actions.d.ts +20 -0
  56. package/dist/visual/format-actions.js +128 -0
  57. package/dist/visual/menus/SuggestionMenu.d.ts +37 -0
  58. package/dist/visual/menus/SuggestionMenu.js +121 -0
  59. package/dist/visual/nodes/CalloutView.d.ts +2 -0
  60. package/dist/visual/nodes/CalloutView.js +45 -0
  61. package/dist/visual/nodes/InlineIslandView.d.ts +2 -0
  62. package/dist/visual/nodes/InlineIslandView.js +174 -0
  63. package/dist/visual/nodes/IslandBlockView.d.ts +2 -0
  64. package/dist/visual/nodes/IslandBlockView.js +206 -0
  65. package/dist/visual/nodes/SourceLockedView.d.ts +2 -0
  66. package/dist/visual/nodes/SourceLockedView.js +62 -0
  67. package/dist/visual/shortcut-handlers.d.ts +27 -0
  68. package/dist/visual/shortcut-handlers.js +43 -0
  69. package/dist/visual/slash-items.d.ts +18 -0
  70. package/dist/visual/slash-items.js +152 -0
  71. package/dist/visual/visual-extensions.d.ts +10 -0
  72. package/dist/visual/visual-extensions.js +246 -0
  73. package/dist/visual/visual-format-target.d.ts +9 -0
  74. package/dist/visual/visual-format-target.js +43 -0
  75. package/package.json +43 -5
@@ -0,0 +1,11 @@
1
+ /**
2
+ * THE context menu's insert-at-the-caret for a host that drives a RichEditor.
3
+ *
4
+ * `inline` text replaces the selection where it is. A `block` (a reference
5
+ * fence) goes through `insertText(…, "after")`, which places it on its own
6
+ * paragraph at the END of the caret's block — never inside a word (G5 review,
7
+ * 2026-10-02: "of" became "o" + block + "f"). One helper so the three note
8
+ * hosts (desktop, content editor, mobile) cannot drift.
9
+ */
10
+ import type { RichEditorController } from "./RichEditorImpl.js";
11
+ export declare function insertAtRichCaret(rich: RichEditorController | null, text: string, placement?: "inline" | "block"): boolean;
@@ -0,0 +1,9 @@
1
+ function insertAtRichCaret(rich, text, placement = "inline") {
2
+ if (!rich) return false;
3
+ if (placement === "block") return rich.insertText(text, "after");
4
+ rich.replaceSelection(text);
5
+ return true;
6
+ }
7
+ export {
8
+ insertAtRichCaret
9
+ };
@@ -0,0 +1,181 @@
1
+ /* components/rich-editor/rich-editor.css — the one editor's look (visual + source).
2
+ Semantic tokens only; light and dark follow the app. */
3
+
4
+ /* ── Visual (Tiptap) ─────────────────────────────────────────────────────── */
5
+ .rich-editor-prose {
6
+ caret-color: hsl(var(--foreground));
7
+ word-break: break-word;
8
+ color: hsl(var(--foreground));
9
+ /* Typography tokens follow the app theme in light AND dark (no prose-invert dependency). */
10
+ --tw-prose-body: hsl(var(--foreground));
11
+ --tw-prose-headings: hsl(var(--foreground));
12
+ --tw-prose-lead: hsl(var(--muted-foreground));
13
+ --tw-prose-links: hsl(var(--primary));
14
+ --tw-prose-bold: hsl(var(--foreground));
15
+ --tw-prose-counters: hsl(var(--muted-foreground));
16
+ --tw-prose-bullets: hsl(var(--muted-foreground));
17
+ --tw-prose-hr: hsl(var(--border));
18
+ --tw-prose-quotes: hsl(var(--foreground));
19
+ --tw-prose-quote-borders: hsl(var(--border));
20
+ --tw-prose-captions: hsl(var(--muted-foreground));
21
+ --tw-prose-code: hsl(var(--foreground));
22
+ --tw-prose-th-borders: hsl(var(--border));
23
+ --tw-prose-td-borders: hsl(var(--border));
24
+ }
25
+ .rich-editor-prose > * + * { margin-top: 0.6em; }
26
+ .rich-editor-prose .rich-editor-source-block > :first-child { margin-top: 0; }
27
+ .rich-editor-prose .rich-editor-source-block + .rich-editor-source-block { margin-top: 1em; }
28
+ .rich-editor-prose p.is-editor-empty:first-child::before,
29
+ .rich-editor-prose .is-empty.is-editor-empty::before {
30
+ content: attr(data-placeholder);
31
+ float: left;
32
+ height: 0;
33
+ pointer-events: none;
34
+ color: hsl(var(--muted-foreground));
35
+ }
36
+ .rich-editor-prose code {
37
+ border-radius: 0.3rem;
38
+ background: hsl(var(--muted));
39
+ padding: 0.1em 0.35em;
40
+ font-size: 0.88em;
41
+ }
42
+ .rich-editor-prose code::before,
43
+ .rich-editor-prose code::after { content: none; }
44
+ .rich-editor-prose a { color: hsl(var(--primary)); text-decoration: underline; cursor: text; }
45
+ .rich-editor-prose blockquote { border-left: 3px solid hsl(var(--border)); padding-left: 1em; font-style: normal; }
46
+ /* Typography's quote marks on a quote's first paragraph landed in every table cell of a quoted table (“Zone”); Preview draws none. */
47
+ .rich-editor-prose blockquote p::before,
48
+ .rich-editor-prose blockquote p::after { content: none; }
49
+ .rich-editor-prose .rich-editor-callout blockquote,
50
+ .rich-editor-prose .rich-editor-callout p { margin: 0.25em 0; }
51
+ .rich-editor-prose table { border-collapse: collapse; width: 100%; margin: 0.75em 0; }
52
+ .rich-editor-prose th,
53
+ .rich-editor-prose td { border: 1px solid hsl(var(--border)); padding: 0.35em 0.6em; vertical-align: top; }
54
+ .rich-editor-prose th { background: hsl(var(--muted) / 0.6); font-weight: 600; }
55
+ .rich-editor-prose th p,
56
+ .rich-editor-prose td p { margin: 0; }
57
+ .rich-editor-prose .selectedCell { background: hsl(var(--primary) / 0.12); }
58
+ .rich-editor-prose .ProseMirror-selectednode { outline: 2px solid hsl(var(--primary) / 0.5); outline-offset: 2px; border-radius: 6px; }
59
+ .rich-editor-prose .ProseMirror-gapcursor::after { border-top-color: hsl(var(--foreground)); }
60
+
61
+ /* GFM task items: a real checkbox drawn by a decoration */
62
+ .rich-editor-prose li.rich-editor-task-item { list-style: none; position: relative; }
63
+ .rich-editor-prose li.rich-editor-task-item > .rich-editor-task-box {
64
+ position: absolute;
65
+ left: -1.5em;
66
+ top: 0.35em;
67
+ width: 1rem;
68
+ height: 1rem;
69
+ accent-color: hsl(var(--primary));
70
+ cursor: pointer;
71
+ }
72
+ .rich-editor-prose li.rich-editor-task-item.is-checked > p {
73
+ color: hsl(var(--muted-foreground));
74
+ text-decoration: line-through;
75
+ text-decoration-color: hsl(var(--muted-foreground) / 0.6);
76
+ }
77
+
78
+ /* Footnotes */
79
+ .rich-editor-prose .rich-editor-footnote-ref {
80
+ font-size: 0.72em;
81
+ vertical-align: super;
82
+ color: hsl(var(--primary));
83
+ font-weight: 600;
84
+ }
85
+ .rich-editor-prose .rich-editor-footnote-def { color: hsl(var(--muted-foreground)); font-weight: 600; }
86
+
87
+ /* Find & replace */
88
+ .rich-editor-find-match { background: hsl(48 96% 60% / 0.45); border-radius: 2px; }
89
+ .rich-editor-find-current { background: hsl(24 95% 55% / 0.65); border-radius: 2px; }
90
+
91
+ /* Heading anchors land below the sticky chrome */
92
+ .rich-editor-prose [data-heading-slug] { scroll-margin-top: 4rem; }
93
+
94
+ /* Focus mode: everything but the block you are in fades */
95
+ .rich-editor-focus .rich-editor-prose > * { transition: opacity 180ms ease; opacity: 0.28; }
96
+ .rich-editor-focus .rich-editor-prose > .has-focus,
97
+ .rich-editor-focus .rich-editor-prose > *:has(.has-focus) { opacity: 1; }
98
+ .rich-editor-focus .cm-line { transition: opacity 180ms ease; opacity: 0.35; }
99
+ .rich-editor-focus .cm-activeLine { opacity: 1; }
100
+
101
+ /* ── Source (CodeMirror live preview) ────────────────────────────────────── */
102
+ .rich-editor-source .cm-editor { min-height: 60dvh; }
103
+ .rich-editor-source .cm-content { padding: 24px 0; }
104
+ .rich-editor-source .cm-line { padding: 0 32px; }
105
+ @media (max-width: 639px) {
106
+ .rich-editor-source .cm-line { padding: 0 16px; }
107
+ }
108
+ .cm-rich-mark { color: hsl(var(--muted-foreground) / 0.7); }
109
+ .cm-rich-h1 { font-size: 1.6em; font-weight: 700; line-height: 1.3; }
110
+ .cm-rich-h2 { font-size: 1.35em; font-weight: 700; line-height: 1.35; }
111
+ .cm-rich-h3 { font-size: 1.15em; font-weight: 650; }
112
+ .cm-rich-h4, .cm-rich-h5, .cm-rich-h6 { font-weight: 650; }
113
+ .cm-rich-quote { border-left: 3px solid hsl(var(--border)); color: hsl(var(--muted-foreground)); }
114
+ .cm-rich-island-line { background: hsl(var(--muted) / 0.45); font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.85em; }
115
+ .cm-rich-island {
116
+ margin: 6px 32px;
117
+ border: 1px solid hsl(var(--border));
118
+ border-radius: 8px;
119
+ background: hsl(var(--card) / 0.6);
120
+ padding: 6px 12px;
121
+ cursor: text;
122
+ overflow: auto;
123
+ max-height: 32rem;
124
+ }
125
+ .cm-rich-island:hover { border-color: hsl(var(--primary) / 0.5); }
126
+ .cm-rich-var { border-radius: 5px; padding: 0 2px; border: 1px solid; font-weight: 500; }
127
+ .cm-rich-var-declared { background: hsl(142 70% 45% / 0.14); color: hsl(142 60% 32%); border-color: hsl(142 60% 45% / 0.35); }
128
+ .cm-rich-var-undeclared { background: hsl(0 80% 60% / 0.14); color: hsl(0 70% 45%); border-color: hsl(0 70% 55% / 0.35); }
129
+ .cm-rich-var-literal { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); border-color: hsl(var(--border)); }
130
+ .cm-rich-var-unbound { background: hsl(var(--primary) / 0.1); color: hsl(var(--primary)); border-color: hsl(var(--primary) / 0.3); }
131
+ .dark .cm-rich-var-declared { color: hsl(142 60% 70%); }
132
+ .dark .cm-rich-var-undeclared { color: hsl(0 80% 75%); }
133
+ .cm-rich-find { background: hsl(48 96% 60% / 0.45); }
134
+ .cm-rich-find-current { background: hsl(24 95% 55% / 0.65); }
135
+
136
+ /* A code fence inside the editor already has ONE frame — the island's, with its
137
+ language picker, copy, edit and remove. The shared renderer's own snippet
138
+ frame and "bash" header inside it were a box in a box with the language
139
+ named twice (page-pass /notes, 2026-09-27): inside an island the snippet is
140
+ bare code. */
141
+ .rich-editor-island[data-island-type="fence"] .rich-editor-island-render [data-code-snippet] {
142
+ margin: 0;
143
+ border: 0;
144
+ border-radius: 0;
145
+ background: transparent;
146
+ }
147
+ .rich-editor-island[data-island-type="fence"] .rich-editor-island-render [data-code-snippet-header] {
148
+ display: none;
149
+ }
150
+
151
+ .rich-editor-visual .ProseMirror .placeholder {
152
+ color: hsl(var(--muted-foreground));
153
+ }
154
+
155
+ /* ONE TYPOGRAPHY for a document, written or read (page-pass /notes,
156
+ 2026-09-28): headings and lists in the editor match the shared renderer
157
+ (components/rich-content/prose/prose-block-elements.tsx — h1 text-xl bold,
158
+ h2 text-lg semibold, h3 text-base semibold, all blue-500 in the heading
159
+ font; lists pl-6, mb-3, relaxed, no gap between items). */
160
+ .rich-editor-prose h1,
161
+ .rich-editor-prose h2,
162
+ .rich-editor-prose h3 {
163
+ font-family: var(--font-heading);
164
+ color: var(--color-blue-500);
165
+ line-height: 1.4;
166
+ }
167
+ .rich-editor-prose h1 { font-size: 1.25rem; font-weight: 700; margin: 1rem 0 0.5rem; }
168
+ .rich-editor-prose h2 { font-size: 1.125rem; font-weight: 600; margin: 0.75rem 0 0.375rem; }
169
+ .rich-editor-prose h3 { font-size: 1rem; font-weight: 600; margin: 0.5rem 0 0.25rem; }
170
+ .rich-editor-prose > h1:first-child,
171
+ .rich-editor-prose > h2:first-child { margin-top: 0; }
172
+ .rich-editor-prose ul,
173
+ .rich-editor-prose ol { padding-left: 1.5rem; margin: 0 0 0.75rem; line-height: 1.625; }
174
+ .rich-editor-prose li { margin: 0; }
175
+ .rich-editor-prose li p { margin: 0; }
176
+
177
+ /* Inside that one frame the code itself sits flat — no second rounded band. */
178
+ .rich-editor-island[data-island-type="fence"] .rich-editor-island-render [data-code-snippet] pre,
179
+ .rich-editor-island[data-island-type="fence"] .rich-editor-island-render [data-code-snippet] code {
180
+ background: transparent;
181
+ }
@@ -0,0 +1,10 @@
1
+ import type { ActionProvider, ClickTarget } from "@ai-matrx/alchemy/actions";
2
+ import { type FormatTarget } from "./format-target.js";
3
+ type VisualResolver = (target: ClickTarget) => FormatTarget | null;
4
+ /** The visual editor registers how its zone half becomes a FormatTarget (on load). */
5
+ export declare function registerVisualFormatResolver(resolver: VisualResolver): void;
6
+ /** THE active editor as a FormatTarget, whatever its engine. */
7
+ export declare function formatTargetOf(target: ClickTarget): FormatTarget | null;
8
+ /** The formatting provider (declared on import; the toolbar root imports this module). */
9
+ export declare const formatProvider: ActionProvider;
10
+ export {};
@@ -0,0 +1,78 @@
1
+ import {
2
+ Bold,
3
+ Code,
4
+ Heading1,
5
+ Heading2,
6
+ Heading3,
7
+ Italic,
8
+ Link2,
9
+ List,
10
+ ListChecks,
11
+ ListOrdered,
12
+ Quote,
13
+ SquareCode,
14
+ Strikethrough
15
+ } from "lucide-react";
16
+ import { registerAlchemyIcon } from "@ai-matrx/rich-content/utils/alchemy-icon-keys";
17
+ import { declareSelectionProvider, hostHalf, placeSelectionActions, shownInSelectionMode } from "@ai-matrx/rich-content/selection-toolbar/selection-actions";
18
+ import { MARKDOWN_FORMAT_HOST_KEY } from "./format-target.js";
19
+ let visualResolver = null;
20
+ function registerVisualFormatResolver(resolver) {
21
+ visualResolver = resolver;
22
+ }
23
+ function formatTargetOf(target) {
24
+ const visual = visualResolver?.(target) ?? null;
25
+ if (visual) return visual;
26
+ const text = hostHalf(target, MARKDOWN_FORMAT_HOST_KEY);
27
+ return text?.kind === "markdown-format" ? text.target : null;
28
+ }
29
+ const FORMATS = [
30
+ { id: "bold", command: "bold", label: "Bold (\u2318B)", icon: Bold },
31
+ { id: "italic", command: "italic", label: "Italic (\u2318I)", icon: Italic },
32
+ { id: "strike", command: "strike", label: "Strikethrough (\u2318\u21E7X)", icon: Strikethrough },
33
+ { id: "code", command: "code", label: "Inline code (\u2318E)", icon: Code },
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 },
37
+ { id: "h3", command: "heading3", label: "Heading 3", icon: Heading3, more: true },
38
+ { id: "quote", command: "quote", label: "Quote", icon: Quote },
39
+ { id: "list", command: "bulletList", label: "Bulleted list (\u2318\u21E78)", icon: List },
40
+ { id: "numbered", command: "orderedList", label: "Numbered list (\u2318\u21E77)", icon: ListOrdered, more: true },
41
+ { id: "tasks", command: "taskList", label: "Checklist (\u2318\u21E79)", icon: ListChecks, more: true },
42
+ { id: "codeblock", command: "codeBlock", label: "Code block", icon: SquareCode, more: true }
43
+ ];
44
+ const ACTIONS = FORMATS.map((spec, index) => {
45
+ const id = `selection:format-${spec.id}`;
46
+ return {
47
+ id,
48
+ label: spec.label,
49
+ icon: registerAlchemyIcon(spec.icon),
50
+ category: "edit",
51
+ order: index,
52
+ placement: spec.more ? "overflow" : "primary",
53
+ preserveSelection: true,
54
+ eligible: (t) => {
55
+ const fmt = formatTargetOf(t);
56
+ if (!fmt || !shownInSelectionMode(id, t) || !fmt.canFormat()) return { status: "absent" };
57
+ return { status: "available" };
58
+ },
59
+ pressed: (t) => formatTargetOf(t)?.isActive(spec.command) ?? false,
60
+ // The pressed state follows the editor (a toggle, a caret move).
61
+ subscribe: (onChange, t) => formatTargetOf(t)?.subscribe?.(onChange) ?? (() => void 0),
62
+ run: (t) => {
63
+ formatTargetOf(t)?.run(spec.command);
64
+ }
65
+ };
66
+ });
67
+ const formatProvider = {
68
+ id: "format-commands",
69
+ tier: "T0",
70
+ declaredIds: () => ACTIONS.map((a) => a.id),
71
+ actions: (target) => formatTargetOf(target) ? placeSelectionActions(ACTIONS, target) : []
72
+ };
73
+ declareSelectionProvider(formatProvider);
74
+ export {
75
+ formatProvider,
76
+ formatTargetOf,
77
+ registerVisualFormatResolver
78
+ };
@@ -0,0 +1,4 @@
1
+ export declare function isApplePlatform(): boolean;
2
+ /** Run the chord's command on this element; true when the chord was a formatting command. */
3
+ export declare function handleFormatChord(el: HTMLTextAreaElement, event: KeyboardEvent): boolean;
4
+ export declare function useTextareaFormatting(element: HTMLTextAreaElement | null, enabled?: boolean): void;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { useEffect } from "react";
3
+ import { useSelectionZone } from "@ai-matrx/rich-content/selection-toolbar/selection-zones";
4
+ import { formatCommandForKey, keyNameOf } from "../core/markdown-format.js";
5
+ import { markdownFormatHost, textareaFormatTarget } from "./format-target.js";
6
+ function isApplePlatform() {
7
+ if (typeof navigator === "undefined") return false;
8
+ const platform = navigator.userAgentData?.platform ?? navigator.platform ?? "";
9
+ return /mac|iphone|ipad|ipod/i.test(platform);
10
+ }
11
+ function handleFormatChord(el, event) {
12
+ const command = formatCommandForKey(keyNameOf(event, isApplePlatform()));
13
+ if (!command || el.readOnly || el.disabled) return false;
14
+ event.preventDefault();
15
+ textareaFormatTarget(el).run(command);
16
+ return true;
17
+ }
18
+ function useTextareaFormatting(element, enabled = true) {
19
+ useEffect(() => {
20
+ if (!element || !enabled) return;
21
+ const onKeyDown = (event) => {
22
+ if (event.defaultPrevented || event.isComposing) return;
23
+ handleFormatChord(element, event);
24
+ };
25
+ element.addEventListener("keydown", onKeyDown);
26
+ return () => element.removeEventListener("keydown", onKeyDown);
27
+ }, [element, enabled]);
28
+ useSelectionZone(
29
+ element,
30
+ element && enabled ? { editable: true, host: markdownFormatHost(textareaFormatTarget(element)) } : null
31
+ );
32
+ }
33
+ export {
34
+ handleFormatChord,
35
+ isApplePlatform,
36
+ useTextareaFormatting
37
+ };
@@ -0,0 +1,38 @@
1
+ import { type MouseEvent, type ReactNode } from "react";
2
+ import { type CaretContext } from "../core/caret-context.js";
3
+ import { type InPlaceEditorProps } from "./InPlaceEditor.js";
4
+ export interface InPlaceOpening {
5
+ caret: CaretContext | null;
6
+ /** The rendered view's height when it was swapped out. */
7
+ height: number;
8
+ }
9
+ /**
10
+ * The double-click half, for hosts that keep their own swap (the AI answer).
11
+ * Spread `readProps` on the element that holds the rendered text.
12
+ */
13
+ export declare function useInPlaceTrigger({ canEdit, onOpen }: {
14
+ canEdit: boolean;
15
+ onOpen: (opening: InPlaceOpening) => void;
16
+ }): {
17
+ readProps: {
18
+ ref: import("react").RefObject<HTMLDivElement | null>;
19
+ onDoubleClick: (event: MouseEvent<HTMLDivElement>) => void;
20
+ "data-edit-in-place": "read";
21
+ } | {
22
+ ref: import("react").RefObject<HTMLDivElement | null>;
23
+ "data-edit-in-place": "off";
24
+ };
25
+ };
26
+ export interface EditInPlaceProps extends Omit<InPlaceEditorProps, "value" | "close" | "caret" | "minHeight"> {
27
+ /** The stored text. */
28
+ value: string;
29
+ /** False for content the person cannot edit: the children render as they are. */
30
+ canEdit: boolean;
31
+ /** The rendered view. */
32
+ children: ReactNode;
33
+ /** Controlled editing (the host's Edit button). Omit to let double-click own it. */
34
+ editing?: boolean;
35
+ onEditingChange?: (editing: boolean) => void;
36
+ readClassName?: string;
37
+ }
38
+ export declare function EditInPlace({ value, canEdit, children, editing: editingProp, onEditingChange, readClassName, ...editorProps }: EditInPlaceProps): import("react").JSX.Element;
@@ -0,0 +1,72 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { useLayoutEffect, useRef, useState } from "react";
4
+ import { cn } from "@ai-matrx/design-system";
5
+ import { caretContextAt, domPointAt } from "../core/caret-context.js";
6
+ import { InPlaceEditor } from "./InPlaceEditor.js";
7
+ const INTERACTIVE = "a, button, input, textarea, select, label, summary, [role='button'], [contenteditable], [data-edit-in-place-ignore]";
8
+ function useInPlaceTrigger({ canEdit, onOpen }) {
9
+ const ref = useRef(null);
10
+ const onDoubleClick = (event) => {
11
+ if (!canEdit || event.defaultPrevented) return;
12
+ const target = event.target instanceof Element ? event.target : null;
13
+ if (target?.closest(INTERACTIVE)) return;
14
+ const root = ref.current;
15
+ if (!root) return;
16
+ const point = domPointAt(document, event.clientX, event.clientY);
17
+ const caret = point ? caretContextAt(root, point.node, point.offset) : null;
18
+ window.getSelection()?.removeAllRanges();
19
+ onOpen({ caret, height: root.getBoundingClientRect().height });
20
+ };
21
+ return {
22
+ readProps: canEdit ? { ref, onDoubleClick, "data-edit-in-place": "read" } : { ref, "data-edit-in-place": "off" }
23
+ };
24
+ }
25
+ function EditInPlace({
26
+ value,
27
+ canEdit,
28
+ children,
29
+ editing: editingProp,
30
+ onEditingChange,
31
+ readClassName,
32
+ ...editorProps
33
+ }) {
34
+ const [editingState, setEditingState] = useState(false);
35
+ const editing = canEdit && (editingProp ?? editingState);
36
+ const [opening, setOpening] = useState(null);
37
+ const setEditing = (next) => {
38
+ if (editingProp === void 0) setEditingState(next);
39
+ onEditingChange?.(next);
40
+ };
41
+ const { readProps } = useInPlaceTrigger({
42
+ canEdit,
43
+ onOpen: (o) => {
44
+ setOpening(o);
45
+ setEditing(true);
46
+ }
47
+ });
48
+ const lastHeight = useRef(void 0);
49
+ useLayoutEffect(() => {
50
+ if (!editing && readProps.ref.current) lastHeight.current = readProps.ref.current.getBoundingClientRect().height;
51
+ });
52
+ if (editing) {
53
+ return /* @__PURE__ */ jsx(
54
+ InPlaceEditor,
55
+ {
56
+ ...editorProps,
57
+ value,
58
+ caret: opening?.caret ?? null,
59
+ minHeight: opening?.height ?? lastHeight.current,
60
+ close: () => {
61
+ setOpening(null);
62
+ setEditing(false);
63
+ }
64
+ }
65
+ );
66
+ }
67
+ return /* @__PURE__ */ jsx("div", { ...readProps, className: cn(canEdit && "cursor-text", readClassName), children });
68
+ }
69
+ export {
70
+ EditInPlace,
71
+ useInPlaceTrigger
72
+ };
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from "react";
2
+ export interface EditInPlaceTextProps {
3
+ /** The stored text. */
4
+ value: string;
5
+ canEdit: boolean;
6
+ /** The host's write — called only with changed, non-empty text. */
7
+ write: (text: string) => Promise<unknown> | unknown;
8
+ /** The rendered text (a heading, a label). */
9
+ children: ReactNode;
10
+ editing?: boolean;
11
+ onEditingChange?: (editing: boolean) => void;
12
+ /** Field label for assistive tech ("Study guide title"). */
13
+ label: string;
14
+ /** Classes for the field — match the rendered text's size so nothing jumps. */
15
+ inputClassName?: string;
16
+ className?: string;
17
+ }
18
+ export declare function EditInPlaceText({ value, canEdit, write, children, editing: editingProp, onEditingChange, label, inputClassName, className, }: EditInPlaceTextProps): import("react").JSX.Element;
@@ -0,0 +1,159 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { cn } from "@ai-matrx/design-system";
5
+ import { ConfirmDialog } from "@ai-matrx/rich-content/host/app-bindings";
6
+ import { createInPlaceSession } from "./in-place-session.js";
7
+ function EditInPlaceText({
8
+ value,
9
+ canEdit,
10
+ write,
11
+ children,
12
+ editing: editingProp,
13
+ onEditingChange,
14
+ label,
15
+ inputClassName,
16
+ className
17
+ }) {
18
+ const [editingState, setEditingState] = useState(false);
19
+ const editing = canEdit && (editingProp ?? editingState);
20
+ const setEditing = (next) => {
21
+ if (editingProp === void 0) setEditingState(next);
22
+ onEditingChange?.(next);
23
+ };
24
+ const [caretAt, setCaretAt] = useState(null);
25
+ const onDoubleClick = (event) => {
26
+ if (!canEdit) return;
27
+ const sel = window.getSelection();
28
+ const at = sel && sel.anchorNode && event.currentTarget.contains(sel.anchorNode) ? textOffset(event.currentTarget, sel.anchorNode, sel.anchorOffset) : null;
29
+ sel?.removeAllRanges();
30
+ setCaretAt(at);
31
+ setEditing(true);
32
+ };
33
+ if (editing) {
34
+ return /* @__PURE__ */ jsx(
35
+ InlineField,
36
+ {
37
+ value,
38
+ write,
39
+ label,
40
+ caretAt,
41
+ className: cn(inputClassName, className),
42
+ close: () => {
43
+ setCaretAt(null);
44
+ setEditing(false);
45
+ }
46
+ }
47
+ );
48
+ }
49
+ return /* @__PURE__ */ jsx(
50
+ "div",
51
+ {
52
+ "data-edit-in-place": canEdit ? "read" : "off",
53
+ onDoubleClick: canEdit ? onDoubleClick : void 0,
54
+ className: cn(canEdit && "cursor-text", className),
55
+ children
56
+ }
57
+ );
58
+ }
59
+ function textOffset(root, node, offset) {
60
+ const range = document.createRange();
61
+ range.selectNodeContents(root);
62
+ range.setEnd(node, offset);
63
+ return range.toString().length;
64
+ }
65
+ function InlineField({
66
+ value,
67
+ write,
68
+ label,
69
+ caretAt,
70
+ className,
71
+ close
72
+ }) {
73
+ const [draft, setDraft] = useState(value);
74
+ const [session] = useState(() => createInPlaceSession({ openedOn: value, mode: "explicit", write: async (t) => void await write(t) }));
75
+ const [saving, setSaving] = useState(false);
76
+ const [confirmDiscard, setConfirmDiscard] = useState(false);
77
+ const input = useRef(null);
78
+ const done = useRef(false);
79
+ const confirming = useRef(false);
80
+ useEffect(() => {
81
+ const el = input.current;
82
+ if (!el) return;
83
+ el.focus();
84
+ const at = caretAt ?? el.value.length;
85
+ el.setSelectionRange(at, at);
86
+ }, [caretAt]);
87
+ const save = async () => {
88
+ if (done.current || saving) return;
89
+ const text = draft.trim() === "" ? value : draft;
90
+ setSaving(true);
91
+ try {
92
+ await session.save(text);
93
+ done.current = true;
94
+ close();
95
+ } finally {
96
+ setSaving(false);
97
+ }
98
+ };
99
+ const cancel = () => {
100
+ if (draft !== value) {
101
+ confirming.current = true;
102
+ setConfirmDiscard(true);
103
+ } else {
104
+ done.current = true;
105
+ close();
106
+ }
107
+ };
108
+ const onKeyDown = (event) => {
109
+ if (event.key === "Enter") {
110
+ event.preventDefault();
111
+ void save();
112
+ } else if (event.key === "Escape") {
113
+ event.preventDefault();
114
+ event.stopPropagation();
115
+ cancel();
116
+ }
117
+ };
118
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
119
+ /* @__PURE__ */ jsx(
120
+ "input",
121
+ {
122
+ ref: input,
123
+ "aria-label": label,
124
+ value: draft,
125
+ disabled: saving,
126
+ onChange: (event) => setDraft(event.target.value),
127
+ onKeyDown,
128
+ onBlur: () => {
129
+ if (!confirming.current) void save();
130
+ },
131
+ className: cn("w-full rounded-sm bg-transparent outline-none ring-1 ring-ring", className)
132
+ }
133
+ ),
134
+ /* @__PURE__ */ jsx(
135
+ ConfirmDialog,
136
+ {
137
+ open: confirmDiscard,
138
+ onOpenChange: (open) => {
139
+ setConfirmDiscard(open);
140
+ if (!open && !done.current) {
141
+ confirming.current = false;
142
+ input.current?.focus();
143
+ }
144
+ },
145
+ title: "Discard your edit?",
146
+ description: "The text stays exactly as it was saved.",
147
+ confirmLabel: "Discard edit",
148
+ cancelLabel: "Keep editing",
149
+ onConfirm: () => {
150
+ done.current = true;
151
+ close();
152
+ }
153
+ }
154
+ )
155
+ ] });
156
+ }
157
+ export {
158
+ EditInPlaceText
159
+ };
@@ -0,0 +1,41 @@
1
+ import { type ReactNode } from "react";
2
+ import { type RichEditorProps } from "../editor/RichEditor.js";
3
+ import type { CaretContext } from "../core/caret-context.js";
4
+ import { type InPlaceSaveMode } from "./in-place-session.js";
5
+ export type InPlaceEditorPassThrough = Omit<RichEditorProps, "value" | "onSave" | "onChange" | "onCancel" | "onNothingToSave" | "cancelLabel" | "controllerRef">;
6
+ export interface InPlaceEditorProps {
7
+ /** The stored text the editor opens on. */
8
+ value: string;
9
+ mode?: InPlaceSaveMode;
10
+ /** The host's write — called only with changed text (see in-place-session). */
11
+ write: (text: string) => Promise<string | void> | string | void;
12
+ /** Back to the rendered view. */
13
+ close: () => void;
14
+ /** Where the person double-clicked (the caret goes there). */
15
+ caret?: CaretContext | null;
16
+ /** The rendered view's height: the editor never opens shorter (no jump). */
17
+ minHeight?: number;
18
+ /** Offer Expand (the same editor instance full screen). Phones open expanded. */
19
+ expandable?: boolean;
20
+ startExpanded?: boolean;
21
+ /** Words for the discard dialog ("The note stays exactly as it was saved…"). */
22
+ discardDescription?: string;
23
+ /** RichEditor props the host sets (surfaceName, contentSource, toolbarExtras…). */
24
+ editor?: InPlaceEditorPassThrough;
25
+ className?: string;
26
+ /** Data attribute value for tests and page shortcuts. */
27
+ id?: string;
28
+ /** Extra toolbar content (before Expand). */
29
+ toolbarExtras?: ReactNode;
30
+ /**
31
+ * More outcomes beside Save, each run with the current text and then the
32
+ * editor closes (a chat message: Save & resubmit, Fork & resubmit).
33
+ */
34
+ actions?: readonly InPlaceAction[];
35
+ }
36
+ export interface InPlaceAction {
37
+ id: string;
38
+ label: string;
39
+ run: (text: string) => Promise<void>;
40
+ }
41
+ export declare function InPlaceEditor({ value, mode, write, close, caret, minHeight, expandable, startExpanded, discardDescription, editor, className, id, toolbarExtras, actions, }: InPlaceEditorProps): import("react").JSX.Element;