@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,71 @@
|
|
|
1
|
+
import { EditorView } from "@codemirror/view";
|
|
2
|
+
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
|
3
|
+
import { tags } from "@lezer/highlight";
|
|
4
|
+
const richEditorTheme = EditorView.theme({
|
|
5
|
+
"&": {
|
|
6
|
+
backgroundColor: "transparent",
|
|
7
|
+
color: "hsl(var(--foreground))",
|
|
8
|
+
fontSize: "0.9375rem"
|
|
9
|
+
},
|
|
10
|
+
"&.cm-focused": { outline: "none" },
|
|
11
|
+
".cm-scroller": {
|
|
12
|
+
fontFamily: "var(--font-sans, ui-sans-serif, system-ui)",
|
|
13
|
+
lineHeight: "1.65"
|
|
14
|
+
},
|
|
15
|
+
".cm-content": { caretColor: "hsl(var(--foreground))", padding: "12px 0" },
|
|
16
|
+
".cm-line": { padding: "0 16px" },
|
|
17
|
+
".cm-cursor, .cm-dropCursor": { borderLeftColor: "hsl(var(--foreground))" },
|
|
18
|
+
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": {
|
|
19
|
+
backgroundColor: "hsl(var(--primary) / 0.18) !important"
|
|
20
|
+
},
|
|
21
|
+
".cm-activeLine": { backgroundColor: "hsl(var(--muted) / 0.35)" },
|
|
22
|
+
".cm-gutters": { display: "none" },
|
|
23
|
+
".cm-tooltip": {
|
|
24
|
+
backgroundColor: "hsl(var(--popover))",
|
|
25
|
+
color: "hsl(var(--popover-foreground))",
|
|
26
|
+
border: "1px solid hsl(var(--border))",
|
|
27
|
+
borderRadius: "8px",
|
|
28
|
+
overflow: "hidden"
|
|
29
|
+
},
|
|
30
|
+
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
|
|
31
|
+
backgroundColor: "hsl(var(--accent))",
|
|
32
|
+
color: "hsl(var(--accent-foreground))"
|
|
33
|
+
},
|
|
34
|
+
".cm-panels": { backgroundColor: "hsl(var(--card))", color: "hsl(var(--foreground))" }
|
|
35
|
+
});
|
|
36
|
+
const monoTheme = EditorView.theme({
|
|
37
|
+
".cm-scroller": {
|
|
38
|
+
fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
|
|
39
|
+
fontSize: "0.8125rem",
|
|
40
|
+
lineHeight: "1.55"
|
|
41
|
+
},
|
|
42
|
+
".cm-line": { padding: "0 12px" },
|
|
43
|
+
".cm-content": { padding: "8px 0" }
|
|
44
|
+
});
|
|
45
|
+
const richHighlight = syntaxHighlighting(
|
|
46
|
+
HighlightStyle.define([
|
|
47
|
+
{ tag: tags.heading1, fontSize: "1.6em", fontWeight: "700" },
|
|
48
|
+
{ tag: tags.heading2, fontSize: "1.35em", fontWeight: "700" },
|
|
49
|
+
{ tag: tags.heading3, fontSize: "1.15em", fontWeight: "650" },
|
|
50
|
+
{ tag: [tags.heading4, tags.heading5, tags.heading6], fontWeight: "650" },
|
|
51
|
+
{ tag: tags.strong, fontWeight: "700" },
|
|
52
|
+
{ tag: tags.emphasis, fontStyle: "italic" },
|
|
53
|
+
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
|
54
|
+
{ tag: tags.link, color: "hsl(var(--primary))", textDecoration: "underline" },
|
|
55
|
+
{ tag: tags.url, color: "hsl(var(--muted-foreground))" },
|
|
56
|
+
{ tag: tags.monospace, fontFamily: "var(--font-mono, ui-monospace, monospace)", fontSize: "0.9em" },
|
|
57
|
+
{ tag: tags.quote, color: "hsl(var(--muted-foreground))" },
|
|
58
|
+
{ tag: [tags.processingInstruction, tags.meta], color: "hsl(var(--muted-foreground))" },
|
|
59
|
+
{ tag: [tags.keyword, tags.operatorKeyword], color: "hsl(var(--primary))" },
|
|
60
|
+
{ tag: [tags.string, tags.special(tags.string)], color: "hsl(142 60% 38%)" },
|
|
61
|
+
{ tag: [tags.number, tags.bool, tags.null], color: "hsl(24 80% 48%)" },
|
|
62
|
+
{ tag: [tags.propertyName, tags.attributeName], color: "hsl(210 70% 50%)" },
|
|
63
|
+
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "hsl(var(--muted-foreground))", fontStyle: "italic" },
|
|
64
|
+
{ tag: [tags.tagName, tags.angleBracket], color: "hsl(280 50% 55%)" }
|
|
65
|
+
])
|
|
66
|
+
);
|
|
67
|
+
export {
|
|
68
|
+
monoTheme,
|
|
69
|
+
richEditorTheme,
|
|
70
|
+
richHighlight
|
|
71
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { StateField, type Extension } from "@codemirror/state";
|
|
2
|
+
import { type DecorationSet } from "@codemirror/view";
|
|
3
|
+
import { type DeclaredVariable } from "../core/variables.js";
|
|
4
|
+
export interface IslandPortal {
|
|
5
|
+
element: HTMLElement;
|
|
6
|
+
raw: string;
|
|
7
|
+
islandType: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class IslandPortalRegistry {
|
|
10
|
+
private portals;
|
|
11
|
+
private listeners;
|
|
12
|
+
private snapshot;
|
|
13
|
+
add(portal: IslandPortal): void;
|
|
14
|
+
remove(element: HTMLElement): void;
|
|
15
|
+
subscribe: (listener: () => void) => (() => void);
|
|
16
|
+
getSnapshot: () => IslandPortal[];
|
|
17
|
+
private emit;
|
|
18
|
+
}
|
|
19
|
+
export declare const setIslandRendering: import("@codemirror/state").StateEffectType<boolean>;
|
|
20
|
+
export declare const setFindHighlights: import("@codemirror/state").StateEffectType<{
|
|
21
|
+
from: number;
|
|
22
|
+
to: number;
|
|
23
|
+
current: boolean;
|
|
24
|
+
}[]>;
|
|
25
|
+
/** Find highlights only decorate matches, so the RAW source view keeps them too. */
|
|
26
|
+
export declare const findField: StateField<DecorationSet>;
|
|
27
|
+
export declare function livePreviewExtensions(options: {
|
|
28
|
+
registry: IslandPortalRegistry;
|
|
29
|
+
getVariables: () => readonly DeclaredVariable[] | null;
|
|
30
|
+
}): Extension[];
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { syntaxTree } from "@codemirror/language";
|
|
2
|
+
import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
|
|
3
|
+
import {
|
|
4
|
+
Decoration,
|
|
5
|
+
EditorView,
|
|
6
|
+
MatchDecorator,
|
|
7
|
+
ViewPlugin,
|
|
8
|
+
WidgetType
|
|
9
|
+
} from "@codemirror/view";
|
|
10
|
+
import { tokenizeSource } from "@ai-matrx/content-ir/source";
|
|
11
|
+
import { classifyVariable } from "../core/variables.js";
|
|
12
|
+
class IslandPortalRegistry {
|
|
13
|
+
portals = /* @__PURE__ */ new Map();
|
|
14
|
+
listeners = /* @__PURE__ */ new Set();
|
|
15
|
+
snapshot = [];
|
|
16
|
+
add(portal) {
|
|
17
|
+
this.portals.set(portal.element, portal);
|
|
18
|
+
this.emit();
|
|
19
|
+
}
|
|
20
|
+
remove(element) {
|
|
21
|
+
if (this.portals.delete(element)) this.emit();
|
|
22
|
+
}
|
|
23
|
+
subscribe = (listener) => {
|
|
24
|
+
this.listeners.add(listener);
|
|
25
|
+
return () => this.listeners.delete(listener);
|
|
26
|
+
};
|
|
27
|
+
getSnapshot = () => this.snapshot;
|
|
28
|
+
emit() {
|
|
29
|
+
this.snapshot = [...this.portals.values()];
|
|
30
|
+
for (const listener of this.listeners) listener();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
class IslandWidget extends WidgetType {
|
|
34
|
+
constructor(raw, islandType, from, registry) {
|
|
35
|
+
super();
|
|
36
|
+
this.raw = raw;
|
|
37
|
+
this.islandType = islandType;
|
|
38
|
+
this.from = from;
|
|
39
|
+
this.registry = registry;
|
|
40
|
+
}
|
|
41
|
+
raw;
|
|
42
|
+
islandType;
|
|
43
|
+
from;
|
|
44
|
+
registry;
|
|
45
|
+
eq(other) {
|
|
46
|
+
return other.raw === this.raw && other.islandType === this.islandType;
|
|
47
|
+
}
|
|
48
|
+
toDOM(view) {
|
|
49
|
+
const element = document.createElement("div");
|
|
50
|
+
element.className = "cm-rich-island";
|
|
51
|
+
element.setAttribute("role", "button");
|
|
52
|
+
element.setAttribute("aria-label", "Protected block \u2014 click to edit its source");
|
|
53
|
+
element.addEventListener("mousedown", (event) => {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
view.dispatch({ selection: { anchor: this.from } });
|
|
56
|
+
view.focus();
|
|
57
|
+
});
|
|
58
|
+
this.registry.add({ element, raw: this.raw, islandType: this.islandType });
|
|
59
|
+
return element;
|
|
60
|
+
}
|
|
61
|
+
destroy(dom) {
|
|
62
|
+
this.registry.remove(dom);
|
|
63
|
+
}
|
|
64
|
+
get estimatedHeight() {
|
|
65
|
+
return 80;
|
|
66
|
+
}
|
|
67
|
+
ignoreEvent() {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const RENDERED_ISLANDS = /* @__PURE__ */ new Set([
|
|
72
|
+
"fence",
|
|
73
|
+
"json",
|
|
74
|
+
"xml_region",
|
|
75
|
+
"xml_attr",
|
|
76
|
+
"xml_container",
|
|
77
|
+
"html_block",
|
|
78
|
+
"math_block",
|
|
79
|
+
"tree",
|
|
80
|
+
"html_comment"
|
|
81
|
+
]);
|
|
82
|
+
function islandDecorations(state, registry, render, blocks = tokenizeSource(state.doc.toString())) {
|
|
83
|
+
const builder = new RangeSetBuilder();
|
|
84
|
+
const { from: selFrom, to: selTo } = state.selection.main;
|
|
85
|
+
for (const block of blocks) {
|
|
86
|
+
if (block.kind !== "island" || !block.complete) continue;
|
|
87
|
+
const active = selTo >= block.start && selFrom <= block.end;
|
|
88
|
+
const startLine = state.doc.lineAt(block.start);
|
|
89
|
+
const endLine = state.doc.lineAt(Math.max(block.start, block.end - 1));
|
|
90
|
+
const whole = startLine.from === block.start && endLine.to === block.end;
|
|
91
|
+
if (render && !active && whole && RENDERED_ISLANDS.has(block.islandType ?? "")) {
|
|
92
|
+
builder.add(
|
|
93
|
+
block.start,
|
|
94
|
+
block.end,
|
|
95
|
+
Decoration.replace({
|
|
96
|
+
widget: new IslandWidget(block.raw, block.islandType ?? "fence", block.start, registry),
|
|
97
|
+
block: true
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (let line = startLine.number; line <= endLine.number; line += 1) {
|
|
103
|
+
const at = state.doc.line(line).from;
|
|
104
|
+
builder.add(at, at, Decoration.line({ class: "cm-rich-island-line" }));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return builder.finish();
|
|
108
|
+
}
|
|
109
|
+
const setIslandRendering = StateEffect.define();
|
|
110
|
+
const refreshIslands = StateEffect.define();
|
|
111
|
+
const LARGE_SOURCE = 5e4;
|
|
112
|
+
const REFRESH_AFTER_MS = 250;
|
|
113
|
+
function islandField(registry) {
|
|
114
|
+
const field = StateField.define({
|
|
115
|
+
create: (state) => {
|
|
116
|
+
const blocks = tokenizeSource(state.doc.toString());
|
|
117
|
+
return { render: true, blocks, decorations: islandDecorations(state, registry, true, blocks) };
|
|
118
|
+
},
|
|
119
|
+
update: (value, tr) => {
|
|
120
|
+
let render = value.render;
|
|
121
|
+
let refresh = false;
|
|
122
|
+
for (const effect of tr.effects) {
|
|
123
|
+
if (effect.is(setIslandRendering)) render = effect.value;
|
|
124
|
+
if (effect.is(refreshIslands)) refresh = true;
|
|
125
|
+
}
|
|
126
|
+
if (render === value.render && !refresh && !tr.docChanged && !tr.selection) return value;
|
|
127
|
+
if (tr.docChanged && !refresh && tr.state.doc.length >= LARGE_SOURCE) {
|
|
128
|
+
const touched = [];
|
|
129
|
+
tr.changes.iterChangedRanges((_fa, _ta, fromB, toB) => touched.push([fromB, toB]));
|
|
130
|
+
const doc = tr.state.doc;
|
|
131
|
+
const decorations = value.decorations.map(tr.changes).update({
|
|
132
|
+
filter: (from, to, deco) => {
|
|
133
|
+
if (!deco.spec.block) return true;
|
|
134
|
+
if (touched.some(([f, t]) => from <= t + 1 && to >= f - 1)) return false;
|
|
135
|
+
return doc.lineAt(from).from === from && doc.lineAt(to).to === to;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
return { render, blocks: null, decorations };
|
|
139
|
+
}
|
|
140
|
+
const blocks = tr.docChanged || refresh || !value.blocks ? tokenizeSource(tr.state.doc.toString()) : value.blocks;
|
|
141
|
+
return { render, blocks, decorations: islandDecorations(tr.state, registry, render, blocks) };
|
|
142
|
+
},
|
|
143
|
+
provide: (f) => EditorView.decorations.from(f, (value) => value.decorations)
|
|
144
|
+
});
|
|
145
|
+
const refresher = ViewPlugin.fromClass(
|
|
146
|
+
class {
|
|
147
|
+
constructor(view) {
|
|
148
|
+
this.view = view;
|
|
149
|
+
}
|
|
150
|
+
view;
|
|
151
|
+
timer = null;
|
|
152
|
+
update(update) {
|
|
153
|
+
if (!update.docChanged || update.state.field(field).blocks) return;
|
|
154
|
+
if (this.timer) clearTimeout(this.timer);
|
|
155
|
+
this.timer = setTimeout(() => {
|
|
156
|
+
this.timer = null;
|
|
157
|
+
this.view.dispatch({ effects: refreshIslands.of(null) });
|
|
158
|
+
}, REFRESH_AFTER_MS);
|
|
159
|
+
}
|
|
160
|
+
destroy() {
|
|
161
|
+
if (this.timer) clearTimeout(this.timer);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
return [field, refresher];
|
|
166
|
+
}
|
|
167
|
+
const HIDDEN_MARKS = /* @__PURE__ */ new Set(["HeaderMark", "EmphasisMark", "CodeMark", "StrikethroughMark", "LinkMark"]);
|
|
168
|
+
const hideMark = Decoration.replace({});
|
|
169
|
+
const dimMark = Decoration.mark({ class: "cm-rich-mark" });
|
|
170
|
+
function activeLines(state) {
|
|
171
|
+
const lines = /* @__PURE__ */ new Set();
|
|
172
|
+
for (const range of state.selection.ranges) {
|
|
173
|
+
const first = state.doc.lineAt(range.from).number;
|
|
174
|
+
const last = state.doc.lineAt(range.to).number;
|
|
175
|
+
for (let line = first; line <= last; line += 1) lines.add(line);
|
|
176
|
+
}
|
|
177
|
+
return lines;
|
|
178
|
+
}
|
|
179
|
+
function previewDecorations(view) {
|
|
180
|
+
const { state } = view;
|
|
181
|
+
const active = activeLines(state);
|
|
182
|
+
const ranges = [];
|
|
183
|
+
for (const { from, to } of view.visibleRanges) {
|
|
184
|
+
syntaxTree(state).iterate({
|
|
185
|
+
from,
|
|
186
|
+
to,
|
|
187
|
+
enter: (node) => {
|
|
188
|
+
const name = node.name;
|
|
189
|
+
if (/^ATXHeading(\d)$/.test(name)) {
|
|
190
|
+
const level = name.slice(-1);
|
|
191
|
+
const line = state.doc.lineAt(node.from);
|
|
192
|
+
ranges.push({ from: line.from, to: line.from, deco: Decoration.line({ class: `cm-rich-h${level}` }) });
|
|
193
|
+
}
|
|
194
|
+
if (name === "Blockquote") {
|
|
195
|
+
const first = state.doc.lineAt(node.from).number;
|
|
196
|
+
const last = state.doc.lineAt(node.to).number;
|
|
197
|
+
for (let line = first; line <= last; line += 1) {
|
|
198
|
+
const at = state.doc.line(line).from;
|
|
199
|
+
ranges.push({ from: at, to: at, deco: Decoration.line({ class: "cm-rich-quote" }) });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (name === "FencedCode" || name === "CodeBlock") return false;
|
|
203
|
+
if (name === "URL" && node.node.parent?.name === "Link") {
|
|
204
|
+
const line = state.doc.lineAt(node.from).number;
|
|
205
|
+
if (!active.has(line)) ranges.push({ from: node.from - 1, to: node.to + 1, deco: hideMark });
|
|
206
|
+
return void 0;
|
|
207
|
+
}
|
|
208
|
+
if (HIDDEN_MARKS.has(name)) {
|
|
209
|
+
if (name === "CodeMark" && node.node.parent?.name !== "InlineCode") return void 0;
|
|
210
|
+
if (name === "LinkMark" && node.node.parent?.name !== "Link") return void 0;
|
|
211
|
+
const mark = state.sliceDoc(node.from, node.to);
|
|
212
|
+
if (name === "LinkMark" && (mark === "(" || mark === ")")) return void 0;
|
|
213
|
+
const line = state.doc.lineAt(node.from).number;
|
|
214
|
+
const end = name === "HeaderMark" && state.sliceDoc(node.to, node.to + 1) === " " ? node.to + 1 : node.to;
|
|
215
|
+
ranges.push({ from: node.from, to: end, deco: active.has(line) ? dimMark : hideMark });
|
|
216
|
+
}
|
|
217
|
+
return void 0;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
ranges.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
222
|
+
const builder = new RangeSetBuilder();
|
|
223
|
+
let lastTo = -1;
|
|
224
|
+
for (const range of ranges) {
|
|
225
|
+
if (range.from === range.to) {
|
|
226
|
+
builder.add(range.from, range.to, range.deco);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (range.from < lastTo) continue;
|
|
230
|
+
builder.add(range.from, range.to, range.deco);
|
|
231
|
+
lastTo = range.to;
|
|
232
|
+
}
|
|
233
|
+
return builder.finish();
|
|
234
|
+
}
|
|
235
|
+
const livePreview = ViewPlugin.fromClass(
|
|
236
|
+
class {
|
|
237
|
+
decorations;
|
|
238
|
+
constructor(view) {
|
|
239
|
+
this.decorations = previewDecorations(view);
|
|
240
|
+
}
|
|
241
|
+
update(update) {
|
|
242
|
+
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
|
243
|
+
this.decorations = previewDecorations(update.view);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
{ decorations: (plugin) => plugin.decorations }
|
|
248
|
+
);
|
|
249
|
+
const VARIABLE_CLASS = {
|
|
250
|
+
declared: "cm-rich-var cm-rich-var-declared",
|
|
251
|
+
undeclared: "cm-rich-var cm-rich-var-undeclared",
|
|
252
|
+
literal: "cm-rich-var cm-rich-var-literal",
|
|
253
|
+
unbound: "cm-rich-var cm-rich-var-unbound"
|
|
254
|
+
};
|
|
255
|
+
function variableHighlighter(getVariables) {
|
|
256
|
+
const matcher = new MatchDecorator({
|
|
257
|
+
regexp: /\{\{[^{}\n]+\}\}/g,
|
|
258
|
+
decoration: (match) => {
|
|
259
|
+
const info = classifyVariable(match[0], getVariables());
|
|
260
|
+
return Decoration.mark({ class: VARIABLE_CLASS[info.state], attributes: { title: info.title } });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
return ViewPlugin.fromClass(
|
|
264
|
+
class {
|
|
265
|
+
decorations;
|
|
266
|
+
constructor(view) {
|
|
267
|
+
this.decorations = matcher.createDeco(view);
|
|
268
|
+
}
|
|
269
|
+
update(update) {
|
|
270
|
+
this.decorations = matcher.updateDeco(update, this.decorations);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
{ decorations: (plugin) => plugin.decorations }
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const setFindHighlights = StateEffect.define();
|
|
277
|
+
const findField = StateField.define({
|
|
278
|
+
create: () => Decoration.none,
|
|
279
|
+
update: (value, tr) => {
|
|
280
|
+
let next = value.map(tr.changes);
|
|
281
|
+
for (const effect of tr.effects) {
|
|
282
|
+
if (effect.is(setFindHighlights)) {
|
|
283
|
+
const builder = new RangeSetBuilder();
|
|
284
|
+
for (const range of [...effect.value].sort((a, b) => a.from - b.from)) {
|
|
285
|
+
if (range.to <= range.from) continue;
|
|
286
|
+
builder.add(range.from, range.to, Decoration.mark({ class: range.current ? "cm-rich-find-current" : "cm-rich-find" }));
|
|
287
|
+
}
|
|
288
|
+
next = builder.finish();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return next;
|
|
292
|
+
},
|
|
293
|
+
provide: (field) => EditorView.decorations.from(field)
|
|
294
|
+
});
|
|
295
|
+
function livePreviewExtensions(options) {
|
|
296
|
+
return [livePreview, variableHighlighter(options.getVariables), islandField(options.registry), findField];
|
|
297
|
+
}
|
|
298
|
+
export {
|
|
299
|
+
IslandPortalRegistry,
|
|
300
|
+
findField,
|
|
301
|
+
livePreviewExtensions,
|
|
302
|
+
setFindHighlights,
|
|
303
|
+
setIslandRendering
|
|
304
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Language, defineLanguageFacet } from "@codemirror/language";
|
|
2
|
+
import { GFM, parser } from "@lezer/markdown";
|
|
3
|
+
const data = defineLanguageFacet({ commentTokens: { block: { open: "<!--", close: "-->" } } });
|
|
4
|
+
const markdownSourceLanguage = new Language(data, parser.configure([GFM]), [], "markdown");
|
|
5
|
+
export {
|
|
6
|
+
markdownSourceLanguage
|
|
7
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { NodeSelection } from "@tiptap/pm/state";
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuSeparator,
|
|
10
|
+
DropdownMenuTrigger
|
|
11
|
+
} from "@ai-matrx/design-system";
|
|
12
|
+
import { ArrowDown, ArrowUp, GripVertical, Heading1, Heading2, List, ListOrdered, Pilcrow, Quote, Trash2 } from "lucide-react";
|
|
13
|
+
import { moveBlock, toggleTaskList } from "../core/commands.js";
|
|
14
|
+
import { useRichEditorContext } from "../editor/RichEditorContext.js";
|
|
15
|
+
function blockAt(editor, clientX, clientY) {
|
|
16
|
+
const view = editor.view;
|
|
17
|
+
const found = view.posAtCoords({ left: clientX, top: clientY });
|
|
18
|
+
if (!found) return null;
|
|
19
|
+
const $pos = view.state.doc.resolve(Math.min(found.inside >= 0 ? found.inside : found.pos, view.state.doc.content.size));
|
|
20
|
+
if ($pos.depth === 0 && found.inside < 0) return null;
|
|
21
|
+
const top = $pos.depth >= 1 ? $pos.before(1) : found.inside;
|
|
22
|
+
const topNode = view.state.doc.nodeAt(top);
|
|
23
|
+
let pos = top;
|
|
24
|
+
if (topNode?.type.name === "sourceBlock" && topNode.childCount > 1 && $pos.depth >= 2) pos = $pos.before(2);
|
|
25
|
+
const dom = view.nodeDOM(pos);
|
|
26
|
+
return dom instanceof HTMLElement ? { pos, dom } : null;
|
|
27
|
+
}
|
|
28
|
+
function BlockHandle({ editor, container }) {
|
|
29
|
+
const context = useRichEditorContext();
|
|
30
|
+
const [target, setTarget] = useState(null);
|
|
31
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const host = container.current;
|
|
34
|
+
if (!host || !editor || context.readOnly) return;
|
|
35
|
+
if (!window.matchMedia("(pointer: fine)").matches) return;
|
|
36
|
+
const onMove = (event) => {
|
|
37
|
+
if (menuOpen) return;
|
|
38
|
+
const content = host.querySelector(".ProseMirror");
|
|
39
|
+
if (!content) return;
|
|
40
|
+
const bounds = content.getBoundingClientRect();
|
|
41
|
+
const hit = blockAt(editor, Math.max(event.clientX, bounds.left + 24), event.clientY);
|
|
42
|
+
if (!hit) return;
|
|
43
|
+
const rect = hit.dom.getBoundingClientRect();
|
|
44
|
+
const hostRect = host.getBoundingClientRect();
|
|
45
|
+
const left = Math.max(0, rect.left - hostRect.left - 28);
|
|
46
|
+
setTarget({ pos: hit.pos, top: rect.top - hostRect.top + host.scrollTop, left, height: rect.height });
|
|
47
|
+
};
|
|
48
|
+
const onLeave = () => {
|
|
49
|
+
if (!menuOpen) setTarget(null);
|
|
50
|
+
};
|
|
51
|
+
host.addEventListener("mousemove", onMove);
|
|
52
|
+
host.addEventListener("mouseleave", onLeave);
|
|
53
|
+
return () => {
|
|
54
|
+
host.removeEventListener("mousemove", onMove);
|
|
55
|
+
host.removeEventListener("mouseleave", onLeave);
|
|
56
|
+
};
|
|
57
|
+
}, [editor, container, menuOpen, context.readOnly]);
|
|
58
|
+
if (!editor || !target || context.readOnly) return null;
|
|
59
|
+
const select = () => {
|
|
60
|
+
const node = editor.state.doc.nodeAt(target.pos);
|
|
61
|
+
if (!node) return false;
|
|
62
|
+
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, target.pos)));
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
const approveIslandsInside = () => {
|
|
66
|
+
const node = editor.state.doc.nodeAt(target.pos);
|
|
67
|
+
node?.descendants((child) => {
|
|
68
|
+
if (child.type.name === "inlineIsland" || child.type.name === "islandBlock") context.approveIsland(String(child.attrs.raw));
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
if (node?.type.name === "islandBlock") context.approveIsland(String(node.attrs.raw));
|
|
72
|
+
};
|
|
73
|
+
const turnInto = (run) => {
|
|
74
|
+
const node = editor.state.doc.nodeAt(target.pos);
|
|
75
|
+
if (node?.isTextblock || node?.type.name === "sourceBlock") {
|
|
76
|
+
editor.commands.setTextSelection(target.pos + (node.isTextblock ? 1 : 2));
|
|
77
|
+
run();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
return /* @__PURE__ */ jsxs(
|
|
81
|
+
"div",
|
|
82
|
+
{
|
|
83
|
+
className: "absolute z-10 flex w-7 items-start justify-center",
|
|
84
|
+
style: { top: target.top, left: target.left, height: Math.min(target.height, 32) },
|
|
85
|
+
children: [
|
|
86
|
+
/* @__PURE__ */ jsx(
|
|
87
|
+
"button",
|
|
88
|
+
{
|
|
89
|
+
type: "button",
|
|
90
|
+
draggable: true,
|
|
91
|
+
"aria-label": "Drag to move, or click for block actions",
|
|
92
|
+
"aria-haspopup": "menu",
|
|
93
|
+
"aria-expanded": menuOpen,
|
|
94
|
+
title: "Drag to move \xB7 click for actions",
|
|
95
|
+
className: "mt-1 flex h-6 w-5 cursor-grab items-center justify-center rounded text-muted-foreground/70 hover:bg-muted hover:text-foreground active:cursor-grabbing",
|
|
96
|
+
onClick: () => setMenuOpen(true),
|
|
97
|
+
onDragStart: (event) => {
|
|
98
|
+
if (!select()) return;
|
|
99
|
+
const slice = editor.state.selection.content();
|
|
100
|
+
editor.view.dragging = { slice, move: true };
|
|
101
|
+
const dom = editor.view.nodeDOM(target.pos);
|
|
102
|
+
event.dataTransfer.effectAllowed = "move";
|
|
103
|
+
event.dataTransfer.setData("text/plain", editor.state.doc.textBetween(target.pos, target.pos + (editor.state.doc.nodeAt(target.pos)?.nodeSize ?? 0), "\n"));
|
|
104
|
+
if (dom instanceof HTMLElement) event.dataTransfer.setDragImage(dom, 0, 0);
|
|
105
|
+
},
|
|
106
|
+
children: /* @__PURE__ */ jsx(GripVertical, { className: "h-4 w-4" })
|
|
107
|
+
}
|
|
108
|
+
),
|
|
109
|
+
/* @__PURE__ */ jsxs(DropdownMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [
|
|
110
|
+
/* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute left-0 top-1 h-6 w-5" }) }),
|
|
111
|
+
/* @__PURE__ */ jsxs(DropdownMenuContent, { align: "start", side: "left", className: "w-52", children: [
|
|
112
|
+
/* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => select() && moveBlock(editor, "up"), children: [
|
|
113
|
+
/* @__PURE__ */ jsx(ArrowUp, { className: "mr-2 h-4 w-4" }),
|
|
114
|
+
" Move up"
|
|
115
|
+
] }),
|
|
116
|
+
/* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => select() && moveBlock(editor, "down"), children: [
|
|
117
|
+
/* @__PURE__ */ jsx(ArrowDown, { className: "mr-2 h-4 w-4" }),
|
|
118
|
+
" Move down"
|
|
119
|
+
] }),
|
|
120
|
+
/* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
|
|
121
|
+
/* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => turnInto(() => editor.chain().focus().setParagraph().run()), children: [
|
|
122
|
+
/* @__PURE__ */ jsx(Pilcrow, { className: "mr-2 h-4 w-4" }),
|
|
123
|
+
" Turn into text"
|
|
124
|
+
] }),
|
|
125
|
+
/* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => turnInto(() => editor.chain().focus().setHeading({ level: 1 }).run()), children: [
|
|
126
|
+
/* @__PURE__ */ jsx(Heading1, { className: "mr-2 h-4 w-4" }),
|
|
127
|
+
" Turn into heading 1"
|
|
128
|
+
] }),
|
|
129
|
+
/* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => turnInto(() => editor.chain().focus().setHeading({ level: 2 }).run()), children: [
|
|
130
|
+
/* @__PURE__ */ jsx(Heading2, { className: "mr-2 h-4 w-4" }),
|
|
131
|
+
" Turn into heading 2"
|
|
132
|
+
] }),
|
|
133
|
+
/* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => turnInto(() => editor.chain().focus().toggleBulletList().run()), children: [
|
|
134
|
+
/* @__PURE__ */ jsx(List, { className: "mr-2 h-4 w-4" }),
|
|
135
|
+
" Turn into bulleted list"
|
|
136
|
+
] }),
|
|
137
|
+
/* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => turnInto(() => editor.chain().focus().toggleOrderedList().run()), children: [
|
|
138
|
+
/* @__PURE__ */ jsx(ListOrdered, { className: "mr-2 h-4 w-4" }),
|
|
139
|
+
" Turn into numbered list"
|
|
140
|
+
] }),
|
|
141
|
+
/* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => turnInto(() => toggleTaskList(editor)), children: [
|
|
142
|
+
/* @__PURE__ */ jsx(List, { className: "mr-2 h-4 w-4" }),
|
|
143
|
+
" Turn into checklist"
|
|
144
|
+
] }),
|
|
145
|
+
/* @__PURE__ */ jsxs(DropdownMenuItem, { onSelect: () => turnInto(() => editor.chain().focus().toggleBlockquote().run()), children: [
|
|
146
|
+
/* @__PURE__ */ jsx(Quote, { className: "mr-2 h-4 w-4" }),
|
|
147
|
+
" Turn into quote"
|
|
148
|
+
] }),
|
|
149
|
+
/* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
|
|
150
|
+
/* @__PURE__ */ jsxs(
|
|
151
|
+
DropdownMenuItem,
|
|
152
|
+
{
|
|
153
|
+
className: "text-destructive focus:text-destructive",
|
|
154
|
+
onSelect: () => {
|
|
155
|
+
approveIslandsInside();
|
|
156
|
+
if (select()) editor.commands.deleteSelection();
|
|
157
|
+
setTarget(null);
|
|
158
|
+
},
|
|
159
|
+
children: [
|
|
160
|
+
/* @__PURE__ */ jsx(Trash2, { className: "mr-2 h-4 w-4" }),
|
|
161
|
+
" Delete block (Undo brings it back)"
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
] })
|
|
166
|
+
] })
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
export {
|
|
172
|
+
BlockHandle
|
|
173
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Ref } from "react";
|
|
2
|
+
import { type VisualLoadStats } from "../core/visual-document.js";
|
|
3
|
+
import { type CaretContext } from "../core/caret-context.js";
|
|
4
|
+
import type { FindOptions } from "../core/find-replace.js";
|
|
5
|
+
import { type RichShellActions } from "./visual-extensions.js";
|
|
6
|
+
export interface ViewFindState {
|
|
7
|
+
count: number;
|
|
8
|
+
current: number;
|
|
9
|
+
skippedProtected: number;
|
|
10
|
+
error: string | null;
|
|
11
|
+
}
|
|
12
|
+
/** What the shell drives in whichever view is showing. */
|
|
13
|
+
export interface EditorViewHandle {
|
|
14
|
+
focus: () => void;
|
|
15
|
+
selectedText: () => string;
|
|
16
|
+
/** Replace the selection with literal text (markdown typed by an AI stays markdown). */
|
|
17
|
+
replaceSelection: (text: string) => void;
|
|
18
|
+
/** Insert a block before / after the selection; false when it could not. */
|
|
19
|
+
insertText: (text: string, where: "before" | "after") => boolean;
|
|
20
|
+
/** Flush pending text now (before a save or a view switch). */
|
|
21
|
+
flush: () => string;
|
|
22
|
+
/**
|
|
23
|
+
* Steps this view's own undo history holds. Zero after a (re)mount — a host
|
|
24
|
+
* that keeps a record-level history (notes) answers ⌘Z then.
|
|
25
|
+
*/
|
|
26
|
+
historyDepth: () => {
|
|
27
|
+
undo: number;
|
|
28
|
+
redo: number;
|
|
29
|
+
};
|
|
30
|
+
scrollToHeading: (slug: string, offset: number) => void;
|
|
31
|
+
/** Put the caret at the text the person double-clicked in the rendered view; false when not found. */
|
|
32
|
+
placeCaret: (context: CaretContext) => boolean;
|
|
33
|
+
find: (query: string, options: FindOptions, step?: 1 | -1) => ViewFindState;
|
|
34
|
+
replaceCurrent: (query: string, replacement: string, options: FindOptions) => string[];
|
|
35
|
+
replaceAll: (query: string, replacement: string, options: FindOptions) => string[];
|
|
36
|
+
clearFind: () => void;
|
|
37
|
+
editLink: (href: string | null) => void;
|
|
38
|
+
currentLink: () => string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Source view only: the scroll container, the (0-based, fractional) line at
|
|
41
|
+
* the top of the viewport, and scrolling a line to the top.
|
|
42
|
+
*/
|
|
43
|
+
scroller?: () => HTMLElement | null;
|
|
44
|
+
topLine?: () => number;
|
|
45
|
+
scrollToLine?: (lineIndex: number) => void;
|
|
46
|
+
}
|
|
47
|
+
export interface VisualEditorProps {
|
|
48
|
+
initialText: string;
|
|
49
|
+
onChange: (text: string) => void;
|
|
50
|
+
onLoadStats?: (stats: VisualLoadStats) => void;
|
|
51
|
+
shell: RichShellActions;
|
|
52
|
+
placeholder?: string;
|
|
53
|
+
focusMode: boolean;
|
|
54
|
+
handleRef: Ref<EditorViewHandle>;
|
|
55
|
+
}
|
|
56
|
+
export declare function VisualEditor({ initialText, onChange, onLoadStats, shell, placeholder, focusMode, handleRef, }: VisualEditorProps): import("react").JSX.Element;
|