@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.
- package/CHANGELOG.md +13 -0
- package/dist/core/markdown-format.js +3 -3
- package/dist/core/text-insertion.d.ts +14 -0
- package/dist/core/text-insertion.js +55 -0
- package/dist/editor/RichEditor.d.ts +4 -0
- package/dist/editor/RichEditor.js +14 -0
- package/dist/editor/RichEditorContext.d.ts +16 -0
- package/dist/editor/RichEditorContext.js +17 -0
- package/dist/editor/RichEditorImpl.d.ts +137 -0
- package/dist/editor/RichEditorImpl.js +714 -0
- package/dist/editor/caretInsert.d.ts +11 -0
- package/dist/editor/caretInsert.js +9 -0
- package/dist/editor/rich-editor.css +181 -0
- package/dist/format/format-actions.d.ts +10 -0
- package/dist/format/format-actions.js +78 -0
- package/dist/format/useTextareaFormatting.d.ts +4 -0
- package/dist/format/useTextareaFormatting.js +37 -0
- package/dist/in-place/EditInPlace.d.ts +38 -0
- package/dist/in-place/EditInPlace.js +72 -0
- package/dist/in-place/EditInPlaceText.d.ts +18 -0
- package/dist/in-place/EditInPlaceText.js +159 -0
- package/dist/in-place/InPlaceEditor.d.ts +41 -0
- package/dist/in-place/InPlaceEditor.js +169 -0
- package/dist/islands/IslandCodeEditor.d.ts +12 -0
- package/dist/islands/IslandCodeEditor.js +82 -0
- package/dist/islands/IslandPreview.d.ts +6 -0
- package/dist/islands/IslandPreview.js +31 -0
- package/dist/islands/island-meta.d.ts +28 -0
- package/dist/islands/island-meta.js +176 -0
- package/dist/panels/FindReplacePanel.d.ts +10 -0
- package/dist/panels/FindReplacePanel.js +118 -0
- package/dist/panels/KindPicker.d.ts +4 -0
- package/dist/panels/KindPicker.js +95 -0
- package/dist/panels/OutlinePanel.d.ts +6 -0
- package/dist/panels/OutlinePanel.js +60 -0
- package/dist/panels/ShortcutsDialog.d.ts +4 -0
- package/dist/panels/ShortcutsDialog.js +24 -0
- package/dist/source/SourceEditor.d.ts +28 -0
- package/dist/source/SourceEditor.js +429 -0
- package/dist/source/cm-theme.d.ts +4 -0
- package/dist/source/cm-theme.js +71 -0
- package/dist/source/live-preview.d.ts +30 -0
- package/dist/source/live-preview.js +304 -0
- package/dist/source/markdown-language.d.ts +2 -0
- package/dist/source/markdown-language.js +7 -0
- package/dist/visual/BlockHandle.d.ts +6 -0
- package/dist/visual/BlockHandle.js +173 -0
- package/dist/visual/VisualEditor.d.ts +56 -0
- package/dist/visual/VisualEditor.js +312 -0
- package/dist/visual/auto-edit.d.ts +3 -0
- package/dist/visual/auto-edit.js +17 -0
- package/dist/visual/context-menu-caret.d.ts +10 -0
- package/dist/visual/context-menu-caret.js +42 -0
- package/dist/visual/decorations.d.ts +9 -0
- package/dist/visual/decorations.js +131 -0
- package/dist/visual/format-actions.d.ts +20 -0
- package/dist/visual/format-actions.js +128 -0
- package/dist/visual/menus/SuggestionMenu.d.ts +37 -0
- package/dist/visual/menus/SuggestionMenu.js +121 -0
- package/dist/visual/nodes/CalloutView.d.ts +2 -0
- package/dist/visual/nodes/CalloutView.js +45 -0
- package/dist/visual/nodes/InlineIslandView.d.ts +2 -0
- package/dist/visual/nodes/InlineIslandView.js +174 -0
- package/dist/visual/nodes/IslandBlockView.d.ts +2 -0
- package/dist/visual/nodes/IslandBlockView.js +206 -0
- package/dist/visual/nodes/SourceLockedView.d.ts +2 -0
- package/dist/visual/nodes/SourceLockedView.js +62 -0
- package/dist/visual/shortcut-handlers.d.ts +27 -0
- package/dist/visual/shortcut-handlers.js +43 -0
- package/dist/visual/slash-items.d.ts +18 -0
- package/dist/visual/slash-items.js +152 -0
- package/dist/visual/visual-extensions.d.ts +10 -0
- package/dist/visual/visual-extensions.js +246 -0
- package/dist/visual/visual-format-target.d.ts +9 -0
- package/dist/visual/visual-format-target.js +43 -0
- package/package.json +42 -4
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
4
|
+
import { redoDepth, undoDepth } from "@tiptap/pm/history";
|
|
5
|
+
import { EditorContent, useEditor } from "@tiptap/react";
|
|
6
|
+
import { getSchema } from "@tiptap/core";
|
|
7
|
+
import { TextSelection } from "@tiptap/pm/state";
|
|
8
|
+
import { cn } from "@ai-matrx/design-system";
|
|
9
|
+
import { toast } from "@ai-matrx/rich-content/host/toast";
|
|
10
|
+
import { TableWriteRefused } from "@ai-matrx/rich-content/utils/table-source";
|
|
11
|
+
import { createRichEditorExtensions } from "../core/extensions.js";
|
|
12
|
+
import { replaceSelectionWithMarkdown } from "../core/paste-markdown.js";
|
|
13
|
+
import { insertMarkdownBlock } from "../core/block-insert.js";
|
|
14
|
+
import {
|
|
15
|
+
buildVisualDocument,
|
|
16
|
+
captureBaseline,
|
|
17
|
+
serializeVisualDocument
|
|
18
|
+
} from "../core/visual-document.js";
|
|
19
|
+
import { findInDoc, replaceInDoc } from "../core/visual-find.js";
|
|
20
|
+
import { locateCaret } from "../core/caret-context.js";
|
|
21
|
+
import { createVisualExtensions } from "./visual-extensions.js";
|
|
22
|
+
import { findHighlightKey } from "./decorations.js";
|
|
23
|
+
import { BlockHandle } from "./BlockHandle.js";
|
|
24
|
+
import { useSelectionZone } from "@ai-matrx/rich-content/selection-toolbar/selection-zones";
|
|
25
|
+
import { RICH_EDITOR_HOST_KEY, islandSelectionOf, tableAnchorOf } from "./format-actions.js";
|
|
26
|
+
import { useRichEditorContext } from "../editor/RichEditorContext.js";
|
|
27
|
+
const HEADLESS_SCHEMA = getSchema(createRichEditorExtensions());
|
|
28
|
+
const REPORT_DELAY_MS = 120;
|
|
29
|
+
function VisualEditor({
|
|
30
|
+
initialText,
|
|
31
|
+
onChange,
|
|
32
|
+
onLoadStats,
|
|
33
|
+
shell,
|
|
34
|
+
placeholder,
|
|
35
|
+
focusMode,
|
|
36
|
+
handleRef
|
|
37
|
+
}) {
|
|
38
|
+
const context = useRichEditorContext();
|
|
39
|
+
const container = useRef(null);
|
|
40
|
+
const [load] = useState(() => buildVisualDocument(initialText, HEADLESS_SCHEMA));
|
|
41
|
+
const baseline = useRef(null);
|
|
42
|
+
const timer = useRef(null);
|
|
43
|
+
const lastReported = useRef(initialText);
|
|
44
|
+
const findState = useRef({ matches: [], index: -1 });
|
|
45
|
+
const refusalShown = useRef(null);
|
|
46
|
+
const report = (editor2, strict = false) => {
|
|
47
|
+
if (!baseline.current) return lastReported.current;
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = serializeVisualDocument(editor2.state.doc, baseline.current);
|
|
51
|
+
refusalShown.current = null;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (!(error instanceof TableWriteRefused)) throw error;
|
|
54
|
+
if (strict) throw error;
|
|
55
|
+
if (refusalShown.current !== error.message) {
|
|
56
|
+
refusalShown.current = error.message;
|
|
57
|
+
toast.error(error.message);
|
|
58
|
+
}
|
|
59
|
+
return lastReported.current;
|
|
60
|
+
}
|
|
61
|
+
if (text !== lastReported.current) {
|
|
62
|
+
lastReported.current = text;
|
|
63
|
+
onChange(text);
|
|
64
|
+
}
|
|
65
|
+
return text;
|
|
66
|
+
};
|
|
67
|
+
const [extensions] = useState(() => createVisualExtensions({ placeholder, shell }));
|
|
68
|
+
const editorsBuilt = useRef(0);
|
|
69
|
+
const reload = useRef(null);
|
|
70
|
+
const plan = () => (reload.current ?? load).plan;
|
|
71
|
+
const editor = useEditor({
|
|
72
|
+
extensions,
|
|
73
|
+
content: load.json,
|
|
74
|
+
onBeforeCreate: ({ editor: next }) => {
|
|
75
|
+
editorsBuilt.current += 1;
|
|
76
|
+
if (editorsBuilt.current === 1) return;
|
|
77
|
+
reload.current = buildVisualDocument(lastReported.current, HEADLESS_SCHEMA);
|
|
78
|
+
baseline.current = null;
|
|
79
|
+
next.setOptions({ content: reload.current.json });
|
|
80
|
+
},
|
|
81
|
+
immediatelyRender: false,
|
|
82
|
+
editable: !context.readOnly,
|
|
83
|
+
editorProps: {
|
|
84
|
+
attributes: {
|
|
85
|
+
class: cn(
|
|
86
|
+
"rich-editor-prose prose prose-sm sm:prose-base dark:prose-invert max-w-none min-h-[60dvh] px-8 py-6 outline-none sm:px-12"
|
|
87
|
+
),
|
|
88
|
+
spellcheck: "true",
|
|
89
|
+
"aria-label": "Document"
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
onCreate: ({ editor: created }) => {
|
|
93
|
+
if (!baseline.current) baseline.current = captureBaseline(created.state.doc, plan());
|
|
94
|
+
onLoadStats?.(plan().stats);
|
|
95
|
+
},
|
|
96
|
+
onUpdate: ({ editor: updated, transaction }) => {
|
|
97
|
+
if (!baseline.current) baseline.current = captureBaseline(transaction.before, plan());
|
|
98
|
+
if (timer.current !== null) window.clearTimeout(timer.current);
|
|
99
|
+
timer.current = window.setTimeout(() => {
|
|
100
|
+
timer.current = null;
|
|
101
|
+
report(updated);
|
|
102
|
+
}, REPORT_DELAY_MS);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
editor?.setEditable(!context.readOnly);
|
|
107
|
+
}, [editor, context.readOnly]);
|
|
108
|
+
const liveEditor = useRef(null);
|
|
109
|
+
const reportRef = useRef(report);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
liveEditor.current = editor;
|
|
112
|
+
reportRef.current = report;
|
|
113
|
+
});
|
|
114
|
+
useEffect(
|
|
115
|
+
() => () => {
|
|
116
|
+
if (timer.current === null) return;
|
|
117
|
+
window.clearTimeout(timer.current);
|
|
118
|
+
timer.current = null;
|
|
119
|
+
const pending = liveEditor.current;
|
|
120
|
+
if (!pending) return;
|
|
121
|
+
try {
|
|
122
|
+
reportRef.current(pending);
|
|
123
|
+
} catch {
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[]
|
|
127
|
+
);
|
|
128
|
+
const setHighlights = (current) => {
|
|
129
|
+
if (!editor) return;
|
|
130
|
+
const { matches } = findState.current;
|
|
131
|
+
editor.view.dispatch(
|
|
132
|
+
editor.state.tr.setMeta(
|
|
133
|
+
findHighlightKey,
|
|
134
|
+
matches.map((match, index) => ({ from: match.from, to: match.to, current: index === current }))
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
useImperativeHandle(handleRef, () => ({
|
|
139
|
+
focus: () => editor?.commands.focus(),
|
|
140
|
+
selectedText: () => {
|
|
141
|
+
if (!editor) return "";
|
|
142
|
+
const { from, to } = editor.state.selection;
|
|
143
|
+
return editor.state.doc.textBetween(from, to, "\n");
|
|
144
|
+
},
|
|
145
|
+
replaceSelection: (text) => {
|
|
146
|
+
if (!editor) return;
|
|
147
|
+
editor.chain().focus().command(({ tr }) => {
|
|
148
|
+
replaceSelectionWithMarkdown(tr, editor.state.schema, text);
|
|
149
|
+
return true;
|
|
150
|
+
}).run();
|
|
151
|
+
},
|
|
152
|
+
insertText: (text, where) => {
|
|
153
|
+
if (!editor) return false;
|
|
154
|
+
let inserted = false;
|
|
155
|
+
try {
|
|
156
|
+
editor.commands.command(({ tr }) => {
|
|
157
|
+
inserted = insertMarkdownBlock(tr, editor.state.schema, text, where);
|
|
158
|
+
return inserted;
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error("[RichEditor] block insert failed", error);
|
|
162
|
+
inserted = false;
|
|
163
|
+
}
|
|
164
|
+
return inserted;
|
|
165
|
+
},
|
|
166
|
+
historyDepth: () => editor ? { undo: undoDepth(editor.state), redo: redoDepth(editor.state) } : { undo: 0, redo: 0 },
|
|
167
|
+
flush: () => {
|
|
168
|
+
if (!editor) return lastReported.current;
|
|
169
|
+
if (timer.current !== null) {
|
|
170
|
+
window.clearTimeout(timer.current);
|
|
171
|
+
timer.current = null;
|
|
172
|
+
}
|
|
173
|
+
return report(editor, true);
|
|
174
|
+
},
|
|
175
|
+
scrollToHeading: (slug) => {
|
|
176
|
+
const element = container.current?.querySelector(`[data-heading-slug="${CSS.escape(slug)}"]`);
|
|
177
|
+
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
178
|
+
if (editor && element) {
|
|
179
|
+
const pos = editor.view.posAtDOM(element, 0);
|
|
180
|
+
editor.chain().setTextSelection(pos).focus(void 0, { scrollIntoView: false }).run();
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
placeCaret: (context2) => {
|
|
184
|
+
if (!editor) return false;
|
|
185
|
+
let flat = "";
|
|
186
|
+
const positions = [];
|
|
187
|
+
editor.state.doc.descendants((node, pos2) => {
|
|
188
|
+
if (node.isText && node.text) {
|
|
189
|
+
for (let i = 0; i < node.text.length; i++) positions.push(pos2 + i);
|
|
190
|
+
flat += node.text;
|
|
191
|
+
} else if (node.isBlock && flat.length > 0 && !flat.endsWith("\n")) {
|
|
192
|
+
positions.push(pos2);
|
|
193
|
+
flat += "\n";
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
});
|
|
197
|
+
const index = locateCaret(flat, context2);
|
|
198
|
+
if (index === null) return false;
|
|
199
|
+
const pos = index < positions.length ? positions[index] : editor.state.doc.content.size - 1;
|
|
200
|
+
try {
|
|
201
|
+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.near(editor.state.doc.resolve(pos))).scrollIntoView());
|
|
202
|
+
editor.view.focus();
|
|
203
|
+
return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
find: (query, options, step) => {
|
|
209
|
+
if (!editor) return { count: 0, current: -1, skippedProtected: 0, error: null };
|
|
210
|
+
const result = findInDoc(editor.state.doc, query, options);
|
|
211
|
+
const previous = findState.current;
|
|
212
|
+
const cursor = editor.state.selection.from;
|
|
213
|
+
let index = result.matches.findIndex((match2) => match2.from >= cursor);
|
|
214
|
+
if (index === -1) index = result.matches.length ? 0 : -1;
|
|
215
|
+
if (step && previous.index >= 0 && result.matches.length) {
|
|
216
|
+
index = (previous.index + step + result.matches.length) % result.matches.length;
|
|
217
|
+
}
|
|
218
|
+
findState.current = { matches: result.matches, index };
|
|
219
|
+
setHighlights(index);
|
|
220
|
+
const match = result.matches[index];
|
|
221
|
+
if (match && step) {
|
|
222
|
+
editor.view.dispatch(
|
|
223
|
+
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, match.from, match.to)).scrollIntoView()
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return { count: result.matches.length, current: index, skippedProtected: result.skippedProtected, error: result.error };
|
|
227
|
+
},
|
|
228
|
+
replaceCurrent: (query, replacement, options) => {
|
|
229
|
+
if (!editor) return [];
|
|
230
|
+
const match = findState.current.matches[findState.current.index];
|
|
231
|
+
if (!match) return [];
|
|
232
|
+
let changed = [];
|
|
233
|
+
editor.commands.command(({ tr }) => {
|
|
234
|
+
changed = replaceInDoc(tr, [match], query, replacement, options);
|
|
235
|
+
return true;
|
|
236
|
+
});
|
|
237
|
+
return changed;
|
|
238
|
+
},
|
|
239
|
+
replaceAll: (query, replacement, options) => {
|
|
240
|
+
if (!editor) return [];
|
|
241
|
+
const { matches } = findInDoc(editor.state.doc, query, options);
|
|
242
|
+
let changed = [];
|
|
243
|
+
editor.commands.command(({ tr }) => {
|
|
244
|
+
changed = replaceInDoc(tr, matches, query, replacement, options);
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
return changed;
|
|
248
|
+
},
|
|
249
|
+
clearFind: () => {
|
|
250
|
+
findState.current = { matches: [], index: -1 };
|
|
251
|
+
setHighlights(-1);
|
|
252
|
+
},
|
|
253
|
+
editLink: (href) => {
|
|
254
|
+
if (!editor) return;
|
|
255
|
+
if (href) editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
|
256
|
+
else editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
257
|
+
},
|
|
258
|
+
currentLink: () => editor?.getAttributes("link").href ?? null
|
|
259
|
+
}));
|
|
260
|
+
const [zoneElement, setZoneElement] = useState(null);
|
|
261
|
+
const editorHalf = editor ? {
|
|
262
|
+
kind: "rich-editor",
|
|
263
|
+
editor,
|
|
264
|
+
onEditLink: shell.editLink,
|
|
265
|
+
offerVariables: context.variables !== null,
|
|
266
|
+
inTable: () => !editor.isDestroyed && editor.isActive("table")
|
|
267
|
+
} : null;
|
|
268
|
+
useSelectionZone(
|
|
269
|
+
zoneElement,
|
|
270
|
+
editorHalf ? {
|
|
271
|
+
editable: !context.readOnly,
|
|
272
|
+
host: { [RICH_EDITOR_HOST_KEY]: editorHalf },
|
|
273
|
+
// The caret in a table keeps the one toolbar up with the table actions; a
|
|
274
|
+
// selected code block (an island) gets it too, acting on the block's source.
|
|
275
|
+
caretAnchor: () => {
|
|
276
|
+
if (!editor || editor.isDestroyed || !editor.isFocused) return null;
|
|
277
|
+
const island = islandSelectionOf(editor);
|
|
278
|
+
if (island) return { left: island.box.left, top: island.box.top, bottom: island.box.top + 1, width: island.box.width };
|
|
279
|
+
if (context.readOnly) return null;
|
|
280
|
+
const box = tableAnchorOf(editor);
|
|
281
|
+
return box ? { left: box.left, top: box.top, bottom: box.top + 1, width: Math.min(box.width, 40) } : null;
|
|
282
|
+
},
|
|
283
|
+
caretText: () => editor && !editor.isDestroyed ? islandSelectionOf(editor)?.raw ?? "" : "",
|
|
284
|
+
// The model selection lands a beat after the DOM one: re-resolve the open toolbar then,
|
|
285
|
+
// so formatting is judged on the selection the person made, never the previous one.
|
|
286
|
+
subscribe: (onChange2) => {
|
|
287
|
+
if (!editor || editor.isDestroyed) return () => {
|
|
288
|
+
};
|
|
289
|
+
editor.on("selectionUpdate", onChange2);
|
|
290
|
+
return () => editor.off("selectionUpdate", onChange2);
|
|
291
|
+
}
|
|
292
|
+
} : null
|
|
293
|
+
);
|
|
294
|
+
return /* @__PURE__ */ jsxs(
|
|
295
|
+
"div",
|
|
296
|
+
{
|
|
297
|
+
ref: (node) => {
|
|
298
|
+
container.current = node;
|
|
299
|
+
setZoneElement(node);
|
|
300
|
+
},
|
|
301
|
+
className: cn("rich-editor-visual relative h-full overflow-y-auto", focusMode && "rich-editor-focus"),
|
|
302
|
+
"data-testid": "rich-editor-visual",
|
|
303
|
+
children: [
|
|
304
|
+
/* @__PURE__ */ jsx(BlockHandle, { editor, container }),
|
|
305
|
+
/* @__PURE__ */ jsx(EditorContent, { editor, className: "mx-auto max-w-3xl pb-[40dvh]" })
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
export {
|
|
311
|
+
VisualEditor
|
|
312
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const pending = /* @__PURE__ */ new WeakMap();
|
|
2
|
+
function markAutoEdit(editor, pos) {
|
|
3
|
+
if (pos < 0) return;
|
|
4
|
+
const set = pending.get(editor) ?? /* @__PURE__ */ new Set();
|
|
5
|
+
set.add(pos);
|
|
6
|
+
pending.set(editor, set);
|
|
7
|
+
}
|
|
8
|
+
function consumeAutoEdit(editor, pos) {
|
|
9
|
+
const set = pending.get(editor);
|
|
10
|
+
if (!set?.has(pos)) return false;
|
|
11
|
+
set.delete(pos);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
consumeAutoEdit,
|
|
16
|
+
markAutoEdit
|
|
17
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Extension } from "@tiptap/core";
|
|
2
|
+
import type { EditorState, Selection } from "@tiptap/pm/state";
|
|
3
|
+
/** What `view.posAtCoords` answers: the position, and the node it is inside. */
|
|
4
|
+
export interface CoordsHit {
|
|
5
|
+
pos: number;
|
|
6
|
+
inside: number;
|
|
7
|
+
}
|
|
8
|
+
/** The selection a right-click at `hit` leaves, or null to keep the current one. */
|
|
9
|
+
export declare function selectionForContextMenu(state: EditorState, hit: CoordsHit | null): Selection | null;
|
|
10
|
+
export declare const ContextMenuCaret: Extension<any, any>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Extension } from "@tiptap/core";
|
|
2
|
+
import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
|
3
|
+
function selectionForContextMenu(state, hit) {
|
|
4
|
+
if (!hit) return null;
|
|
5
|
+
const { doc, selection } = state;
|
|
6
|
+
const pos = Math.max(0, Math.min(hit.pos, doc.content.size));
|
|
7
|
+
if (!selection.empty && pos >= selection.from && pos <= selection.to) return null;
|
|
8
|
+
if (hit.inside >= 0) {
|
|
9
|
+
const node = doc.nodeAt(hit.inside);
|
|
10
|
+
if (node && node.isAtom && NodeSelection.isSelectable(node)) {
|
|
11
|
+
const next2 = NodeSelection.create(doc, hit.inside);
|
|
12
|
+
return next2.eq(selection) ? null : next2;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const next = TextSelection.near(doc.resolve(pos));
|
|
16
|
+
return next.eq(selection) ? null : next;
|
|
17
|
+
}
|
|
18
|
+
const key = new PluginKey("richEditorContextMenuCaret");
|
|
19
|
+
const ContextMenuCaret = Extension.create({
|
|
20
|
+
name: "richEditorContextMenuCaret",
|
|
21
|
+
addProseMirrorPlugins() {
|
|
22
|
+
return [
|
|
23
|
+
new Plugin({
|
|
24
|
+
key,
|
|
25
|
+
props: {
|
|
26
|
+
handleDOMEvents: {
|
|
27
|
+
contextmenu: (view, event) => {
|
|
28
|
+
const hit = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
|
29
|
+
const next = selectionForContextMenu(view.state, hit);
|
|
30
|
+
if (next) view.dispatch(view.state.tr.setSelection(next));
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
export {
|
|
40
|
+
ContextMenuCaret,
|
|
41
|
+
selectionForContextMenu
|
|
42
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Extension } from "@tiptap/core";
|
|
2
|
+
import { PluginKey } from "@tiptap/pm/state";
|
|
3
|
+
export interface FindHighlight {
|
|
4
|
+
from: number;
|
|
5
|
+
to: number;
|
|
6
|
+
current: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const findHighlightKey: PluginKey<FindHighlight[]>;
|
|
9
|
+
export declare const RichDecorations: Extension<any, any>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Extension } from "@tiptap/core";
|
|
2
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
3
|
+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
|
4
|
+
import { headingPlainText, slugsFor } from "../core/outline.js";
|
|
5
|
+
import { TASK_DONE, TASK_OPEN } from "../core/commands.js";
|
|
6
|
+
const findHighlightKey = new PluginKey("richEditorFind");
|
|
7
|
+
const decorationsKey = new PluginKey("richEditorDecorations");
|
|
8
|
+
function taskCheckbox(checked, pos) {
|
|
9
|
+
const box = document.createElement("input");
|
|
10
|
+
box.type = "checkbox";
|
|
11
|
+
box.checked = checked;
|
|
12
|
+
box.className = "rich-editor-task-box";
|
|
13
|
+
box.setAttribute("aria-label", checked ? "Mark as not done" : "Mark as done");
|
|
14
|
+
box.dataset.itemPos = String(pos);
|
|
15
|
+
box.contentEditable = "false";
|
|
16
|
+
return box;
|
|
17
|
+
}
|
|
18
|
+
function buildDecorations(doc) {
|
|
19
|
+
const decorations = [];
|
|
20
|
+
const headings = [];
|
|
21
|
+
doc.descendants((node, pos) => {
|
|
22
|
+
if (node.type.name === "heading") {
|
|
23
|
+
headings.push({ pos, size: node.nodeSize, text: headingPlainText(node.textContent) });
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (node.type.name === "listItem" && typeof node.attrs.mdTask === "string") {
|
|
27
|
+
const checked = /\[[xX]\]/.test(node.attrs.mdTask);
|
|
28
|
+
decorations.push(
|
|
29
|
+
Decoration.node(pos, pos + node.nodeSize, {
|
|
30
|
+
class: `rich-editor-task-item${checked ? " is-checked" : ""}`
|
|
31
|
+
}),
|
|
32
|
+
Decoration.widget(pos + 1, () => taskCheckbox(checked, pos), {
|
|
33
|
+
side: -1,
|
|
34
|
+
key: `task-${pos}-${checked}`,
|
|
35
|
+
ignoreSelection: true
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (node.isText && node.text && node.text.includes("[^")) {
|
|
40
|
+
for (const match of node.text.matchAll(/\[\^[^\]\s]+\](:)?/g)) {
|
|
41
|
+
const from = pos + (match.index ?? 0);
|
|
42
|
+
decorations.push(
|
|
43
|
+
Decoration.inline(from, from + match[0].length, {
|
|
44
|
+
class: match[1] ? "rich-editor-footnote-def" : "rich-editor-footnote-ref"
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
const slugs = slugsFor(headings.map((heading) => heading.text));
|
|
52
|
+
headings.forEach((heading, index) => {
|
|
53
|
+
decorations.push(
|
|
54
|
+
Decoration.node(heading.pos, heading.pos + heading.size, {
|
|
55
|
+
id: slugs[index] ?? "",
|
|
56
|
+
"data-heading-slug": slugs[index] ?? ""
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
return DecorationSet.create(doc, decorations);
|
|
61
|
+
}
|
|
62
|
+
function findDecorations(state) {
|
|
63
|
+
const highlights = findHighlightKey.getState(state) ?? [];
|
|
64
|
+
if (!highlights.length) return DecorationSet.empty;
|
|
65
|
+
return DecorationSet.create(
|
|
66
|
+
state.doc,
|
|
67
|
+
highlights.filter((highlight) => highlight.to <= state.doc.content.size).map(
|
|
68
|
+
(highlight) => Decoration.inline(highlight.from, highlight.to, {
|
|
69
|
+
class: highlight.current ? "rich-editor-find-current" : "rich-editor-find-match"
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const RichDecorations = Extension.create({
|
|
75
|
+
name: "richDecorations",
|
|
76
|
+
addProseMirrorPlugins() {
|
|
77
|
+
const editor = this.editor;
|
|
78
|
+
return [
|
|
79
|
+
new Plugin({
|
|
80
|
+
key: decorationsKey,
|
|
81
|
+
state: {
|
|
82
|
+
init: (_config, state) => buildDecorations(state.doc),
|
|
83
|
+
apply: (tr, previous) => tr.docChanged ? buildDecorations(tr.doc) : previous
|
|
84
|
+
},
|
|
85
|
+
props: {
|
|
86
|
+
decorations: (state) => decorationsKey.getState(state),
|
|
87
|
+
handleDOMEvents: {
|
|
88
|
+
mousedown: (_view, event) => {
|
|
89
|
+
const target = event.target;
|
|
90
|
+
if (target?.classList.contains("rich-editor-task-box")) {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
const pos = Number(target.dataset.itemPos);
|
|
93
|
+
const item = editor.state.doc.nodeAt(pos);
|
|
94
|
+
if (item?.type.name === "listItem" && editor.isEditable) {
|
|
95
|
+
const checked = /\[[xX]\]/.test(String(item.attrs.mdTask));
|
|
96
|
+
editor.commands.command(({ tr }) => {
|
|
97
|
+
tr.setNodeMarkup(pos, void 0, { ...item.attrs, mdTask: checked ? TASK_OPEN : TASK_DONE });
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
new Plugin({
|
|
109
|
+
key: findHighlightKey,
|
|
110
|
+
state: {
|
|
111
|
+
init: () => [],
|
|
112
|
+
apply: (tr, previous) => {
|
|
113
|
+
const next = tr.getMeta(findHighlightKey);
|
|
114
|
+
if (next) return next;
|
|
115
|
+
if (!tr.docChanged) return previous;
|
|
116
|
+
return previous.map((highlight) => ({
|
|
117
|
+
...highlight,
|
|
118
|
+
from: tr.mapping.map(highlight.from),
|
|
119
|
+
to: tr.mapping.map(highlight.to)
|
|
120
|
+
})).filter((highlight) => highlight.to > highlight.from);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
props: { decorations: findDecorations }
|
|
124
|
+
})
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
export {
|
|
129
|
+
RichDecorations,
|
|
130
|
+
findHighlightKey
|
|
131
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import type { ActionProvider } from "@ai-matrx/alchemy/actions";
|
|
3
|
+
export declare const RICH_EDITOR_HOST_KEY = "richEditor";
|
|
4
|
+
export interface RichEditorSelectionHost {
|
|
5
|
+
kind: "rich-editor";
|
|
6
|
+
editor: Editor;
|
|
7
|
+
onEditLink: () => void;
|
|
8
|
+
offerVariables: boolean;
|
|
9
|
+
/** The caret or selection is inside a table (its table actions show). */
|
|
10
|
+
inTable: () => boolean;
|
|
11
|
+
}
|
|
12
|
+
/** A selected code block / island (a NodeSelection on it): its box and its source. */
|
|
13
|
+
export declare function islandSelectionOf(editor: Editor): {
|
|
14
|
+
box: DOMRect;
|
|
15
|
+
raw: string;
|
|
16
|
+
} | null;
|
|
17
|
+
/** The table the caret is in, as a box (the toolbar's caret-mode anchor), or null. */
|
|
18
|
+
export declare function tableAnchorOf(editor: Editor): DOMRect | null;
|
|
19
|
+
/** The formatting provider (declared on load; the toolbar root registers it). */
|
|
20
|
+
export declare const richEditorFormatProvider: ActionProvider;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { NodeSelection } from "@tiptap/pm/state";
|
|
2
|
+
import {
|
|
3
|
+
AlignCenter,
|
|
4
|
+
AlignLeft,
|
|
5
|
+
AlignRight,
|
|
6
|
+
ArrowDownToLine,
|
|
7
|
+
ArrowLeftToLine,
|
|
8
|
+
ArrowRightToLine,
|
|
9
|
+
ArrowUpToLine,
|
|
10
|
+
Braces,
|
|
11
|
+
Columns3,
|
|
12
|
+
Rows3,
|
|
13
|
+
Trash2
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { registerAlchemyIcon } from "@ai-matrx/rich-content/utils/alchemy-icon-keys";
|
|
16
|
+
import { declareSelectionProvider, hostHalf, placeSelectionActions, shownInSelectionMode } from "@ai-matrx/rich-content/selection-toolbar/selection-actions";
|
|
17
|
+
import { insertCodeBlock, insertVariable, setColumnAlign, toggleTaskList } from "../core/commands.js";
|
|
18
|
+
import { registerVisualFormatResolver } from "../format/format-actions.js";
|
|
19
|
+
import { visualFormatTarget } from "./visual-format-target.js";
|
|
20
|
+
import { markAutoEdit } from "./auto-edit.js";
|
|
21
|
+
import { toVariableName } from "../core/variables.js";
|
|
22
|
+
const RICH_EDITOR_HOST_KEY = "richEditor";
|
|
23
|
+
function islandSelectionOf(editor) {
|
|
24
|
+
const { selection } = editor.state;
|
|
25
|
+
if (!(selection instanceof NodeSelection) || selection.node.type.name !== "islandBlock") return null;
|
|
26
|
+
const dom = editor.view.nodeDOM(selection.from);
|
|
27
|
+
if (!(dom instanceof HTMLElement)) return null;
|
|
28
|
+
return { box: dom.getBoundingClientRect(), raw: String(selection.node.attrs.raw ?? "") };
|
|
29
|
+
}
|
|
30
|
+
function tableAnchorOf(editor) {
|
|
31
|
+
if (!editor.isEditable || !editor.isActive("table")) return null;
|
|
32
|
+
const { $from } = editor.state.selection;
|
|
33
|
+
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
|
34
|
+
if ($from.node(depth).type.name === "table") {
|
|
35
|
+
const dom = editor.view.nodeDOM($from.before(depth));
|
|
36
|
+
if (dom instanceof HTMLElement) return dom.getBoundingClientRect();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function editorOf(target) {
|
|
42
|
+
const half = hostHalf(target, RICH_EDITOR_HOST_KEY);
|
|
43
|
+
return half?.kind === "rich-editor" && !half.editor.isDestroyed ? half : null;
|
|
44
|
+
}
|
|
45
|
+
function visualTargetOf(target) {
|
|
46
|
+
const visual = editorOf(target);
|
|
47
|
+
if (!visual) return null;
|
|
48
|
+
const { editor } = visual;
|
|
49
|
+
return visualFormatTarget(editor, {
|
|
50
|
+
link: () => (visual.onEditLink(), true),
|
|
51
|
+
taskList: () => toggleTaskList(editor),
|
|
52
|
+
codeBlock: () => {
|
|
53
|
+
markAutoEdit(editor, insertCodeBlock(editor));
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
registerVisualFormatResolver(visualTargetOf);
|
|
59
|
+
const VARIABLE_ACTION = {
|
|
60
|
+
id: "selection:format-variable",
|
|
61
|
+
label: "Turn into a {{variable}}",
|
|
62
|
+
icon: registerAlchemyIcon(Braces),
|
|
63
|
+
category: "edit",
|
|
64
|
+
order: 50,
|
|
65
|
+
placement: "primary",
|
|
66
|
+
preserveSelection: true,
|
|
67
|
+
eligible: (t) => {
|
|
68
|
+
const host = editorOf(t);
|
|
69
|
+
if (!host || !host.offerVariables || !shownInSelectionMode("selection:format-variable", t)) return { status: "absent" };
|
|
70
|
+
if (!visualFormatTarget(host.editor).canFormat()) return { status: "absent" };
|
|
71
|
+
return { status: "available" };
|
|
72
|
+
},
|
|
73
|
+
run: (t) => {
|
|
74
|
+
const host = editorOf(t);
|
|
75
|
+
if (!host) return;
|
|
76
|
+
const { editor } = host;
|
|
77
|
+
const { from, to } = editor.state.selection;
|
|
78
|
+
const name = toVariableName(editor.state.doc.textBetween(from, to, " "));
|
|
79
|
+
if (name) insertVariable(editor, name);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const TABLE_TOOLS = [
|
|
83
|
+
{ id: "row-above", label: "Add row above", icon: ArrowUpToLine, run: (e) => e.chain().focus().addRowBefore().run() },
|
|
84
|
+
{ id: "row-below", label: "Add row below", icon: ArrowDownToLine, run: (e) => e.chain().focus().addRowAfter().run() },
|
|
85
|
+
{ id: "col-left", label: "Add column left", icon: ArrowLeftToLine, run: (e) => e.chain().focus().addColumnBefore().run() },
|
|
86
|
+
{ id: "col-right", label: "Add column right", icon: ArrowRightToLine, run: (e) => e.chain().focus().addColumnAfter().run() },
|
|
87
|
+
{ id: "align-left", more: true, label: "Align column left", icon: AlignLeft, run: (e) => setColumnAlign(e, "left") },
|
|
88
|
+
{ id: "align-center", more: true, label: "Center column", icon: AlignCenter, run: (e) => setColumnAlign(e, "center") },
|
|
89
|
+
{ id: "align-right", more: true, label: "Align column right", icon: AlignRight, run: (e) => setColumnAlign(e, "right") },
|
|
90
|
+
{ id: "del-row", label: "Delete row", icon: Rows3, run: (e) => e.chain().focus().deleteRow().run(), destructive: true, more: true },
|
|
91
|
+
{ id: "del-col", label: "Delete column", icon: Columns3, run: (e) => e.chain().focus().deleteColumn().run(), destructive: true, more: true },
|
|
92
|
+
{ id: "del-table", label: "Delete table (Undo brings it back)", icon: Trash2, run: (e) => e.chain().focus().deleteTable().run(), destructive: true, more: true }
|
|
93
|
+
];
|
|
94
|
+
const TABLE_ACTIONS = TABLE_TOOLS.map((spec, index) => {
|
|
95
|
+
const id = `selection:table-${spec.id}`;
|
|
96
|
+
return {
|
|
97
|
+
id,
|
|
98
|
+
label: spec.label,
|
|
99
|
+
icon: registerAlchemyIcon(spec.icon),
|
|
100
|
+
category: "edit",
|
|
101
|
+
order: 100 + index,
|
|
102
|
+
placement: spec.more ? "overflow" : "primary",
|
|
103
|
+
preserveSelection: true,
|
|
104
|
+
...spec.destructive ? { destructive: true } : {},
|
|
105
|
+
eligible: (t) => {
|
|
106
|
+
const host = editorOf(t);
|
|
107
|
+
if (!host || !shownInSelectionMode(id, t) || !host.editor.isEditable || !host.inTable()) return { status: "absent" };
|
|
108
|
+
return { status: "available" };
|
|
109
|
+
},
|
|
110
|
+
run: (t) => {
|
|
111
|
+
const host = editorOf(t);
|
|
112
|
+
if (host) spec.run(host.editor);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
const richEditorFormatProvider = {
|
|
117
|
+
id: "rich-editor-format",
|
|
118
|
+
tier: "T0",
|
|
119
|
+
declaredIds: () => [VARIABLE_ACTION, ...TABLE_ACTIONS].map((a) => a.id),
|
|
120
|
+
actions: (target) => editorOf(target) ? placeSelectionActions([VARIABLE_ACTION, ...TABLE_ACTIONS], target) : []
|
|
121
|
+
};
|
|
122
|
+
declareSelectionProvider(richEditorFormatProvider);
|
|
123
|
+
export {
|
|
124
|
+
RICH_EDITOR_HOST_KEY,
|
|
125
|
+
islandSelectionOf,
|
|
126
|
+
richEditorFormatProvider,
|
|
127
|
+
tableAnchorOf
|
|
128
|
+
};
|