@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,27 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import type { DeclaredVariable } from "../core/variables.js";
3
+ /** What the keymap and menus reach outside the document for. */
4
+ export interface RichShellActions {
5
+ save: () => void;
6
+ find: () => void;
7
+ replace: () => void;
8
+ toggleOutline: () => void;
9
+ toggleFocus: () => void;
10
+ /** Leave focus mode; false when it was not on (so Escape stays free for others). */
11
+ exitFocus: () => boolean;
12
+ cycleView: () => void;
13
+ showHelp: () => void;
14
+ showWordCount: () => void;
15
+ editLink: () => void;
16
+ pickKind: () => Promise<string | null>;
17
+ pickImage: () => void;
18
+ uploadImage: (file: File) => Promise<string | null>;
19
+ variables: () => readonly DeclaredVariable[] | null;
20
+ declareVariable: (name: string) => void;
21
+ /** Approve a deliberate change to an island whose stored bytes are `raw`. */
22
+ approveIsland: (raw: string) => void;
23
+ }
24
+ type Handler = (editor: Editor, shell: RichShellActions) => boolean;
25
+ /** Every shortcut id in the table → what it does. Exported so a test proves none is unbound. */
26
+ export declare const SHORTCUT_HANDLERS: Record<string, Handler>;
27
+ export {};
@@ -0,0 +1,43 @@
1
+ import { insertCodeBlock, insertInlineMath, moveBlock, toggleTaskList } from "../core/commands.js";
2
+ import { markAutoEdit } from "./auto-edit.js";
3
+ const SHORTCUT_HANDLERS = {
4
+ bold: (e) => e.chain().focus().toggleBold().run(),
5
+ italic: (e) => e.chain().focus().toggleItalic().run(),
6
+ strike: (e) => e.chain().focus().toggleStrike().run(),
7
+ code: (e) => e.chain().focus().toggleCode().run(),
8
+ link: (_e, shell) => (shell.editLink(), true),
9
+ paragraph: (e) => e.chain().focus().setParagraph().run(),
10
+ heading1: (e) => e.chain().focus().toggleHeading({ level: 1 }).run(),
11
+ heading2: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(),
12
+ heading3: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
13
+ heading4: (e) => e.chain().focus().toggleHeading({ level: 4 }).run(),
14
+ heading5: (e) => e.chain().focus().toggleHeading({ level: 5 }).run(),
15
+ heading6: (e) => e.chain().focus().toggleHeading({ level: 6 }).run(),
16
+ orderedList: (e) => e.chain().focus().toggleOrderedList().run(),
17
+ bulletList: (e) => e.chain().focus().toggleBulletList().run(),
18
+ taskList: (e) => toggleTaskList(e),
19
+ quote: (e) => e.chain().focus().toggleBlockquote().run(),
20
+ moveUp: (e) => moveBlock(e, "up"),
21
+ moveDown: (e) => moveBlock(e, "down"),
22
+ codeBlock: (e) => {
23
+ markAutoEdit(e, insertCodeBlock(e));
24
+ return true;
25
+ },
26
+ inlineMath: (e) => {
27
+ const { from, to } = e.state.selection;
28
+ return insertInlineMath(e, from === to ? "x" : e.state.doc.textBetween(from, to));
29
+ },
30
+ undo: (e) => e.commands.undo(),
31
+ redo: (e) => e.commands.redo(),
32
+ find: (_e, shell) => (shell.find(), true),
33
+ replace: (_e, shell) => (shell.replace(), true),
34
+ save: (_e, shell) => (shell.save(), true),
35
+ wordCount: (_e, shell) => (shell.showWordCount(), true),
36
+ outline: (_e, shell) => (shell.toggleOutline(), true),
37
+ focus: (_e, shell) => (shell.toggleFocus(), true),
38
+ cycleView: (_e, shell) => (shell.cycleView(), true),
39
+ help: (_e, shell) => (shell.showHelp(), true)
40
+ };
41
+ export {
42
+ SHORTCUT_HANDLERS
43
+ };
@@ -0,0 +1,18 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { type LucideIcon } from "lucide-react";
3
+ export interface SlashHost {
4
+ pickKind: () => Promise<string | null>;
5
+ pickImage: () => void;
6
+ }
7
+ export interface SlashItem {
8
+ id: string;
9
+ title: string;
10
+ description: string;
11
+ group: string;
12
+ icon: LucideIcon;
13
+ keywords: string[];
14
+ run: (editor: Editor, host: SlashHost) => void;
15
+ }
16
+ export declare const SLASH_ITEMS: readonly SlashItem[];
17
+ /** Filter by title and keywords, best matches first. */
18
+ export declare function filterSlashItems(query: string): SlashItem[];
@@ -0,0 +1,152 @@
1
+ import {
2
+ AlertTriangle,
3
+ Braces,
4
+ CheckSquare,
5
+ Code,
6
+ FileText,
7
+ Footprints,
8
+ Heading1,
9
+ Heading2,
10
+ Heading3,
11
+ ImagePlus,
12
+ Info,
13
+ Lightbulb,
14
+ List,
15
+ ListOrdered,
16
+ Minus,
17
+ Quote,
18
+ SeparatorHorizontal,
19
+ Shapes,
20
+ Sigma,
21
+ Table
22
+ } from "lucide-react";
23
+ import {
24
+ insertCodeBlock,
25
+ insertFootnote,
26
+ insertInlineMath,
27
+ insertIsland,
28
+ insertMathBlock,
29
+ insertPageBreak,
30
+ setCallout,
31
+ toggleTaskList
32
+ } from "../core/commands.js";
33
+ import { markAutoEdit } from "./auto-edit.js";
34
+ const callout = (type, icon, title) => ({
35
+ id: `callout-${type.toLowerCase()}`,
36
+ title,
37
+ description: `${/^[aeiou]/i.test(title) ? "An" : "A"} ${title.toLowerCase()} callout (> [!${type}])`,
38
+ group: "Callouts",
39
+ icon,
40
+ keywords: ["callout", "admonition", "alert", type.toLowerCase()],
41
+ run: (editor) => {
42
+ setCallout(editor, type);
43
+ }
44
+ });
45
+ const SLASH_ITEMS = [
46
+ { id: "text", title: "Text", description: "Plain paragraph", group: "Basic", icon: FileText, keywords: ["paragraph", "normal"], run: (e) => void e.chain().focus().setParagraph().run() },
47
+ { id: "h1", title: "Heading 1", description: "Big section heading", group: "Basic", icon: Heading1, keywords: ["title", "#"], run: (e) => void e.chain().focus().setHeading({ level: 1 }).run() },
48
+ { id: "h2", title: "Heading 2", description: "Medium section heading", group: "Basic", icon: Heading2, keywords: ["subtitle", "##"], run: (e) => void e.chain().focus().setHeading({ level: 2 }).run() },
49
+ { id: "h3", title: "Heading 3", description: "Small section heading", group: "Basic", icon: Heading3, keywords: ["###"], run: (e) => void e.chain().focus().setHeading({ level: 3 }).run() },
50
+ { id: "bullets", title: "Bulleted list", description: "A simple list", group: "Basic", icon: List, keywords: ["ul", "unordered", "-"], run: (e) => void e.chain().focus().toggleBulletList().run() },
51
+ { id: "numbers", title: "Numbered list", description: "Steps in order", group: "Basic", icon: ListOrdered, keywords: ["ol", "ordered", "1."], run: (e) => void e.chain().focus().toggleOrderedList().run() },
52
+ { id: "todo", title: "Checklist", description: "Tasks with checkboxes", group: "Basic", icon: CheckSquare, keywords: ["task", "todo", "checkbox", "[ ]"], run: (e) => void toggleTaskList(e) },
53
+ { id: "quote", title: "Quote", description: "A quoted passage", group: "Basic", icon: Quote, keywords: ["blockquote", ">"], run: (e) => void e.chain().focus().toggleBlockquote().run() },
54
+ { id: "table", title: "Table", description: "Rows and columns", group: "Basic", icon: Table, keywords: ["grid", "columns"], run: (e) => void e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
55
+ { id: "divider", title: "Divider", description: "A horizontal rule", group: "Basic", icon: Minus, keywords: ["hr", "rule", "line", "---"], run: (e) => void e.chain().focus().setHorizontalRule().run() },
56
+ callout("NOTE", Info, "Note"),
57
+ callout("TIP", Lightbulb, "Tip"),
58
+ callout("IMPORTANT", Info, "Important"),
59
+ callout("WARNING", AlertTriangle, "Warning"),
60
+ callout("CAUTION", AlertTriangle, "Caution"),
61
+ {
62
+ id: "code",
63
+ title: "Code block",
64
+ description: "Code with a language",
65
+ group: "Insert",
66
+ icon: Code,
67
+ keywords: ["```", "snippet", "program"],
68
+ run: (e) => markAutoEdit(e, insertCodeBlock(e))
69
+ },
70
+ {
71
+ id: "equation",
72
+ title: "Equation",
73
+ description: "Display math ($$\u2026$$) with live preview",
74
+ group: "Insert",
75
+ icon: Sigma,
76
+ keywords: ["math", "latex", "katex", "formula", "$$"],
77
+ run: (e) => markAutoEdit(e, insertMathBlock(e))
78
+ },
79
+ {
80
+ id: "inline-equation",
81
+ title: "Inline equation",
82
+ description: "Math inside a sentence ($\u2026$)",
83
+ group: "Insert",
84
+ icon: Sigma,
85
+ keywords: ["math", "inline", "latex", "$"],
86
+ run: (e) => void insertInlineMath(e, "x")
87
+ },
88
+ {
89
+ id: "variable",
90
+ title: "Variable",
91
+ description: "A {{variable}} filled in at run time",
92
+ group: "Insert",
93
+ icon: Braces,
94
+ keywords: ["{{", "placeholder", "field", "input"],
95
+ run: (e) => void e.chain().focus().insertContent("{{").run()
96
+ },
97
+ {
98
+ id: "kind",
99
+ title: "Structured block",
100
+ description: "A kind from the shape registry",
101
+ group: "Insert",
102
+ icon: Shapes,
103
+ keywords: ["kind", "shape", "component", "json", "block"],
104
+ run: (e, host) => {
105
+ void host.pickKind().then((raw) => {
106
+ if (raw) insertIsland(e, raw, "json");
107
+ });
108
+ }
109
+ },
110
+ {
111
+ id: "image",
112
+ title: "Image",
113
+ description: "Upload a picture",
114
+ group: "Insert",
115
+ icon: ImagePlus,
116
+ keywords: ["picture", "photo", "upload", "img"],
117
+ run: (_e, host) => host.pickImage()
118
+ },
119
+ {
120
+ id: "footnote",
121
+ title: "Footnote",
122
+ description: "A numbered note at the end",
123
+ group: "Insert",
124
+ icon: Footprints,
125
+ keywords: ["reference", "note", "[^1]"],
126
+ run: (e) => void insertFootnote(e)
127
+ },
128
+ {
129
+ id: "page-break",
130
+ title: "Page break",
131
+ description: "Start a new page when printed",
132
+ group: "Insert",
133
+ icon: SeparatorHorizontal,
134
+ keywords: ["print", "pdf", "new page", "pagebreak"],
135
+ run: (e) => void insertPageBreak(e)
136
+ }
137
+ ];
138
+ function filterSlashItems(query) {
139
+ const q = query.trim().toLowerCase();
140
+ if (!q) return [...SLASH_ITEMS];
141
+ const scored = SLASH_ITEMS.map((item) => {
142
+ const title = item.title.toLowerCase();
143
+ const score = title.startsWith(q) ? 3 : title.includes(q) ? 2 : item.keywords.some((keyword) => keyword.toLowerCase().includes(q)) ? 1 : 0;
144
+ return { item, score };
145
+ }).filter((entry) => entry.score > 0);
146
+ scored.sort((a, b) => b.score - a.score);
147
+ return scored.map((entry) => entry.item);
148
+ }
149
+ export {
150
+ SLASH_ITEMS,
151
+ filterSlashItems
152
+ };
@@ -0,0 +1,10 @@
1
+ import { type Extensions } from "@tiptap/core";
2
+ import { type EditorState } from "@tiptap/pm/state";
3
+ import { type RichShellActions } from "./shortcut-handlers.js";
4
+ export type { RichShellActions } from "./shortcut-handlers.js";
5
+ /** True while the `/` or `{{` menu is open. */
6
+ export declare function suggestionOpen(state: EditorState): boolean;
7
+ export declare function createVisualExtensions(options: {
8
+ placeholder?: string;
9
+ shell: RichShellActions;
10
+ }): Extensions;
@@ -0,0 +1,246 @@
1
+ import { Extension } from "@tiptap/core";
2
+ import { ReactNodeViewRenderer } from "@tiptap/react";
3
+ import Suggestion from "@tiptap/suggestion";
4
+ import { toast } from "@ai-matrx/rich-content/host/toast";
5
+ import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
6
+ import { TrailingNode } from "@tiptap/extensions";
7
+ import { createRichEditorExtensions } from "../core/extensions.js";
8
+ import { islandsReleasedByHistory } from "../core/history-approval.js";
9
+ import { fenceFromParagraph, insertInlineIsland, insertVariable, TASK_OPEN } from "../core/commands.js";
10
+ import { RICH_EDITOR_SHORTCUTS, TYPED_TRIGGERS } from "../core/shortcuts.js";
11
+ import { SHORTCUT_HANDLERS } from "./shortcut-handlers.js";
12
+ import { variableName, variableSuggestions } from "../core/variables.js";
13
+ import { IslandBlockView } from "./nodes/IslandBlockView.js";
14
+ import { SourceLockedView } from "./nodes/SourceLockedView.js";
15
+ import { InlineIslandView } from "./nodes/InlineIslandView.js";
16
+ import { CalloutView } from "./nodes/CalloutView.js";
17
+ import { RichDecorations } from "./decorations.js";
18
+ import { ContextMenuCaret } from "./context-menu-caret.js";
19
+ import { markAutoEdit } from "./auto-edit.js";
20
+ import { filterSlashItems } from "./slash-items.js";
21
+ import { suggestionRenderer } from "./menus/SuggestionMenu.js";
22
+ const slashKey = new PluginKey("richEditorSlash");
23
+ const variableKey = new PluginKey("richEditorVariable");
24
+ function stopControlEvents({ event }) {
25
+ const target = event.target;
26
+ if (!(target instanceof Element)) return false;
27
+ return Boolean(target.closest("button, input, textarea, select, [role='button'], .cm-editor, [data-node-controls]"));
28
+ }
29
+ function suggestionOpen(state) {
30
+ const active = (key) => key.getState(state)?.active === true;
31
+ return active(slashKey) || active(variableKey);
32
+ }
33
+ function listItemDepth(editor) {
34
+ const { $from } = editor.state.selection;
35
+ for (let depth = $from.depth; depth > 0; depth -= 1) {
36
+ if ($from.node(depth).type.name === "listItem") return depth;
37
+ }
38
+ return -1;
39
+ }
40
+ function createVisualExtensions(options) {
41
+ const { shell } = options;
42
+ const base = createRichEditorExtensions({
43
+ placeholder: options.placeholder,
44
+ onPasteNotice: (message) => toast.info(message)
45
+ }).map((extension) => {
46
+ switch (extension.name) {
47
+ case "islandBlock":
48
+ return extension.extend({ addNodeView: () => ReactNodeViewRenderer(IslandBlockView, { stopEvent: stopControlEvents }) });
49
+ case "sourceLocked":
50
+ return extension.extend({ addNodeView: () => ReactNodeViewRenderer(SourceLockedView, { stopEvent: stopControlEvents }) });
51
+ case "inlineIsland":
52
+ return extension.extend({ addNodeView: () => ReactNodeViewRenderer(InlineIslandView, { stopEvent: stopControlEvents }) });
53
+ case "blockquote":
54
+ return extension.extend({ addNodeView: () => ReactNodeViewRenderer(CalloutView, { stopEvent: stopControlEvents }) });
55
+ default:
56
+ return extension;
57
+ }
58
+ });
59
+ const keymap = Extension.create({
60
+ name: "richEditorKeymap",
61
+ priority: 1e3,
62
+ addKeyboardShortcuts() {
63
+ const bindings = {};
64
+ for (const spec of RICH_EDITOR_SHORTCUTS) {
65
+ const handler = SHORTCUT_HANDLERS[spec.id];
66
+ if (!handler) continue;
67
+ for (const key of spec.keys) {
68
+ if (TYPED_TRIGGERS.has(key)) continue;
69
+ bindings[key] = () => handler(this.editor, shell);
70
+ }
71
+ }
72
+ bindings.Escape = () => shell.exitFocus();
73
+ bindings.Enter = () => {
74
+ if (suggestionOpen(this.editor.state)) return false;
75
+ if (fenceFromParagraph(this.editor)) {
76
+ const { from } = this.editor.state.selection;
77
+ markAutoEdit(this.editor, from);
78
+ return true;
79
+ }
80
+ const depth = listItemDepth(this.editor);
81
+ if (depth === -1) return false;
82
+ const item = this.editor.state.selection.$from.node(depth);
83
+ if (typeof item.attrs.mdTask !== "string") return false;
84
+ if (item.textContent.length === 0) return false;
85
+ if (!this.editor.commands.splitListItem("listItem")) return false;
86
+ this.editor.commands.updateAttributes("listItem", { mdTask: TASK_OPEN });
87
+ return true;
88
+ };
89
+ return bindings;
90
+ }
91
+ });
92
+ const slash = Extension.create({
93
+ name: "richEditorSlash",
94
+ priority: 1100,
95
+ addProseMirrorPlugins() {
96
+ const host = { pickKind: shell.pickKind, pickImage: shell.pickImage };
97
+ return [
98
+ Suggestion({
99
+ pluginKey: slashKey,
100
+ editor: this.editor,
101
+ char: "/",
102
+ // "/code block" keeps filtering past the space, like Notion.
103
+ allowSpaces: true,
104
+ items: ({ query }) => filterSlashItems(query).map((item) => ({
105
+ id: item.id,
106
+ title: item.title,
107
+ description: item.description,
108
+ group: item.group,
109
+ icon: item.icon
110
+ })),
111
+ command: ({ editor, range, props }) => {
112
+ const item = filterSlashItems("").find((candidate) => candidate.id === props.id);
113
+ editor.chain().focus().deleteRange(range).run();
114
+ item?.run(editor, host);
115
+ },
116
+ // With spaces allowed, "/ then prose" that matches nothing must not eat Enter.
117
+ render: suggestionRenderer({ emptyText: "Nothing matches \u2014 keep typing or press Escape.", label: "Insert", holdEnterWhenEmpty: false })
118
+ })
119
+ ];
120
+ }
121
+ });
122
+ const variables = Extension.create({
123
+ name: "richEditorVariables",
124
+ priority: 1100,
125
+ addProseMirrorPlugins() {
126
+ return [
127
+ Suggestion({
128
+ pluginKey: variableKey,
129
+ editor: this.editor,
130
+ char: "{{",
131
+ allowedPrefixes: null,
132
+ items: ({ query, editor }) => {
133
+ const inDocument = [];
134
+ editor.state.doc.descendants((node) => {
135
+ if (node.type.name !== "inlineIsland") return true;
136
+ const name = variableName(String(node.attrs.raw ?? ""))?.trim();
137
+ if (name && !inDocument.includes(name)) inDocument.push(name);
138
+ return false;
139
+ });
140
+ return variableSuggestions(shell.variables() ?? [], inDocument, query).map(
141
+ (suggestion) => suggestion.source === "new" ? { id: `new:${suggestion.name}`, title: `Add \u201C${suggestion.name}\u201D`, description: "A new variable", group: "New" } : {
142
+ id: `var:${suggestion.name}`,
143
+ title: suggestion.name,
144
+ description: suggestion.description,
145
+ hint: suggestion.type,
146
+ group: suggestion.source === "declared" ? "Declared variables" : "In this document"
147
+ }
148
+ );
149
+ },
150
+ command: ({ editor, range, props }) => {
151
+ const name = props.id.replace(/^(var|new):/, "");
152
+ editor.chain().focus().deleteRange(range).run();
153
+ insertVariable(editor, name);
154
+ if (props.id.startsWith("new:")) shell.declareVariable(name);
155
+ },
156
+ render: suggestionRenderer({
157
+ emptyText: "Type a name for the variable.",
158
+ label: "Variables"
159
+ })
160
+ })
161
+ ];
162
+ }
163
+ });
164
+ const imagePaste = Extension.create({
165
+ name: "richEditorImagePaste",
166
+ addProseMirrorPlugins() {
167
+ const editor = this.editor;
168
+ const upload = (files) => {
169
+ for (const file of files) {
170
+ void shell.uploadImage(file).then((markdown) => {
171
+ if (markdown) insertInlineIsland(editor, markdown, "md_image");
172
+ });
173
+ }
174
+ };
175
+ const images = (list) => Array.from(list ?? []).filter((file) => file.type.startsWith("image/"));
176
+ return [
177
+ new Plugin({
178
+ key: new PluginKey("richEditorImagePaste"),
179
+ props: {
180
+ handlePaste: (_view, event) => {
181
+ const files = images(event.clipboardData?.files);
182
+ if (!files.length) return false;
183
+ event.preventDefault();
184
+ upload(files);
185
+ return true;
186
+ },
187
+ handleDrop: (_view, event) => {
188
+ const files = images(event.dataTransfer?.files);
189
+ if (!files.length) return false;
190
+ event.preventDefault();
191
+ upload(files);
192
+ return true;
193
+ }
194
+ }
195
+ })
196
+ ];
197
+ }
198
+ });
199
+ const protectSelectedIslands = Extension.create({
200
+ name: "richEditorProtectSelectedIslands",
201
+ addProseMirrorPlugins() {
202
+ return [
203
+ new Plugin({
204
+ key: new PluginKey("richEditorProtectSelectedIslands"),
205
+ props: {
206
+ handleTextInput: (view, _from, _to, text) => {
207
+ const { selection } = view.state;
208
+ if (!(selection instanceof NodeSelection)) return false;
209
+ const name = selection.node.type.name;
210
+ if (name !== "islandBlock" && name !== "sourceLocked") return false;
211
+ const paragraph = view.state.schema.nodes.paragraph;
212
+ if (!paragraph) return false;
213
+ const at = selection.to;
214
+ const tr = view.state.tr.insert(at, paragraph.create(null, view.state.schema.text(text)));
215
+ tr.setSelection(TextSelection.create(tr.doc, at + 1 + text.length));
216
+ view.dispatch(tr.scrollIntoView());
217
+ return true;
218
+ }
219
+ }
220
+ })
221
+ ];
222
+ }
223
+ });
224
+ const historyApproval = Extension.create({
225
+ name: "richEditorHistoryApproval",
226
+ onTransaction({ transaction }) {
227
+ for (const raw of islandsReleasedByHistory(transaction)) shell.approveIsland(raw);
228
+ }
229
+ });
230
+ return [
231
+ ...base,
232
+ historyApproval,
233
+ keymap,
234
+ slash,
235
+ variables,
236
+ imagePaste,
237
+ protectSelectedIslands,
238
+ TrailingNode.configure({ node: "paragraph" }),
239
+ RichDecorations,
240
+ ContextMenuCaret
241
+ ];
242
+ }
243
+ export {
244
+ createVisualExtensions,
245
+ suggestionOpen
246
+ };
@@ -0,0 +1,9 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import type { FormatCommandId } from "../core/markdown-format.js";
3
+ import type { FormatTarget } from "../format/format-target.js";
4
+ /**
5
+ * The visual engine. `overrides` carries the verbs the editor implements in
6
+ * its own shell (link field, checklist, code-block island) — the same handlers
7
+ * its keyboard shortcuts run, so a button and a chord can never differ.
8
+ */
9
+ export declare function visualFormatTarget(editor: Editor, overrides?: Partial<Record<FormatCommandId, () => boolean>>): FormatTarget;
@@ -0,0 +1,43 @@
1
+ import { NodeSelection } from "@tiptap/pm/state";
2
+ const VISUAL = {
3
+ bold: { run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive("bold") },
4
+ italic: { run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive("italic") },
5
+ strike: { run: (e) => e.chain().focus().toggleStrike().run(), active: (e) => e.isActive("strike") },
6
+ code: { run: (e) => e.chain().focus().toggleCode().run(), active: (e) => e.isActive("code") },
7
+ // Link opens the editor's own link field — the host passes it in.
8
+ link: { run: () => false, active: (e) => e.isActive("link") },
9
+ heading1: { run: (e) => e.chain().focus().toggleHeading({ level: 1 }).run(), active: (e) => e.isActive("heading", { level: 1 }) },
10
+ heading2: { run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(), active: (e) => e.isActive("heading", { level: 2 }) },
11
+ heading3: { run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(), active: (e) => e.isActive("heading", { level: 3 }) },
12
+ bulletList: { run: (e) => e.chain().focus().toggleBulletList().run(), active: (e) => e.isActive("bulletList") },
13
+ orderedList: { run: (e) => e.chain().focus().toggleOrderedList().run(), active: (e) => e.isActive("orderedList") },
14
+ taskList: { run: () => false, active: (e) => e.isActive("taskList") },
15
+ quote: { run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive("blockquote") },
16
+ codeBlock: { run: () => false, active: () => false }
17
+ };
18
+ function visualFormatTarget(editor, overrides = {}) {
19
+ return {
20
+ engine: "visual",
21
+ run: (command) => {
22
+ if (editor.isDestroyed || !editor.isEditable) return false;
23
+ const own = overrides[command];
24
+ return own ? own() : VISUAL[command].run(editor);
25
+ },
26
+ isActive: (command) => !editor.isDestroyed && VISUAL[command].active(editor),
27
+ canFormat: () => {
28
+ if (editor.isDestroyed || !editor.isEditable) return false;
29
+ const { selection, doc } = editor.state;
30
+ if (selection.empty || selection instanceof NodeSelection) return false;
31
+ return doc.textBetween(selection.from, selection.to).trim().length > 0;
32
+ },
33
+ subscribe: (onChange) => {
34
+ editor.on("transaction", onChange);
35
+ return () => {
36
+ editor.off("transaction", onChange);
37
+ };
38
+ }
39
+ };
40
+ }
41
+ export {
42
+ visualFormatTarget
43
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/rich-editor",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
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",
@@ -19,7 +19,9 @@
19
19
  "LICENSE",
20
20
  "CHANGELOG.md"
21
21
  ],
22
- "sideEffects": false,
22
+ "sideEffects": [
23
+ "**/*.css"
24
+ ],
23
25
  "exports": {
24
26
  "./core/*": {
25
27
  "types": "./dist/core/*.d.ts",
@@ -33,9 +35,38 @@
33
35
  "types": "./dist/format/*.d.ts",
34
36
  "import": "./dist/format/*.js"
35
37
  },
38
+ "./islands/*": {
39
+ "types": "./dist/islands/*.d.ts",
40
+ "import": "./dist/islands/*.js"
41
+ },
42
+ "./panels/*": {
43
+ "types": "./dist/panels/*.d.ts",
44
+ "import": "./dist/panels/*.js"
45
+ },
46
+ "./source/*": {
47
+ "types": "./dist/source/*.d.ts",
48
+ "import": "./dist/source/*.js"
49
+ },
50
+ "./visual/*": {
51
+ "types": "./dist/visual/*.d.ts",
52
+ "import": "./dist/visual/*.js"
53
+ },
54
+ "./editor/*": {
55
+ "types": "./dist/editor/*.d.ts",
56
+ "import": "./dist/editor/*.js"
57
+ },
58
+ "./rich-editor.css": "./dist/editor/rich-editor.css",
36
59
  "./package.json": "./package.json"
37
60
  },
38
61
  "dependencies": {
62
+ "@codemirror/autocomplete": "^6.20.3",
63
+ "@codemirror/commands": "^6.11.1",
64
+ "@codemirror/lang-json": "^6.0.2",
65
+ "@codemirror/language": "^6.12.4",
66
+ "@codemirror/state": "^6.7.6",
67
+ "@codemirror/view": "^6.43.13",
68
+ "@lezer/highlight": "^1.2.4",
69
+ "@lezer/markdown": "^1.7.2",
39
70
  "@tiptap/core": "^3.31.3",
40
71
  "@tiptap/extension-blockquote": "^3.31.3",
41
72
  "@tiptap/extension-bold": "^3.31.3",
@@ -53,10 +84,17 @@
53
84
  "@tiptap/extension-text": "^3.31.3",
54
85
  "@tiptap/extensions": "^3.31.3",
55
86
  "@tiptap/pm": "^3.31.3",
87
+ "@tiptap/react": "^3.31.3",
88
+ "@tiptap/suggestion": "^3.31.3",
56
89
  "github-slugger": "^2.0.0",
90
+ "lucide-react": "^1.48.0",
91
+ "@ai-matrx/agents": "latest",
92
+ "@ai-matrx/alchemy": "latest",
57
93
  "@ai-matrx/content-ir": "latest",
58
- "@ai-matrx/rich-content": "latest",
59
- "@ai-matrx/print": "latest"
94
+ "@ai-matrx/design-system": "latest",
95
+ "@ai-matrx/kit": "latest",
96
+ "@ai-matrx/print": "latest",
97
+ "@ai-matrx/rich-content": "latest"
60
98
  },
61
99
  "peerDependencies": {
62
100
  "react": ">=19.0.0",
@@ -87,7 +125,7 @@
87
125
  "node": ">=20"
88
126
  },
89
127
  "scripts": {
90
- "build": "pnpm --filter @ai-matrx/rich-content build && node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup && node ./scripts/tsc-own.mjs -p tsconfig.build.json && node ./scripts/finish-build.mjs",
128
+ "build": "pnpm --filter @ai-matrx/rich-content build && node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup && node -e \"require('node:fs').cpSync('src/editor/rich-editor.css','dist/editor/rich-editor.css')\" && node ./scripts/tsc-own.mjs -p tsconfig.build.json && node ./scripts/finish-build.mjs",
91
129
  "check:package": "pnpm build && publint && pnpm verify:modules-tarball && pnpm verify:tarball && pnpm check:host-boundary",
92
130
  "check:host-boundary": "node ./scripts/check-host-boundary.mjs && node ./scripts/check-host-boundary.mjs --self-test",
93
131
  "deps:write": "node ./scripts/finish-build.mjs --write-deps",