@atri-editor/core 0.1.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/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/AtriEditor.d.ts +192 -0
- package/dist/AtriEditor.d.ts.map +1 -0
- package/dist/AtriEditorElement.d.ts +84 -0
- package/dist/AtriEditorElement.d.ts.map +1 -0
- package/dist/ai/AICommandMenu.d.ts +59 -0
- package/dist/ai/AICommandMenu.d.ts.map +1 -0
- package/dist/ai/AIInterceptor.d.ts +39 -0
- package/dist/ai/AIInterceptor.d.ts.map +1 -0
- package/dist/ai/AIService.d.ts +42 -0
- package/dist/ai/AIService.d.ts.map +1 -0
- package/dist/ai/index.d.ts +5 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/core/CoreEditor.d.ts +100 -0
- package/dist/core/CoreEditor.d.ts.map +1 -0
- package/dist/core/ExtensionManager.d.ts +47 -0
- package/dist/core/ExtensionManager.d.ts.map +1 -0
- package/dist/core/I18nManager.d.ts +21 -0
- package/dist/core/I18nManager.d.ts.map +1 -0
- package/dist/core/MarkdownService.d.ts +42 -0
- package/dist/core/MarkdownService.d.ts.map +1 -0
- package/dist/core/ThemeManager.d.ts +30 -0
- package/dist/core/ThemeManager.d.ts.map +1 -0
- package/dist/core/ToolbarManager.d.ts +69 -0
- package/dist/core/ToolbarManager.d.ts.map +1 -0
- package/dist/core/bubble-toolbar.d.ts +38 -0
- package/dist/core/bubble-toolbar.d.ts.map +1 -0
- package/dist/core/icons.d.ts +6 -0
- package/dist/core/icons.d.ts.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/extensions/AtriImage.d.ts +2 -0
- package/dist/extensions/AtriImage.d.ts.map +1 -0
- package/dist/extensions/Attachment.d.ts +24 -0
- package/dist/extensions/Attachment.d.ts.map +1 -0
- package/dist/extensions/AttachmentLink.d.ts +19 -0
- package/dist/extensions/AttachmentLink.d.ts.map +1 -0
- package/dist/extensions/MediaFiles.d.ts +13 -0
- package/dist/extensions/MediaFiles.d.ts.map +1 -0
- package/dist/extensions/attachment-attrs.d.ts +29 -0
- package/dist/extensions/attachment-attrs.d.ts.map +1 -0
- package/dist/extensions/media.d.ts +10 -0
- package/dist/extensions/media.d.ts.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3489 -0
- package/dist/index.js.map +1 -0
- package/dist/media/InsertPanel.d.ts +59 -0
- package/dist/media/InsertPanel.d.ts.map +1 -0
- package/dist/media/MediaRuntime.d.ts +113 -0
- package/dist/media/MediaRuntime.d.ts.map +1 -0
- package/dist/media/MediaStatusStrip.d.ts +20 -0
- package/dist/media/MediaStatusStrip.d.ts.map +1 -0
- package/dist/media/Uploader.d.ts +12 -0
- package/dist/media/Uploader.d.ts.map +1 -0
- package/dist/media/file-policy.d.ts +34 -0
- package/dist/media/file-policy.d.ts.map +1 -0
- package/dist/media/insert-position.d.ts +13 -0
- package/dist/media/insert-position.d.ts.map +1 -0
- package/dist/style.css +578 -0
- package/dist/types/ai.d.ts +76 -0
- package/dist/types/ai.d.ts.map +1 -0
- package/dist/types/editor.d.ts +140 -0
- package/dist/types/editor.d.ts.map +1 -0
- package/dist/types/events.d.ts +26 -0
- package/dist/types/events.d.ts.map +1 -0
- package/dist/types/extension.d.ts +61 -0
- package/dist/types/extension.d.ts.map +1 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/markdown.d.ts +29 -0
- package/dist/types/markdown.d.ts.map +1 -0
- package/dist/types/media.d.ts +149 -0
- package/dist/types/media.d.ts.map +1 -0
- package/dist/utils/dom.d.ts +16 -0
- package/dist/utils/dom.d.ts.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/markdown.d.ts +14 -0
- package/dist/utils/markdown.d.ts.map +1 -0
- package/dist/utils/selection.d.ts +17 -0
- package/dist/utils/selection.d.ts.map +1 -0
- package/package.json +63 -0
- package/styles.d.ts +6 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3489 @@
|
|
|
1
|
+
import { Node, mergeAttributes, Extension, isNodeSelection, Editor, getHTMLFromFragment, elementFromString, getTextBetween } from "@tiptap/core";
|
|
2
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
3
|
+
import { Markdown } from "@tiptap/markdown";
|
|
4
|
+
import { Placeholder } from "@tiptap/extensions";
|
|
5
|
+
import TextAlign from "@tiptap/extension-text-align";
|
|
6
|
+
import BubbleMenu from "@tiptap/extension-bubble-menu";
|
|
7
|
+
import Image from "@tiptap/extension-image";
|
|
8
|
+
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
|
|
9
|
+
import { Node as Node$1, DOMParser } from "@tiptap/pm/model";
|
|
10
|
+
import i18next from "i18next";
|
|
11
|
+
import { autoUpdate, computePosition, offset, flip, shift } from "@floating-ui/dom";
|
|
12
|
+
function mediaInsertTarget(selection) {
|
|
13
|
+
if (selection instanceof NodeSelection && (selection.node.type.name === "image" || selection.node.type.name === "attachment" || selection.node.type.name === "attachmentLink")) {
|
|
14
|
+
return selection.to;
|
|
15
|
+
}
|
|
16
|
+
return { from: selection.from, to: selection.to };
|
|
17
|
+
}
|
|
18
|
+
const AtriImage = Image.extend({
|
|
19
|
+
addAttributes() {
|
|
20
|
+
var _a;
|
|
21
|
+
return {
|
|
22
|
+
...(_a = this.parent) == null ? void 0 : _a.call(this),
|
|
23
|
+
// 只写不读:保存下来的 HTML 里不该留着一个永远不会完成的"上传中"
|
|
24
|
+
status: {
|
|
25
|
+
default: null,
|
|
26
|
+
parseHTML: () => null,
|
|
27
|
+
renderHTML: (attributes) => attributes.status ? { "data-atri-upload-status": attributes.status } : {}
|
|
28
|
+
},
|
|
29
|
+
uploadId: {
|
|
30
|
+
default: null,
|
|
31
|
+
parseHTML: () => null,
|
|
32
|
+
rendered: false
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
addCommands() {
|
|
37
|
+
var _a;
|
|
38
|
+
return {
|
|
39
|
+
...(_a = this.parent) == null ? void 0 : _a.call(this),
|
|
40
|
+
// stock 版走 insertContent,替换的是整个选区;而插入一张图之后选区正好盖住这张图,
|
|
41
|
+
// 于是空文档里连插两张只剩一张。落点交给 mediaInsertTarget 判断
|
|
42
|
+
setImage: (options) => ({ state, commands }) => commands.insertContentAt(mediaInsertTarget(state.selection), {
|
|
43
|
+
type: this.name,
|
|
44
|
+
attrs: options
|
|
45
|
+
})
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const UNITS = ["B", "KB", "MB", "GB", "TB"];
|
|
50
|
+
const UNIT_FACTOR = 1024;
|
|
51
|
+
function formatFileSize(bytes) {
|
|
52
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
|
53
|
+
if (bytes < UNIT_FACTOR) return `${Math.round(bytes)} B`;
|
|
54
|
+
let value = bytes;
|
|
55
|
+
let unit = 0;
|
|
56
|
+
while (value >= UNIT_FACTOR && unit < UNITS.length - 1) {
|
|
57
|
+
value /= UNIT_FACTOR;
|
|
58
|
+
unit += 1;
|
|
59
|
+
}
|
|
60
|
+
const label = Number.isInteger(value) ? String(value) : value.toFixed(1);
|
|
61
|
+
return `${label} ${UNITS[unit]}`;
|
|
62
|
+
}
|
|
63
|
+
function parseFileSize(label) {
|
|
64
|
+
if (!label) return void 0;
|
|
65
|
+
const match = /^\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s*$/i.exec(label);
|
|
66
|
+
if (!match) return void 0;
|
|
67
|
+
const value = Number(match[1]);
|
|
68
|
+
if (!Number.isFinite(value)) return void 0;
|
|
69
|
+
const unit = UNITS.indexOf(match[2].toUpperCase());
|
|
70
|
+
return Math.round(value * UNIT_FACTOR ** unit);
|
|
71
|
+
}
|
|
72
|
+
const DEFAULT_MAX_FILE_SIZE = 10 * UNIT_FACTOR ** 2;
|
|
73
|
+
const DEFAULT_MAX_FILES = 10;
|
|
74
|
+
function matchesAccept(file, accept) {
|
|
75
|
+
if (!accept) return true;
|
|
76
|
+
const patterns = (Array.isArray(accept) ? accept : accept.split(",")).map((pattern) => pattern.trim().toLowerCase()).filter(Boolean);
|
|
77
|
+
if (!patterns.length) return true;
|
|
78
|
+
const name = file.name.toLowerCase();
|
|
79
|
+
const type = (file.type || "").toLowerCase();
|
|
80
|
+
return patterns.some((pattern) => {
|
|
81
|
+
if (pattern.startsWith(".")) return name.endsWith(pattern);
|
|
82
|
+
if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
|
|
83
|
+
return type === pattern;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function acceptAttribute(accept) {
|
|
87
|
+
if (!accept) return void 0;
|
|
88
|
+
const patterns = (Array.isArray(accept) ? accept : accept.split(",")).map((pattern) => pattern.trim()).filter(Boolean);
|
|
89
|
+
return patterns.length ? patterns.join(",") : void 0;
|
|
90
|
+
}
|
|
91
|
+
function validateFile(file, options) {
|
|
92
|
+
const maxFileSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
93
|
+
if (file.size > maxFileSize) return { ok: false, reason: "too-large" };
|
|
94
|
+
if (!matchesAccept(file, options.accept)) return { ok: false, reason: "unsupported-type" };
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
function isImageFile(file) {
|
|
98
|
+
return (file.type || "").toLowerCase().startsWith("image/");
|
|
99
|
+
}
|
|
100
|
+
function isAttachmentForm(name) {
|
|
101
|
+
return name === "attachment" || name === "attachmentLink";
|
|
102
|
+
}
|
|
103
|
+
function selectNearestForm(tr, from, typeName) {
|
|
104
|
+
let best = -1;
|
|
105
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
106
|
+
tr.doc.descendants((node, pos) => {
|
|
107
|
+
if (node.type.name !== typeName) return;
|
|
108
|
+
const dist = Math.abs(pos - from);
|
|
109
|
+
if (dist < bestDist) {
|
|
110
|
+
bestDist = dist;
|
|
111
|
+
best = pos;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
if (best < 0) return;
|
|
115
|
+
try {
|
|
116
|
+
tr.setSelection(NodeSelection.create(tr.doc, best));
|
|
117
|
+
} catch {
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function insertAttachmentContent(props, typeName, options) {
|
|
121
|
+
const { state, commands, tr, dispatch } = props;
|
|
122
|
+
const target = mediaInsertTarget(state.selection);
|
|
123
|
+
const anchor = typeof target === "number" ? target : target.to;
|
|
124
|
+
if (!commands.insertContentAt(target, {
|
|
125
|
+
type: typeName,
|
|
126
|
+
attrs: {
|
|
127
|
+
src: options.src,
|
|
128
|
+
name: options.name ?? null,
|
|
129
|
+
size: options.size ?? null,
|
|
130
|
+
mime: options.mime ?? null
|
|
131
|
+
}
|
|
132
|
+
})) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (dispatch) selectNearestForm(tr, anchor, typeName);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
function dataAttr(name, value) {
|
|
139
|
+
return value === null || value === void 0 || value === "" ? {} : { [name]: String(value) };
|
|
140
|
+
}
|
|
141
|
+
function progressAttrs(progress) {
|
|
142
|
+
if (typeof progress !== "number" || !Number.isFinite(progress)) return {};
|
|
143
|
+
const percent = Math.min(100, Math.max(0, Math.round(progress)));
|
|
144
|
+
return {
|
|
145
|
+
"data-atri-upload-progress": String(percent),
|
|
146
|
+
style: `--atri-upload-progress: ${percent}%`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function attachmentLabel(name, src) {
|
|
150
|
+
return name || String(src || "").split("/").pop() || "";
|
|
151
|
+
}
|
|
152
|
+
function attachmentAttributes() {
|
|
153
|
+
return {
|
|
154
|
+
src: {
|
|
155
|
+
default: null,
|
|
156
|
+
parseHTML: (element) => element.getAttribute("data-src"),
|
|
157
|
+
renderHTML: (attributes) => dataAttr("data-src", attributes.src)
|
|
158
|
+
},
|
|
159
|
+
name: {
|
|
160
|
+
default: null,
|
|
161
|
+
parseHTML: (element) => element.getAttribute("data-name"),
|
|
162
|
+
renderHTML: (attributes) => dataAttr("data-name", attributes.name)
|
|
163
|
+
},
|
|
164
|
+
size: {
|
|
165
|
+
default: null,
|
|
166
|
+
parseHTML: (element) => {
|
|
167
|
+
const raw = element.getAttribute("data-size");
|
|
168
|
+
const parsed = raw === null ? Number.NaN : Number(raw);
|
|
169
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
170
|
+
},
|
|
171
|
+
renderHTML: (attributes) => dataAttr("data-size", attributes.size)
|
|
172
|
+
},
|
|
173
|
+
mime: {
|
|
174
|
+
default: null,
|
|
175
|
+
parseHTML: (element) => element.getAttribute("data-mime"),
|
|
176
|
+
renderHTML: (attributes) => dataAttr("data-mime", attributes.mime)
|
|
177
|
+
},
|
|
178
|
+
// 下面三项是上传过程中的瞬时态:写得出、读不回,
|
|
179
|
+
// 免得保存下来的 HTML 里带着一个永远不会完成的"上传中"
|
|
180
|
+
status: {
|
|
181
|
+
default: null,
|
|
182
|
+
parseHTML: () => null,
|
|
183
|
+
renderHTML: (attributes) => dataAttr("data-atri-upload-status", attributes.status)
|
|
184
|
+
},
|
|
185
|
+
// 行内链接没有卡片那条进度条,百分比只落 data 属性;
|
|
186
|
+
// 照常定义是为了让运行时的补丁不分形态,切换后下一个进度 tick 也接得上
|
|
187
|
+
progress: {
|
|
188
|
+
default: null,
|
|
189
|
+
parseHTML: () => null,
|
|
190
|
+
renderHTML: (attributes) => progressAttrs(attributes.progress)
|
|
191
|
+
},
|
|
192
|
+
uploadId: {
|
|
193
|
+
default: null,
|
|
194
|
+
parseHTML: () => null,
|
|
195
|
+
rendered: false
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const markdownPattern$1 = /^!file\[([^\]]*)\]\(\s*([^\s)]+)(?:\s+"([^"]*)")?\s*\)(?:\n|$)/;
|
|
200
|
+
const FORM_TYPES = {
|
|
201
|
+
card: "attachment",
|
|
202
|
+
link: "attachmentLink"
|
|
203
|
+
};
|
|
204
|
+
function locateAttachment(state) {
|
|
205
|
+
const isForm = (type) => isAttachmentForm(type.name);
|
|
206
|
+
const { selection } = state;
|
|
207
|
+
if (selection instanceof NodeSelection) {
|
|
208
|
+
return isForm(selection.node.type) ? { node: selection.node, pos: selection.from } : null;
|
|
209
|
+
}
|
|
210
|
+
if (!("$from" in selection)) return null;
|
|
211
|
+
const after = selection.$from.nodeAfter;
|
|
212
|
+
if (after && isForm(after.type)) return { node: after, pos: selection.from };
|
|
213
|
+
const before = selection.$from.nodeBefore;
|
|
214
|
+
if (before && isForm(before.type)) return { node: before, pos: selection.from - before.nodeSize };
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
function switchAttachmentDisplay(state, dispatch, display) {
|
|
218
|
+
var _a;
|
|
219
|
+
const located = locateAttachment(state);
|
|
220
|
+
if (!located) return false;
|
|
221
|
+
const wantName = FORM_TYPES[display];
|
|
222
|
+
if (located.node.type.name === wantName) return false;
|
|
223
|
+
const target = state.schema.nodes[wantName];
|
|
224
|
+
if (!target) return false;
|
|
225
|
+
if (!dispatch) return true;
|
|
226
|
+
const next = target.create(located.node.attrs);
|
|
227
|
+
const tr = state.tr.replaceRangeWith(located.pos, located.pos + located.node.nodeSize, next);
|
|
228
|
+
let newPos = tr.mapping.map(located.pos, 1);
|
|
229
|
+
if (((_a = tr.doc.nodeAt(newPos)) == null ? void 0 : _a.type.name) !== wantName) {
|
|
230
|
+
newPos = -1;
|
|
231
|
+
tr.doc.descendants((n, pos) => {
|
|
232
|
+
if (newPos < 0 && n.type.name === wantName && n.attrs.src === located.node.attrs.src && n.attrs.uploadId === located.node.attrs.uploadId) {
|
|
233
|
+
newPos = pos;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (newPos >= 0 && target.spec.selectable !== false) {
|
|
238
|
+
const nodeSelection = NodeSelection.create(tr.doc, newPos);
|
|
239
|
+
tr.setSelection(nodeSelection);
|
|
240
|
+
}
|
|
241
|
+
dispatch(tr);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
const Attachment = Node.create({
|
|
245
|
+
name: "attachment",
|
|
246
|
+
group: "block",
|
|
247
|
+
atom: true,
|
|
248
|
+
selectable: true,
|
|
249
|
+
draggable: true,
|
|
250
|
+
addAttributes() {
|
|
251
|
+
return attachmentAttributes();
|
|
252
|
+
},
|
|
253
|
+
parseHTML() {
|
|
254
|
+
return [{ tag: "div[data-atri-attachment]" }];
|
|
255
|
+
},
|
|
256
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
257
|
+
const { src, name, size, status } = node.attrs;
|
|
258
|
+
const label = attachmentLabel(name, src);
|
|
259
|
+
const children = [
|
|
260
|
+
["span", { class: "atri-attachment-icon", "aria-hidden": "true" }]
|
|
261
|
+
];
|
|
262
|
+
const body = [
|
|
263
|
+
["a", { class: "atri-attachment-name", href: src, rel: "noopener noreferrer" }, label]
|
|
264
|
+
];
|
|
265
|
+
if (size) {
|
|
266
|
+
body.push(["span", { class: "atri-attachment-size" }, formatFileSize(size)]);
|
|
267
|
+
}
|
|
268
|
+
children.push(["div", { class: "atri-attachment-body" }, ...body]);
|
|
269
|
+
if (status) {
|
|
270
|
+
children.push([
|
|
271
|
+
"span",
|
|
272
|
+
{ class: "atri-attachment-progress" },
|
|
273
|
+
["span", { class: "atri-attachment-progress-bar" }]
|
|
274
|
+
]);
|
|
275
|
+
}
|
|
276
|
+
return [
|
|
277
|
+
"div",
|
|
278
|
+
mergeAttributes(HTMLAttributes, { "data-atri-attachment": "", class: "atri-attachment" }),
|
|
279
|
+
...children
|
|
280
|
+
];
|
|
281
|
+
},
|
|
282
|
+
addCommands() {
|
|
283
|
+
return {
|
|
284
|
+
setAttachment: (options) => (props) => insertAttachmentContent(props, this.name, options),
|
|
285
|
+
setAttachmentDisplay: (options) => ({ state, dispatch }) => switchAttachmentDisplay(state, dispatch, options.display),
|
|
286
|
+
toggleAttachmentDisplay: () => ({ state, dispatch }) => {
|
|
287
|
+
const located = locateAttachment(state);
|
|
288
|
+
if (!located) return false;
|
|
289
|
+
return switchAttachmentDisplay(
|
|
290
|
+
state,
|
|
291
|
+
dispatch,
|
|
292
|
+
located.node.type.name === "attachment" ? "link" : "card"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
addProseMirrorPlugins() {
|
|
298
|
+
const findForm = (event) => {
|
|
299
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
300
|
+
const dom = target == null ? void 0 : target.closest("[data-atri-attachment], [data-atri-attachment-link]");
|
|
301
|
+
if (!dom) return null;
|
|
302
|
+
const view = this.editor.view;
|
|
303
|
+
const pos = view.posAtDOM(dom, 0);
|
|
304
|
+
const node = view.state.doc.nodeAt(pos);
|
|
305
|
+
return node && isAttachmentForm(node.type.name) ? { dom, pos, node } : null;
|
|
306
|
+
};
|
|
307
|
+
let pendingClick = null;
|
|
308
|
+
return [
|
|
309
|
+
new Plugin({
|
|
310
|
+
key: new PluginKey("atriAttachmentSelection"),
|
|
311
|
+
props: {
|
|
312
|
+
handleDOMEvents: {
|
|
313
|
+
// 这两种 atom 没有 NodeView,鼠标点了不会自己变成 NodeSelection——
|
|
314
|
+
// 而切形态、删除都以"选中的附件"为作用对象;点了却切不动比多一次点击更糟
|
|
315
|
+
mousedown: (view, event) => {
|
|
316
|
+
const hit = findForm(event);
|
|
317
|
+
if (!hit) {
|
|
318
|
+
pendingClick = null;
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
const sel = view.state.selection;
|
|
322
|
+
if (sel instanceof NodeSelection && sel.from === hit.pos) {
|
|
323
|
+
pendingClick = { pos: hit.pos, navigate: true };
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
pendingClick = { pos: hit.pos, navigate: false };
|
|
328
|
+
view.dispatch(
|
|
329
|
+
view.state.tr.setSelection(NodeSelection.create(view.state.doc, hit.pos))
|
|
330
|
+
);
|
|
331
|
+
return true;
|
|
332
|
+
},
|
|
333
|
+
// 附件里的 <a>(卡片的名字链接、行内链接本体):第一次点击只选中附件,
|
|
334
|
+
// 已选中再点才真的打开文件——否则"点一下选中去切换"与"点开文件"互相打架。
|
|
335
|
+
// 判据取 mousedown 记下的那一笔而不是 click 时刻的选区:
|
|
336
|
+
// 放行的 mousedown 会被 PM 改写成 TextSelection,选区就当不得证据
|
|
337
|
+
click: (_view, event) => {
|
|
338
|
+
const hit = findForm(event);
|
|
339
|
+
const pending = pendingClick;
|
|
340
|
+
pendingClick = null;
|
|
341
|
+
if (!hit || !pending || pending.pos !== hit.pos) return false;
|
|
342
|
+
const inAnchor = event.target instanceof Element && !!event.target.closest("a[href]");
|
|
343
|
+
if (pending.navigate && (inAnchor || hit.node.type.name === "attachmentLink")) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
event.preventDefault();
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
];
|
|
353
|
+
},
|
|
354
|
+
markdownTokenName: "file",
|
|
355
|
+
markdownTokenizer: {
|
|
356
|
+
name: "file",
|
|
357
|
+
level: "block",
|
|
358
|
+
start: (src) => src.indexOf("!file["),
|
|
359
|
+
tokenize: (src) => {
|
|
360
|
+
const match = markdownPattern$1.exec(src);
|
|
361
|
+
if (!match) return void 0;
|
|
362
|
+
return {
|
|
363
|
+
type: "file",
|
|
364
|
+
raw: match[0],
|
|
365
|
+
text: match[1] ?? "",
|
|
366
|
+
href: match[2] ?? "",
|
|
367
|
+
title: match[3]
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
parseMarkdown: (token, helpers) => helpers.createNode(
|
|
372
|
+
"attachment",
|
|
373
|
+
{
|
|
374
|
+
src: token.href,
|
|
375
|
+
name: token.text || null,
|
|
376
|
+
size: parseFileSize(token.title) ?? null,
|
|
377
|
+
mime: null
|
|
378
|
+
},
|
|
379
|
+
[]
|
|
380
|
+
),
|
|
381
|
+
renderMarkdown: (node) => {
|
|
382
|
+
var _a, _b, _c;
|
|
383
|
+
const src = ((_a = node.attrs) == null ? void 0 : _a.src) ?? "";
|
|
384
|
+
const name = ((_b = node.attrs) == null ? void 0 : _b.name) ?? "";
|
|
385
|
+
const size = (_c = node.attrs) == null ? void 0 : _c.size;
|
|
386
|
+
return size ? `!file[${name}](${src} "${formatFileSize(size)}")` : `!file[${name}](${src})`;
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
const markdownPattern = /^!filelink\[([^\]]*)\]\(\s*([^\s)]+)(?:\s+"([^"]*)")?\s*\)/;
|
|
390
|
+
const AttachmentLink = Node.create({
|
|
391
|
+
name: "attachmentLink",
|
|
392
|
+
inline: true,
|
|
393
|
+
group: "inline",
|
|
394
|
+
atom: true,
|
|
395
|
+
selectable: true,
|
|
396
|
+
draggable: true,
|
|
397
|
+
addAttributes() {
|
|
398
|
+
return attachmentAttributes();
|
|
399
|
+
},
|
|
400
|
+
parseHTML() {
|
|
401
|
+
return [
|
|
402
|
+
{
|
|
403
|
+
tag: "a[data-atri-attachment-link]",
|
|
404
|
+
priority: 100,
|
|
405
|
+
// 手写或从别处粘来的 <a> 只带 href,没有 data-src:认下来,别留一枚空链接
|
|
406
|
+
getAttrs: (element) => element.getAttribute("data-src") ? {} : { src: element.getAttribute("href") ?? null }
|
|
407
|
+
}
|
|
408
|
+
];
|
|
409
|
+
},
|
|
410
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
411
|
+
const { src, name } = node.attrs;
|
|
412
|
+
return [
|
|
413
|
+
"a",
|
|
414
|
+
mergeAttributes(HTMLAttributes, {
|
|
415
|
+
"data-atri-attachment-link": "",
|
|
416
|
+
class: "atri-attachment-link",
|
|
417
|
+
href: src,
|
|
418
|
+
// download 让同源地址点击即下载(跨域浏览器会退化成导航);
|
|
419
|
+
// mergeAttributes 会丢掉 null,没名字就只剩普通链接
|
|
420
|
+
download: name,
|
|
421
|
+
rel: "noopener noreferrer"
|
|
422
|
+
}),
|
|
423
|
+
["span", { class: "atri-attachment-icon", "aria-hidden": "true" }],
|
|
424
|
+
attachmentLabel(name, src)
|
|
425
|
+
];
|
|
426
|
+
},
|
|
427
|
+
addCommands() {
|
|
428
|
+
return {
|
|
429
|
+
setAttachmentLink: (options) => (props) => insertAttachmentContent(props, this.name, options)
|
|
430
|
+
};
|
|
431
|
+
},
|
|
432
|
+
markdownTokenName: "filelink",
|
|
433
|
+
markdownTokenizer: {
|
|
434
|
+
name: "filelink",
|
|
435
|
+
level: "inline",
|
|
436
|
+
start: (src) => src.indexOf("!filelink["),
|
|
437
|
+
tokenize: (src) => {
|
|
438
|
+
const match = markdownPattern.exec(src);
|
|
439
|
+
if (!match) return void 0;
|
|
440
|
+
return {
|
|
441
|
+
type: "filelink",
|
|
442
|
+
raw: match[0],
|
|
443
|
+
text: match[1] ?? "",
|
|
444
|
+
href: match[2] ?? "",
|
|
445
|
+
title: match[3]
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
parseMarkdown: (token, helpers) => helpers.createNode(
|
|
450
|
+
"attachmentLink",
|
|
451
|
+
{
|
|
452
|
+
src: token.href,
|
|
453
|
+
name: token.text || null,
|
|
454
|
+
size: parseFileSize(token.title) ?? null,
|
|
455
|
+
mime: null
|
|
456
|
+
},
|
|
457
|
+
[]
|
|
458
|
+
),
|
|
459
|
+
renderMarkdown: (node) => {
|
|
460
|
+
var _a, _b, _c;
|
|
461
|
+
const src = ((_a = node.attrs) == null ? void 0 : _a.src) ?? "";
|
|
462
|
+
const name = ((_b = node.attrs) == null ? void 0 : _b.name) ?? "";
|
|
463
|
+
const size = (_c = node.attrs) == null ? void 0 : _c.size;
|
|
464
|
+
return size ? `!filelink[${name}](${src} "${formatFileSize(size)}")` : `!filelink[${name}](${src})`;
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
function filesOf$1(transfer) {
|
|
468
|
+
if (!transfer) return [];
|
|
469
|
+
return Array.from(transfer.files);
|
|
470
|
+
}
|
|
471
|
+
function dropPos(view, event) {
|
|
472
|
+
const result = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
|
473
|
+
if (!result) return void 0;
|
|
474
|
+
return result.inside > -1 ? result.inside : result.pos;
|
|
475
|
+
}
|
|
476
|
+
const MediaFiles = Extension.create({
|
|
477
|
+
name: "mediaFiles",
|
|
478
|
+
addOptions() {
|
|
479
|
+
return { runtime: null };
|
|
480
|
+
},
|
|
481
|
+
addProseMirrorPlugins() {
|
|
482
|
+
const runtime = this.options.runtime;
|
|
483
|
+
if (!runtime) return [];
|
|
484
|
+
return [
|
|
485
|
+
new Plugin({
|
|
486
|
+
props: {
|
|
487
|
+
/** 先于 PM 的剪贴板解析:带文件的粘贴(截图)交给上传管线,纯文本照常走默认链路 */
|
|
488
|
+
handlePaste: (view, event) => {
|
|
489
|
+
const files = filesOf$1(event.clipboardData);
|
|
490
|
+
if (!files.length) return false;
|
|
491
|
+
event.preventDefault();
|
|
492
|
+
if (view.editable) void runtime.handleFiles(files);
|
|
493
|
+
return true;
|
|
494
|
+
},
|
|
495
|
+
handleDrop: (view, event) => {
|
|
496
|
+
const dragEvent = event;
|
|
497
|
+
const files = filesOf$1(dragEvent.dataTransfer);
|
|
498
|
+
if (!files.length) return false;
|
|
499
|
+
dragEvent.preventDefault();
|
|
500
|
+
if (!view.editable) return true;
|
|
501
|
+
void runtime.handleFiles(files, { pos: dropPos(view, dragEvent) });
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
];
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
const MIN_RESIZE_SIZE = 60;
|
|
510
|
+
function createMediaExtensions(media, runtime) {
|
|
511
|
+
if (media === false) return [];
|
|
512
|
+
const image = media == null ? void 0 : media.image;
|
|
513
|
+
const resizable = (image == null ? void 0 : image.resize) ?? true;
|
|
514
|
+
return [
|
|
515
|
+
AtriImage.configure({
|
|
516
|
+
inline: (image == null ? void 0 : image.inline) ?? false,
|
|
517
|
+
// allowBase64 只管一件事:img[src^="data:"] 解不解得回来(stock Image 里它只出现在
|
|
518
|
+
// parseHTML)。失败后内联写进文档的就是 data URL,而重建编辑器按 getHTML() 回填,
|
|
519
|
+
// 不一起开的话那张图重建一次就静默没了
|
|
520
|
+
allowBase64: ((image == null ? void 0 : image.allowBase64) ?? false) || ((image == null ? void 0 : image.fallbackToBase64) ?? false),
|
|
521
|
+
resize: resizable ? {
|
|
522
|
+
enabled: true,
|
|
523
|
+
minWidth: MIN_RESIZE_SIZE,
|
|
524
|
+
minHeight: MIN_RESIZE_SIZE,
|
|
525
|
+
alwaysPreserveAspectRatio: true
|
|
526
|
+
} : false
|
|
527
|
+
}),
|
|
528
|
+
Attachment,
|
|
529
|
+
// 行内链接形态:与卡片是两个节点类型(PM 的 inline/group 静态声明),切换靠命令换节点
|
|
530
|
+
AttachmentLink,
|
|
531
|
+
// 拖放与剪贴板投放都汇到 runtime.handleFiles,与面板选文件同一条路
|
|
532
|
+
MediaFiles.configure({ runtime: runtime ?? null })
|
|
533
|
+
];
|
|
534
|
+
}
|
|
535
|
+
function pickUrl(body) {
|
|
536
|
+
var _a;
|
|
537
|
+
if (!body || typeof body !== "object") return void 0;
|
|
538
|
+
const record = body;
|
|
539
|
+
const candidates = [record.url, record.location, (_a = record.data) == null ? void 0 : _a.url];
|
|
540
|
+
const url = candidates.find((value) => typeof value === "string" && value !== "");
|
|
541
|
+
return url;
|
|
542
|
+
}
|
|
543
|
+
function uploadViaEndpoint(config, file, context) {
|
|
544
|
+
return new Promise((resolve, reject) => {
|
|
545
|
+
var _a;
|
|
546
|
+
const xhr = new XMLHttpRequest();
|
|
547
|
+
xhr.open("POST", config.endpoint, true);
|
|
548
|
+
xhr.withCredentials = config.withCredentials ?? false;
|
|
549
|
+
Object.entries(config.headers ?? {}).forEach(([key, value]) => {
|
|
550
|
+
xhr.setRequestHeader(key, value);
|
|
551
|
+
});
|
|
552
|
+
xhr.upload.onprogress = (event) => {
|
|
553
|
+
if (!event.lengthComputable || !event.total) return;
|
|
554
|
+
context.onProgress({
|
|
555
|
+
percent: Math.round(event.loaded / event.total * 100),
|
|
556
|
+
loaded: event.loaded,
|
|
557
|
+
total: event.total
|
|
558
|
+
});
|
|
559
|
+
};
|
|
560
|
+
xhr.onload = () => {
|
|
561
|
+
if (xhr.status < 200 || xhr.status >= 300) {
|
|
562
|
+
reject(new Error(`上传失败:HTTP ${xhr.status}`));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
let body = null;
|
|
566
|
+
try {
|
|
567
|
+
body = xhr.responseText ? JSON.parse(xhr.responseText) : null;
|
|
568
|
+
} catch {
|
|
569
|
+
body = xhr.responseText;
|
|
570
|
+
}
|
|
571
|
+
if (config.transformResult) {
|
|
572
|
+
resolve(config.transformResult(body, file));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const url = typeof body === "string" ? body : pickUrl(body);
|
|
576
|
+
if (!url) {
|
|
577
|
+
reject(new Error("上传失败:响应里找不到文件地址,请用 upload.transformResult 指定"));
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
resolve({ url });
|
|
581
|
+
};
|
|
582
|
+
xhr.onerror = () => reject(new Error("上传失败:网络错误"));
|
|
583
|
+
xhr.onabort = () => reject(new DOMException("Aborted", "AbortError"));
|
|
584
|
+
if (context.signal.aborted) {
|
|
585
|
+
xhr.abort();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
context.signal.addEventListener("abort", () => xhr.abort(), { once: true });
|
|
589
|
+
const form = new FormData();
|
|
590
|
+
form.append(config.fieldName ?? "file", file, ((_a = config.requestName) == null ? void 0 : _a.call(config, file)) ?? file.name);
|
|
591
|
+
xhr.send(form);
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
function createUploader(upload) {
|
|
595
|
+
if (!upload) return null;
|
|
596
|
+
if (typeof upload === "function") {
|
|
597
|
+
return { upload: (file, context) => upload(file, context) };
|
|
598
|
+
}
|
|
599
|
+
if (!upload.endpoint) return null;
|
|
600
|
+
return { upload: (file, context) => uploadViaEndpoint(upload, file, context) };
|
|
601
|
+
}
|
|
602
|
+
function readFileAsDataUrl(file) {
|
|
603
|
+
return new Promise((resolve, reject) => {
|
|
604
|
+
const reader = new FileReader();
|
|
605
|
+
reader.onload = () => resolve(String(reader.result));
|
|
606
|
+
reader.onerror = () => reject(reader.error ?? new Error("读取文件失败"));
|
|
607
|
+
reader.readAsDataURL(file);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
class MediaRuntime {
|
|
611
|
+
constructor(config = {}) {
|
|
612
|
+
this.editor = null;
|
|
613
|
+
this.tasks = /* @__PURE__ */ new Map();
|
|
614
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
615
|
+
this.sequence = 0;
|
|
616
|
+
this.config = config;
|
|
617
|
+
this.uploader = createUploader(config.upload);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* 换绑到新的 Editor 实例,并把瞬时态补回重建后的文档
|
|
621
|
+
*/
|
|
622
|
+
bind(editor) {
|
|
623
|
+
this.editor = editor;
|
|
624
|
+
this.restoreTransientState();
|
|
625
|
+
editor.on("transaction", ({ transaction }) => {
|
|
626
|
+
if (transaction.docChanged && this.tasks.size) this.prune();
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* 处理一批文件:校验 → 插节点 → 上传 → 回写
|
|
631
|
+
* kind 缺省时按 MIME 分流;MediaKind 与节点名一一对应,直接当 type 用
|
|
632
|
+
*/
|
|
633
|
+
async handleFiles(files, options = {}) {
|
|
634
|
+
var _a, _b;
|
|
635
|
+
if (!this.editor) return;
|
|
636
|
+
const { kind, pos } = options;
|
|
637
|
+
const maxFiles = this.config.maxFiles ?? DEFAULT_MAX_FILES;
|
|
638
|
+
const queue = [];
|
|
639
|
+
for (const file of Array.from(files)) {
|
|
640
|
+
const mediaKind = kind ?? (isImageFile(file) ? "image" : "attachment");
|
|
641
|
+
const accept = mediaKind === "image" ? (_a = this.config.image) == null ? void 0 : _a.accept : (_b = this.config.attachment) == null ? void 0 : _b.accept;
|
|
642
|
+
const validation = validateFile(file, { accept, maxFileSize: this.config.maxFileSize });
|
|
643
|
+
if (!validation.ok) {
|
|
644
|
+
this.reject(file, validation.reason);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (queue.length >= maxFiles) {
|
|
648
|
+
this.reject(file, "too-many");
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
queue.push({ file, kind: mediaKind });
|
|
652
|
+
}
|
|
653
|
+
if (!this.uploader) {
|
|
654
|
+
await this.inlineImages(queue, pos);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const tasks = queue.map(({ file, kind: mediaKind }) => this.createTask(file, mediaKind));
|
|
658
|
+
this.insertContents(
|
|
659
|
+
tasks.map((task) => this.nodeSpec(task)),
|
|
660
|
+
pos
|
|
661
|
+
);
|
|
662
|
+
await Promise.all(tasks.map((task) => this.startUpload(task)));
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* 该类型的文件白名单,已归一成 <input accept> 能用的写法
|
|
666
|
+
* 面板与校验共用同一份配置,免得选得到却传不了
|
|
667
|
+
*/
|
|
668
|
+
acceptFor(kind) {
|
|
669
|
+
var _a, _b;
|
|
670
|
+
return acceptAttribute(
|
|
671
|
+
kind === "image" ? (_a = this.config.image) == null ? void 0 : _a.accept : (_b = this.config.attachment) == null ? void 0 : _b.accept
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* 重试所有失败的上传,卡片原地回到上传中
|
|
676
|
+
* 失败后已内联成 data URL 的图片也在其中:换到服务端地址前它一直算"失败"
|
|
677
|
+
*/
|
|
678
|
+
retryFailed() {
|
|
679
|
+
const retried = Array.from(this.tasks.values()).filter((task) => task.status === "error").map((task) => this.startUpload(task));
|
|
680
|
+
return Promise.all(retried).then(() => void 0);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* 文档里是否还有"现在就保存会丢内容"的文件:上传中与失败都算,
|
|
684
|
+
* 因为它们的 src 还指着本地预览地址(blob:),刷新即失效
|
|
685
|
+
*
|
|
686
|
+
* 失败后已按配置内联成 data URL 的图片不计入——内容已经安全落进文档,
|
|
687
|
+
* 服务端地址只是可选的优化,别让一个一直失败的上传把保存永久卡住
|
|
688
|
+
*/
|
|
689
|
+
hasPendingUploads() {
|
|
690
|
+
return Array.from(this.tasks.values()).some((task) => !task.inlined);
|
|
691
|
+
}
|
|
692
|
+
getState() {
|
|
693
|
+
const tasks = Array.from(this.tasks.values());
|
|
694
|
+
const uploading = tasks.filter((task) => task.status === "uploading");
|
|
695
|
+
const total = uploading.reduce((sum, task) => sum + task.file.size, 0);
|
|
696
|
+
const loaded = uploading.reduce(
|
|
697
|
+
(sum, task) => sum + task.file.size * clampPercent(task.percent ?? 0) / 100,
|
|
698
|
+
0
|
|
699
|
+
);
|
|
700
|
+
return {
|
|
701
|
+
uploading: uploading.length,
|
|
702
|
+
failed: tasks.filter((task) => task.status === "error" && !task.inlined).length,
|
|
703
|
+
inline: tasks.filter((task) => task.inlined).length,
|
|
704
|
+
percent: total ? Math.round(loaded / total * 100) : 0
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
subscribe(listener) {
|
|
708
|
+
this.listeners.add(listener);
|
|
709
|
+
listener(this.getState());
|
|
710
|
+
return () => {
|
|
711
|
+
this.listeners.delete(listener);
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
destroy() {
|
|
715
|
+
this.tasks.forEach((task) => {
|
|
716
|
+
task.controller.abort();
|
|
717
|
+
revokePreviewUrl(task);
|
|
718
|
+
});
|
|
719
|
+
this.tasks.clear();
|
|
720
|
+
this.listeners.clear();
|
|
721
|
+
this.editor = null;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* 无上传通道:图片按配置退化成 base64 内联,附件没有合理退路
|
|
725
|
+
* 先把文件都读完再一起插,顺序才与选文件时一致
|
|
726
|
+
*/
|
|
727
|
+
async inlineImages(entries, pos) {
|
|
728
|
+
var _a;
|
|
729
|
+
const contents = [];
|
|
730
|
+
for (const { file, kind } of entries) {
|
|
731
|
+
if (kind !== "image" || !((_a = this.config.image) == null ? void 0 : _a.allowBase64)) {
|
|
732
|
+
this.reject(file, "no-upload");
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
const src = await readFileAsDataUrl(file);
|
|
737
|
+
contents.push({ type: "image", attrs: { src, alt: file.name } });
|
|
738
|
+
} catch (cause) {
|
|
739
|
+
this.reject(file, "upload-failed", cause);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
this.insertContents(contents, pos);
|
|
743
|
+
}
|
|
744
|
+
createTask(file, kind) {
|
|
745
|
+
const task = {
|
|
746
|
+
id: `atri-upload-${this.sequence += 1}`,
|
|
747
|
+
kind,
|
|
748
|
+
file,
|
|
749
|
+
previewUrl: createPreviewUrl(file),
|
|
750
|
+
status: "uploading",
|
|
751
|
+
percent: null,
|
|
752
|
+
inlined: false,
|
|
753
|
+
controller: new AbortController()
|
|
754
|
+
};
|
|
755
|
+
this.tasks.set(task.id, task);
|
|
756
|
+
return task;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* 带本地预览的节点内容;上传完这些地址就被服务端地址换掉
|
|
760
|
+
*/
|
|
761
|
+
nodeSpec(task) {
|
|
762
|
+
var _a;
|
|
763
|
+
const { file, previewUrl } = task;
|
|
764
|
+
const attrs = {
|
|
765
|
+
src: previewUrl ?? file.name,
|
|
766
|
+
status: task.status,
|
|
767
|
+
uploadId: task.id
|
|
768
|
+
};
|
|
769
|
+
if (task.kind === "attachment") {
|
|
770
|
+
attrs.name = file.name;
|
|
771
|
+
attrs.size = file.size;
|
|
772
|
+
attrs.mime = file.type || null;
|
|
773
|
+
} else {
|
|
774
|
+
attrs.alt = file.name;
|
|
775
|
+
}
|
|
776
|
+
const type = task.kind === "attachment" && ((_a = this.config.attachment) == null ? void 0 : _a.display) === "link" ? "attachmentLink" : task.kind;
|
|
777
|
+
return { type, attrs };
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* 一批节点走一次事务:逐个插的话每插一个"那个位置"的含义就变一次,顺序会乱;
|
|
781
|
+
* 顺带让一次多文件拖放只需撤销一步
|
|
782
|
+
* pos 为拖放落点,省略时落在当前选区(选中的媒体节点不被顶掉)
|
|
783
|
+
*/
|
|
784
|
+
insertContents(contents, pos) {
|
|
785
|
+
const editor = this.editor;
|
|
786
|
+
if (!editor || !contents.length) return;
|
|
787
|
+
editor.chain().insertContentAt(pos ?? mediaInsertTarget(editor.state.selection), contents).run();
|
|
788
|
+
this.emit();
|
|
789
|
+
}
|
|
790
|
+
async startUpload(task) {
|
|
791
|
+
var _a;
|
|
792
|
+
if (!this.uploader) return;
|
|
793
|
+
task.status = "uploading";
|
|
794
|
+
task.percent = null;
|
|
795
|
+
task.inlined = false;
|
|
796
|
+
task.controller = new AbortController();
|
|
797
|
+
this.patch(task, this.stateAttrs(task, "uploading"));
|
|
798
|
+
this.emit();
|
|
799
|
+
try {
|
|
800
|
+
const result = await this.uploader.upload(task.file, {
|
|
801
|
+
onProgress: (progress) => this.updateProgress(task, progress),
|
|
802
|
+
signal: task.controller.signal
|
|
803
|
+
});
|
|
804
|
+
if (!this.tasks.has(task.id)) return;
|
|
805
|
+
this.applyResult(task, result);
|
|
806
|
+
} catch (cause) {
|
|
807
|
+
if (task.controller.signal.aborted) {
|
|
808
|
+
this.dropTask(task);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
task.status = "error";
|
|
812
|
+
task.percent = null;
|
|
813
|
+
const canInline = task.kind === "image" && ((_a = this.config.image) == null ? void 0 : _a.fallbackToBase64) === true;
|
|
814
|
+
const inlined = canInline && await this.inlineAfterFailure(task);
|
|
815
|
+
if (!inlined) {
|
|
816
|
+
this.patch(task, this.stateAttrs(task, "error"));
|
|
817
|
+
this.emit();
|
|
818
|
+
}
|
|
819
|
+
this.reject(task.file, "upload-failed", cause);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* 上传失败后把图片读成 data URL,原位换掉 src
|
|
824
|
+
*
|
|
825
|
+
* 是 patch 而不是插新节点:位置与"撤销一步回到插入前"这两条语义都靠它才不碎。
|
|
826
|
+
* error 态留着——重试按钮还在,网络恢复后能把 data URL 换成服务端地址
|
|
827
|
+
*/
|
|
828
|
+
async inlineAfterFailure(task) {
|
|
829
|
+
if (!this.tasks.has(task.id)) return false;
|
|
830
|
+
let src;
|
|
831
|
+
try {
|
|
832
|
+
src = await readFileAsDataUrl(task.file);
|
|
833
|
+
} catch {
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
this.patch(task, { src, ...this.stateAttrs(task, "error") });
|
|
837
|
+
if (!this.tasks.has(task.id)) return false;
|
|
838
|
+
task.inlined = true;
|
|
839
|
+
revokePreviewUrl(task);
|
|
840
|
+
this.emit();
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
applyResult(task, result) {
|
|
844
|
+
const { file } = task;
|
|
845
|
+
const attrs = {
|
|
846
|
+
src: result.url,
|
|
847
|
+
status: null,
|
|
848
|
+
uploadId: null
|
|
849
|
+
};
|
|
850
|
+
if (task.kind === "attachment") {
|
|
851
|
+
attrs.progress = null;
|
|
852
|
+
attrs.name = result.name ?? file.name;
|
|
853
|
+
attrs.size = result.size ?? file.size;
|
|
854
|
+
attrs.mime = result.mime ?? (file.type || null);
|
|
855
|
+
} else {
|
|
856
|
+
attrs.alt = result.name ?? file.name;
|
|
857
|
+
}
|
|
858
|
+
this.patch(task, attrs);
|
|
859
|
+
this.tasks.delete(task.id);
|
|
860
|
+
revokePreviewUrl(task);
|
|
861
|
+
this.emit();
|
|
862
|
+
}
|
|
863
|
+
updateProgress(task, progress) {
|
|
864
|
+
if (task.status !== "uploading") return;
|
|
865
|
+
const percent = Number.isFinite(progress.percent) ? clampPercent(progress.percent) : null;
|
|
866
|
+
task.percent = percent;
|
|
867
|
+
if (task.kind === "attachment") {
|
|
868
|
+
this.patch(task, { progress: percent }, { silent: true });
|
|
869
|
+
}
|
|
870
|
+
this.emit();
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* 写进节点的那份状态;附件卡片额外带百分比
|
|
874
|
+
*/
|
|
875
|
+
stateAttrs(task, status, progress = null) {
|
|
876
|
+
return task.kind === "attachment" ? { status, progress } : { status };
|
|
877
|
+
}
|
|
878
|
+
patch(task, attrs, options = {}) {
|
|
879
|
+
const editor = this.editor;
|
|
880
|
+
if (!editor || editor.isDestroyed) return;
|
|
881
|
+
const position = this.findPosition(task);
|
|
882
|
+
if (position === null) {
|
|
883
|
+
task.controller.abort();
|
|
884
|
+
this.dropTask(task);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const node = editor.state.doc.nodeAt(position);
|
|
888
|
+
if (!node) return;
|
|
889
|
+
const tr = editor.state.tr.setNodeMarkup(position, void 0, {
|
|
890
|
+
...node.attrs,
|
|
891
|
+
...attrs
|
|
892
|
+
});
|
|
893
|
+
tr.setMeta("addToHistory", false);
|
|
894
|
+
if (options.silent) tr.setMeta("preventUpdate", true);
|
|
895
|
+
editor.view.dispatch(tr);
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* 编辑器重建后 uploadId 不在 HTML 里,靠本地预览地址把瞬时态补回文档
|
|
899
|
+
*/
|
|
900
|
+
restoreTransientState() {
|
|
901
|
+
const editor = this.editor;
|
|
902
|
+
if (!editor || !this.tasks.size) return;
|
|
903
|
+
const pending = [];
|
|
904
|
+
editor.state.doc.descendants((node, position) => {
|
|
905
|
+
const task = this.taskByPreviewUrl(node.attrs.src);
|
|
906
|
+
if (task && node.attrs.uploadId == null) pending.push({ position, node, task });
|
|
907
|
+
});
|
|
908
|
+
if (!pending.length) return;
|
|
909
|
+
const tr = editor.state.tr;
|
|
910
|
+
pending.sort((a, b) => b.position - a.position).forEach(({ position, node, task }) => {
|
|
911
|
+
tr.setNodeMarkup(position, void 0, {
|
|
912
|
+
...node.attrs,
|
|
913
|
+
uploadId: task.id,
|
|
914
|
+
...this.stateAttrs(task, task.status, task.percent)
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
tr.setMeta("addToHistory", false);
|
|
918
|
+
tr.setMeta("preventUpdate", true);
|
|
919
|
+
editor.view.dispatch(tr);
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* 丢掉文档里已经没有对应节点的任务,中止其请求
|
|
923
|
+
*/
|
|
924
|
+
prune() {
|
|
925
|
+
const editor = this.editor;
|
|
926
|
+
if (!editor || editor.isDestroyed) return;
|
|
927
|
+
const alive = /* @__PURE__ */ new Set();
|
|
928
|
+
editor.state.doc.descendants((node) => {
|
|
929
|
+
if (typeof node.attrs.uploadId === "string") alive.add(node.attrs.uploadId);
|
|
930
|
+
if (typeof node.attrs.src === "string") alive.add(node.attrs.src);
|
|
931
|
+
});
|
|
932
|
+
this.tasks.forEach((task) => {
|
|
933
|
+
const exists = alive.has(task.id) || task.previewUrl !== null && alive.has(task.previewUrl);
|
|
934
|
+
if (exists) return;
|
|
935
|
+
task.controller.abort();
|
|
936
|
+
this.dropTask(task);
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
findPosition(task) {
|
|
940
|
+
const editor = this.editor;
|
|
941
|
+
if (!editor) return null;
|
|
942
|
+
let found = null;
|
|
943
|
+
editor.state.doc.descendants((node, position) => {
|
|
944
|
+
if (found !== null) return;
|
|
945
|
+
if (node.attrs.uploadId === task.id || node.attrs.src === task.previewUrl) {
|
|
946
|
+
found = position;
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
return found;
|
|
950
|
+
}
|
|
951
|
+
taskByPreviewUrl(src) {
|
|
952
|
+
if (typeof src !== "string" || !src) return null;
|
|
953
|
+
for (const task of this.tasks.values()) {
|
|
954
|
+
if (task.previewUrl === src) return task;
|
|
955
|
+
}
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* 摘掉任务并回收预览地址;是否 abort 由调用方决定
|
|
960
|
+
*/
|
|
961
|
+
dropTask(task) {
|
|
962
|
+
if (this.tasks.get(task.id) !== task) return;
|
|
963
|
+
this.tasks.delete(task.id);
|
|
964
|
+
revokePreviewUrl(task);
|
|
965
|
+
this.emit();
|
|
966
|
+
}
|
|
967
|
+
reject(file, reason, cause) {
|
|
968
|
+
if (this.config.onError) {
|
|
969
|
+
this.config.onError({ file, reason, cause });
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
console.warn(`[Atri Editor] File "${file.name}" was rejected (${reason}).`);
|
|
973
|
+
}
|
|
974
|
+
emit() {
|
|
975
|
+
if (!this.listeners.size) return;
|
|
976
|
+
const state = this.getState();
|
|
977
|
+
this.listeners.forEach((listener) => listener(state));
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function clampPercent(percent) {
|
|
981
|
+
return Math.round(Math.min(100, Math.max(0, percent)));
|
|
982
|
+
}
|
|
983
|
+
function createPreviewUrl(file) {
|
|
984
|
+
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") return null;
|
|
985
|
+
try {
|
|
986
|
+
return URL.createObjectURL(file);
|
|
987
|
+
} catch {
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
function revokePreviewUrl(task) {
|
|
992
|
+
const { previewUrl } = task;
|
|
993
|
+
if (!previewUrl) return;
|
|
994
|
+
if (typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function") return;
|
|
995
|
+
URL.revokeObjectURL(previewUrl);
|
|
996
|
+
task.previewUrl = null;
|
|
997
|
+
}
|
|
998
|
+
const BUBBLE_TEXT_ITEMS = ["bold", "italic", "underline", "strike", "code"];
|
|
999
|
+
const BUBBLE_NODE_ITEMS = ["attachmentDisplay", "delete"];
|
|
1000
|
+
const BUBBLE_SELECTABLE_NODES = ["image", "attachment", "attachmentLink"];
|
|
1001
|
+
function bubbleNodeNameFor(state) {
|
|
1002
|
+
if (!isNodeSelection(state.selection)) return null;
|
|
1003
|
+
const { name } = state.selection.node.type;
|
|
1004
|
+
return BUBBLE_SELECTABLE_NODES.includes(name) ? name : null;
|
|
1005
|
+
}
|
|
1006
|
+
function bubbleGroupFor(state) {
|
|
1007
|
+
if (isNodeSelection(state.selection)) return bubbleNodeNameFor(state) ? "node" : null;
|
|
1008
|
+
return "text";
|
|
1009
|
+
}
|
|
1010
|
+
function allowsBubbleMark(state, from) {
|
|
1011
|
+
const parent = state.doc.resolve(from).parent.type;
|
|
1012
|
+
return BUBBLE_TEXT_ITEMS.some((markName) => {
|
|
1013
|
+
const mark = state.schema.marks[markName];
|
|
1014
|
+
return !!mark && parent.allowsMarkType(mark);
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
function shouldShowBubbleMenu(props) {
|
|
1018
|
+
const { editor, element, state, from, to } = props;
|
|
1019
|
+
let group = editor.isEditable ? bubbleGroupFor(state) : null;
|
|
1020
|
+
const nodeName = group === "node" ? bubbleNodeNameFor(state) : null;
|
|
1021
|
+
if (group === "text" && !state.doc.textBetween(from, to).trim()) group = null;
|
|
1022
|
+
if (group === "text" && !allowsBubbleMark(state, from)) group = null;
|
|
1023
|
+
element.dataset.atriBubbleMode = group ?? "none";
|
|
1024
|
+
element.dataset.atriBubbleNode = nodeName ?? "";
|
|
1025
|
+
if (!group) return false;
|
|
1026
|
+
return !!element.querySelector(`[data-atri-bubble-group="${group}"] [data-toolbar-item]`);
|
|
1027
|
+
}
|
|
1028
|
+
class CoreEditor {
|
|
1029
|
+
constructor(config) {
|
|
1030
|
+
this.config = config;
|
|
1031
|
+
this.placeholderText = config.placeholder ?? "";
|
|
1032
|
+
this.mediaRuntime = config.media === false ? null : config.mediaRuntime ?? new MediaRuntime(config.media);
|
|
1033
|
+
this.editor = this.createEditor();
|
|
1034
|
+
}
|
|
1035
|
+
createEditor() {
|
|
1036
|
+
var _a, _b, _c, _d, _e;
|
|
1037
|
+
const { element, content, contentFormat, editable, extensions } = this.config;
|
|
1038
|
+
const editorExtensions = [
|
|
1039
|
+
StarterKit.configure({
|
|
1040
|
+
// StarterKit v3 默认包含 Underline, Link, TrailingNode
|
|
1041
|
+
})
|
|
1042
|
+
];
|
|
1043
|
+
editorExtensions.push(
|
|
1044
|
+
Placeholder.configure({
|
|
1045
|
+
placeholder: () => this.placeholderText
|
|
1046
|
+
})
|
|
1047
|
+
);
|
|
1048
|
+
editorExtensions.push(
|
|
1049
|
+
TextAlign.configure({
|
|
1050
|
+
types: ["heading", "paragraph"]
|
|
1051
|
+
})
|
|
1052
|
+
);
|
|
1053
|
+
const markdownEnabled = ((_a = this.config.markdown) == null ? void 0 : _a.enabled) !== false;
|
|
1054
|
+
if (markdownEnabled) {
|
|
1055
|
+
editorExtensions.push(
|
|
1056
|
+
Markdown.configure({
|
|
1057
|
+
indentation: (_b = this.config.markdown) == null ? void 0 : _b.indentation,
|
|
1058
|
+
markedOptions: (_c = this.config.markdown) == null ? void 0 : _c.markedOptions
|
|
1059
|
+
})
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
const contentType = contentFormat === "markdown" && !markdownEnabled ? void 0 : contentFormat;
|
|
1063
|
+
editorExtensions.push(...createMediaExtensions(this.config.media, this.mediaRuntime));
|
|
1064
|
+
if (this.config.bubbleElement) {
|
|
1065
|
+
editorExtensions.push(
|
|
1066
|
+
BubbleMenu.configure({
|
|
1067
|
+
element: this.config.bubbleElement,
|
|
1068
|
+
// 官方默认 250ms 防抖,拖选时要等手停下来才浮出;这里直接关掉
|
|
1069
|
+
updateDelay: 0,
|
|
1070
|
+
shouldShow: shouldShowBubbleMenu,
|
|
1071
|
+
options: {
|
|
1072
|
+
// 位置重算只监听一个 scrollTarget + window resize,交给真正会滚的那层
|
|
1073
|
+
scrollTarget: element,
|
|
1074
|
+
// 贴着正文盒的边翻转会看起来像被裁了,留出 4px
|
|
1075
|
+
flip: { padding: 4 },
|
|
1076
|
+
shift: { padding: 4 }
|
|
1077
|
+
}
|
|
1078
|
+
})
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
const enableInputRules = ((_d = this.config.markdown) == null ? void 0 : _d.shortcuts) !== false;
|
|
1082
|
+
if (extensions) {
|
|
1083
|
+
editorExtensions.push(...extensions);
|
|
1084
|
+
}
|
|
1085
|
+
const editor = new Editor({
|
|
1086
|
+
element,
|
|
1087
|
+
content,
|
|
1088
|
+
contentType,
|
|
1089
|
+
enableInputRules,
|
|
1090
|
+
editable: editable ?? true,
|
|
1091
|
+
extensions: editorExtensions,
|
|
1092
|
+
editorProps: {
|
|
1093
|
+
attributes: {
|
|
1094
|
+
class: "atri-editor-content"
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
onCreate: () => {
|
|
1098
|
+
var _a2, _b2;
|
|
1099
|
+
(_b2 = (_a2 = this.config).onCreate) == null ? void 0 : _b2.call(_a2);
|
|
1100
|
+
},
|
|
1101
|
+
onUpdate: () => {
|
|
1102
|
+
var _a2, _b2;
|
|
1103
|
+
(_b2 = (_a2 = this.config).onUpdate) == null ? void 0 : _b2.call(_a2);
|
|
1104
|
+
},
|
|
1105
|
+
onFocus: () => {
|
|
1106
|
+
var _a2, _b2;
|
|
1107
|
+
(_b2 = (_a2 = this.config).onFocus) == null ? void 0 : _b2.call(_a2);
|
|
1108
|
+
},
|
|
1109
|
+
onBlur: () => {
|
|
1110
|
+
var _a2, _b2;
|
|
1111
|
+
(_b2 = (_a2 = this.config).onBlur) == null ? void 0 : _b2.call(_a2);
|
|
1112
|
+
},
|
|
1113
|
+
onDestroy: () => {
|
|
1114
|
+
var _a2, _b2;
|
|
1115
|
+
(_b2 = (_a2 = this.config).onDestroy) == null ? void 0 : _b2.call(_a2);
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
(_e = this.mediaRuntime) == null ? void 0 : _e.bind(editor);
|
|
1119
|
+
return editor;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* 获取上传运行时;media 为 false 时为 null
|
|
1123
|
+
*/
|
|
1124
|
+
getMediaRuntime() {
|
|
1125
|
+
return this.mediaRuntime;
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* 获取 Tiptap Editor 实例
|
|
1129
|
+
*/
|
|
1130
|
+
getEditor() {
|
|
1131
|
+
return this.editor;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* 获取 HTML 内容
|
|
1135
|
+
*/
|
|
1136
|
+
getHTML() {
|
|
1137
|
+
try {
|
|
1138
|
+
return this.editor.getHTML();
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
console.error("[CoreEditor] getHTML() failed:", err);
|
|
1141
|
+
console.error("[CoreEditor] Schema nodes:", Object.keys(this.editor.schema.nodes));
|
|
1142
|
+
console.error("[CoreEditor] Schema marks:", Object.keys(this.editor.schema.marks));
|
|
1143
|
+
throw err;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* 获取 JSON 内容
|
|
1148
|
+
*/
|
|
1149
|
+
getJSON() {
|
|
1150
|
+
return this.editor.getJSON();
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* 设置内容
|
|
1154
|
+
*/
|
|
1155
|
+
setContent(content, emitUpdate = true) {
|
|
1156
|
+
this.editor.commands.setContent(content, { emitUpdate });
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* 清空内容
|
|
1160
|
+
*/
|
|
1161
|
+
clearContent() {
|
|
1162
|
+
this.editor.commands.clearContent(true);
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* 是否为空
|
|
1166
|
+
*/
|
|
1167
|
+
isEmpty() {
|
|
1168
|
+
return this.editor.isEmpty;
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* 设置可编辑状态
|
|
1172
|
+
*/
|
|
1173
|
+
setEditable(editable) {
|
|
1174
|
+
this.editor.setEditable(editable);
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* 是否可编辑
|
|
1178
|
+
*/
|
|
1179
|
+
isEditable() {
|
|
1180
|
+
return this.editor.isEditable;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* 聚焦
|
|
1184
|
+
*/
|
|
1185
|
+
focus() {
|
|
1186
|
+
this.editor.commands.focus();
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* 失焦
|
|
1190
|
+
*/
|
|
1191
|
+
blur() {
|
|
1192
|
+
this.editor.commands.blur();
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* 插入内容
|
|
1196
|
+
*/
|
|
1197
|
+
insertContent(content) {
|
|
1198
|
+
this.editor.commands.insertContent(content);
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* 设置占位符
|
|
1202
|
+
* 占位符写在节点装饰上,只在状态重算时重新取值,因此派发一个空事务刷新视图;
|
|
1203
|
+
* 空事务不改动文档,Tiptap 不会据此触发 update
|
|
1204
|
+
*/
|
|
1205
|
+
setPlaceholder(placeholder) {
|
|
1206
|
+
this.placeholderText = placeholder;
|
|
1207
|
+
this.editor.view.dispatch(this.editor.state.tr);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* 销毁编辑器
|
|
1211
|
+
*/
|
|
1212
|
+
destroy() {
|
|
1213
|
+
this.editor.destroy();
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
function simpleMarkdownToHtml(markdown2) {
|
|
1217
|
+
let html = markdown2;
|
|
1218
|
+
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
|
|
1219
|
+
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
|
|
1220
|
+
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
|
|
1221
|
+
html = html.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>");
|
|
1222
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
1223
|
+
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
|
1224
|
+
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>');
|
|
1225
|
+
html = html.replace(/`(.+?)`/g, "<code>$1</code>");
|
|
1226
|
+
html = html.replace(
|
|
1227
|
+
/!\[([^\]]*)\]\(\s*([^\s)]+)(?:\s+"([^"]*)")?\s*\)/g,
|
|
1228
|
+
(_all, alt, src, title) => `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ""}>`
|
|
1229
|
+
);
|
|
1230
|
+
html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
|
|
1231
|
+
html = html.replace(/^\* (.+)$/gm, "<li>$1</li>");
|
|
1232
|
+
html = html.replace(/^- (.+)$/gm, "<li>$1</li>");
|
|
1233
|
+
html = html.replace(/^(\d+)\. (.+)$/gm, "<li>$2</li>");
|
|
1234
|
+
html = html.replace(/\n\n/g, "</p><p>");
|
|
1235
|
+
html = `<p>${html}</p>`;
|
|
1236
|
+
html = html.replace(/<p><\/p>/g, "");
|
|
1237
|
+
html = html.replace(/<p>(<h[1-6]>)/g, "$1");
|
|
1238
|
+
html = html.replace(/(<\/h[1-6]>)<\/p>/g, "$1");
|
|
1239
|
+
html = html.replace(/<p>(<pre>)/g, "$1");
|
|
1240
|
+
html = html.replace(/(<\/pre>)<\/p>/g, "$1");
|
|
1241
|
+
html = html.replace(/<p>(<li>)/g, "$1");
|
|
1242
|
+
html = html.replace(/(<\/li>)<\/p>/g, "$1");
|
|
1243
|
+
return html;
|
|
1244
|
+
}
|
|
1245
|
+
function simpleHtmlToMarkdown(html) {
|
|
1246
|
+
let md = html;
|
|
1247
|
+
md = md.replace(/<h1[^>]*>(.+?)<\/h1>/gi, "# $1\n\n");
|
|
1248
|
+
md = md.replace(/<h2[^>]*>(.+?)<\/h2>/gi, "## $1\n\n");
|
|
1249
|
+
md = md.replace(/<h3[^>]*>(.+?)<\/h3>/gi, "### $1\n\n");
|
|
1250
|
+
md = md.replace(/<strong[^>]*>(.+?)<\/strong>/gi, "**$1**");
|
|
1251
|
+
md = md.replace(/<b[^>]*>(.+?)<\/b>/gi, "**$1**");
|
|
1252
|
+
md = md.replace(/<em[^>]*>(.+?)<\/em>/gi, "*$1*");
|
|
1253
|
+
md = md.replace(/<i[^>]*>(.+?)<\/i>/gi, "*$1*");
|
|
1254
|
+
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.+?)<\/a>/gi, "[$2]($1)");
|
|
1255
|
+
md = md.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
1256
|
+
var _a, _b;
|
|
1257
|
+
const src = ((_a = /src="([^"]*)"/i.exec(tag)) == null ? void 0 : _a[1]) ?? "";
|
|
1258
|
+
const alt = ((_b = /alt="([^"]*)"/i.exec(tag)) == null ? void 0 : _b[1]) ?? "";
|
|
1259
|
+
return src ? `` : alt;
|
|
1260
|
+
});
|
|
1261
|
+
md = md.replace(/<code[^>]*>(.+?)<\/code>/gi, "`$1`");
|
|
1262
|
+
md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, "```\n$1\n```\n\n");
|
|
1263
|
+
md = md.replace(/<br\s*\/?>/gi, "\n");
|
|
1264
|
+
md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, "$1\n\n");
|
|
1265
|
+
md = md.replace(/<[^>]+>/g, "");
|
|
1266
|
+
md = md.replace(/&/g, "&");
|
|
1267
|
+
md = md.replace(/</g, "<");
|
|
1268
|
+
md = md.replace(/>/g, ">");
|
|
1269
|
+
md = md.replace(/"/g, '"');
|
|
1270
|
+
md = md.replace(/'/g, "'");
|
|
1271
|
+
md = md.replace(/\n{3,}/g, "\n\n");
|
|
1272
|
+
return md.trim();
|
|
1273
|
+
}
|
|
1274
|
+
const markdown = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
1275
|
+
__proto__: null,
|
|
1276
|
+
simpleHtmlToMarkdown,
|
|
1277
|
+
simpleMarkdownToHtml
|
|
1278
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
1279
|
+
class MarkdownService {
|
|
1280
|
+
constructor(editor, config = {}) {
|
|
1281
|
+
this.editor = editor;
|
|
1282
|
+
this.config = {
|
|
1283
|
+
enabled: true,
|
|
1284
|
+
...config
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Tiptap Markdown 序列化器,未注册 Markdown 扩展时为 null
|
|
1289
|
+
*/
|
|
1290
|
+
get manager() {
|
|
1291
|
+
return this.editor.markdown ?? null;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* 获取编辑器内容为 Markdown
|
|
1295
|
+
* 自定义节点的输出由其 renderMarkdown 钩子负责(见 ExtensionManager.registerNodeView),
|
|
1296
|
+
* 因此这里始终走 Tiptap 序列化器,不对整篇文档做额外替换。
|
|
1297
|
+
*/
|
|
1298
|
+
getMarkdown() {
|
|
1299
|
+
const manager = this.manager;
|
|
1300
|
+
if (!manager) {
|
|
1301
|
+
return simpleHtmlToMarkdown(this.editor.getHTML());
|
|
1302
|
+
}
|
|
1303
|
+
return manager.serialize(this.editor.getJSON());
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* 设置 Markdown 内容
|
|
1307
|
+
*/
|
|
1308
|
+
setMarkdown(content, emitUpdate = true) {
|
|
1309
|
+
if (this.manager) {
|
|
1310
|
+
this.editor.commands.setContent(content, { contentType: "markdown", emitUpdate });
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
this.editor.commands.setContent(simpleMarkdownToHtml(content), { emitUpdate });
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Markdown 转 HTML
|
|
1317
|
+
* 与 htmlToMarkdown 方向对称:解析为 JSON 后再按当前 schema 序列化为 HTML
|
|
1318
|
+
*/
|
|
1319
|
+
markdownToHTML(markdown2) {
|
|
1320
|
+
const manager = this.manager;
|
|
1321
|
+
if (!manager) {
|
|
1322
|
+
return simpleMarkdownToHtml(markdown2);
|
|
1323
|
+
}
|
|
1324
|
+
const { schema } = this.editor;
|
|
1325
|
+
return getHTMLFromFragment(
|
|
1326
|
+
Node$1.fromJSON(schema, manager.parse(markdown2)).content,
|
|
1327
|
+
schema
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* HTML 转 Markdown
|
|
1332
|
+
*/
|
|
1333
|
+
htmlToMarkdown(html) {
|
|
1334
|
+
const manager = this.manager;
|
|
1335
|
+
if (!manager) {
|
|
1336
|
+
return simpleHtmlToMarkdown(html);
|
|
1337
|
+
}
|
|
1338
|
+
const parsed = DOMParser.fromSchema(this.editor.schema).parse(
|
|
1339
|
+
elementFromString(html)
|
|
1340
|
+
);
|
|
1341
|
+
return manager.serialize(parsed.toJSON());
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Markdown 转 JSON
|
|
1345
|
+
*/
|
|
1346
|
+
markdownToJSON(markdown2) {
|
|
1347
|
+
const manager = this.manager;
|
|
1348
|
+
if (manager) {
|
|
1349
|
+
return manager.parse(markdown2);
|
|
1350
|
+
}
|
|
1351
|
+
return {
|
|
1352
|
+
type: "doc",
|
|
1353
|
+
content: [
|
|
1354
|
+
{
|
|
1355
|
+
type: "paragraph",
|
|
1356
|
+
content: [{ type: "text", text: markdown2 }]
|
|
1357
|
+
}
|
|
1358
|
+
]
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* 是否启用
|
|
1363
|
+
*/
|
|
1364
|
+
isEnabled() {
|
|
1365
|
+
return this.config.enabled !== false;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
class ThemeManager {
|
|
1369
|
+
constructor(container, theme = "light") {
|
|
1370
|
+
this.container = container;
|
|
1371
|
+
this.currentTheme = theme;
|
|
1372
|
+
this.applyTheme();
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* 应用当前主题
|
|
1376
|
+
*/
|
|
1377
|
+
applyTheme() {
|
|
1378
|
+
this.container.classList.remove("atri-theme-light", "atri-theme-dark");
|
|
1379
|
+
if (this.currentTheme === "light" || this.currentTheme === "dark") {
|
|
1380
|
+
this.container.classList.add(`atri-theme-${this.currentTheme}`);
|
|
1381
|
+
} else {
|
|
1382
|
+
this.container.classList.add(`atri-theme-${this.currentTheme}`);
|
|
1383
|
+
}
|
|
1384
|
+
this.container.setAttribute("data-atri-theme", this.currentTheme);
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* 获取当前主题
|
|
1388
|
+
*/
|
|
1389
|
+
getTheme() {
|
|
1390
|
+
return this.currentTheme;
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* 设置主题
|
|
1394
|
+
*/
|
|
1395
|
+
setTheme(theme) {
|
|
1396
|
+
this.currentTheme = theme;
|
|
1397
|
+
this.applyTheme();
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* 切换亮/暗主题
|
|
1401
|
+
*/
|
|
1402
|
+
toggleTheme() {
|
|
1403
|
+
if (this.currentTheme === "light") {
|
|
1404
|
+
this.setTheme("dark");
|
|
1405
|
+
} else if (this.currentTheme === "dark") {
|
|
1406
|
+
this.setTheme("light");
|
|
1407
|
+
} else {
|
|
1408
|
+
this.setTheme("light");
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* 是否为暗色主题
|
|
1413
|
+
*/
|
|
1414
|
+
isDark() {
|
|
1415
|
+
return this.currentTheme === "dark";
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
const zhResources = {
|
|
1419
|
+
editor: {
|
|
1420
|
+
placeholder: "请输入内容...",
|
|
1421
|
+
bold: "加粗",
|
|
1422
|
+
italic: "斜体",
|
|
1423
|
+
underline: "下划线",
|
|
1424
|
+
strikethrough: "删除线",
|
|
1425
|
+
code: "行内代码",
|
|
1426
|
+
heading: "标题",
|
|
1427
|
+
heading1: "标题 1",
|
|
1428
|
+
heading2: "标题 2",
|
|
1429
|
+
heading3: "标题 3",
|
|
1430
|
+
paragraph: "正文",
|
|
1431
|
+
bulletList: "无序列表",
|
|
1432
|
+
orderedList: "有序列表",
|
|
1433
|
+
taskList: "任务列表",
|
|
1434
|
+
codeBlock: "代码块",
|
|
1435
|
+
blockquote: "引用",
|
|
1436
|
+
link: "链接",
|
|
1437
|
+
image: "图片",
|
|
1438
|
+
attachment: "附件",
|
|
1439
|
+
attachmentDisplay: "附件样式",
|
|
1440
|
+
delete: "删除",
|
|
1441
|
+
video: "视频",
|
|
1442
|
+
table: "表格",
|
|
1443
|
+
undo: "撤销",
|
|
1444
|
+
redo: "重做",
|
|
1445
|
+
alignLeft: "左对齐",
|
|
1446
|
+
alignCenter: "居中",
|
|
1447
|
+
alignRight: "右对齐",
|
|
1448
|
+
alignJustify: "两端对齐"
|
|
1449
|
+
},
|
|
1450
|
+
media: {
|
|
1451
|
+
uploading: "上传中 {{files}} 个 · {{percent}}%",
|
|
1452
|
+
uploadFailed: "{{files}} 个文件上传失败",
|
|
1453
|
+
inlined: "{{files}} 张图片未上传(已随文档保存)",
|
|
1454
|
+
retry: "重试",
|
|
1455
|
+
browse: "选择文件",
|
|
1456
|
+
dropHere: "或将文件拖到这里",
|
|
1457
|
+
urlPlaceholder: "图片地址(https://…)",
|
|
1458
|
+
altPlaceholder: "替代文字",
|
|
1459
|
+
insert: "插入"
|
|
1460
|
+
},
|
|
1461
|
+
ai: {
|
|
1462
|
+
menu: "AI 菜单",
|
|
1463
|
+
continue: "AI 续写",
|
|
1464
|
+
translate: "AI 翻译",
|
|
1465
|
+
optimize: "AI 优化",
|
|
1466
|
+
summarize: "生成摘要",
|
|
1467
|
+
processing: "AI 处理中...",
|
|
1468
|
+
error: "AI 处理出错"
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
const enResources = {
|
|
1472
|
+
editor: {
|
|
1473
|
+
placeholder: "Type something...",
|
|
1474
|
+
bold: "Bold",
|
|
1475
|
+
italic: "Italic",
|
|
1476
|
+
underline: "Underline",
|
|
1477
|
+
strikethrough: "Strikethrough",
|
|
1478
|
+
code: "Inline Code",
|
|
1479
|
+
heading: "Heading",
|
|
1480
|
+
heading1: "Heading 1",
|
|
1481
|
+
heading2: "Heading 2",
|
|
1482
|
+
heading3: "Heading 3",
|
|
1483
|
+
paragraph: "Paragraph",
|
|
1484
|
+
bulletList: "Bullet List",
|
|
1485
|
+
orderedList: "Ordered List",
|
|
1486
|
+
taskList: "Task List",
|
|
1487
|
+
codeBlock: "Code Block",
|
|
1488
|
+
blockquote: "Blockquote",
|
|
1489
|
+
link: "Link",
|
|
1490
|
+
image: "Image",
|
|
1491
|
+
attachment: "Attachment",
|
|
1492
|
+
attachmentDisplay: "Attachment style",
|
|
1493
|
+
delete: "Delete",
|
|
1494
|
+
video: "Video",
|
|
1495
|
+
table: "Table",
|
|
1496
|
+
undo: "Undo",
|
|
1497
|
+
redo: "Redo",
|
|
1498
|
+
alignLeft: "Align Left",
|
|
1499
|
+
alignCenter: "Align Center",
|
|
1500
|
+
alignRight: "Align Right",
|
|
1501
|
+
alignJustify: "Justify"
|
|
1502
|
+
},
|
|
1503
|
+
media: {
|
|
1504
|
+
uploading: "Uploading {{files}} · {{percent}}%",
|
|
1505
|
+
uploadFailed: "{{files}} file(s) failed to upload",
|
|
1506
|
+
inlined: "{{files}} image(s) saved with the document (not uploaded)",
|
|
1507
|
+
retry: "Retry",
|
|
1508
|
+
browse: "Browse",
|
|
1509
|
+
dropHere: "or drop files here",
|
|
1510
|
+
urlPlaceholder: "Image URL (https://…)",
|
|
1511
|
+
altPlaceholder: "Alternative text",
|
|
1512
|
+
insert: "Insert"
|
|
1513
|
+
},
|
|
1514
|
+
ai: {
|
|
1515
|
+
menu: "AI Menu",
|
|
1516
|
+
continue: "AI Continue",
|
|
1517
|
+
translate: "AI Translate",
|
|
1518
|
+
optimize: "AI Optimize",
|
|
1519
|
+
summarize: "AI Summarize",
|
|
1520
|
+
processing: "AI Processing...",
|
|
1521
|
+
error: "AI Error"
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
class I18nManager {
|
|
1525
|
+
constructor(lang = "zh") {
|
|
1526
|
+
this.instance = i18next.createInstance();
|
|
1527
|
+
this.instance.init({
|
|
1528
|
+
lng: lang,
|
|
1529
|
+
fallbackLng: "zh",
|
|
1530
|
+
initImmediate: false,
|
|
1531
|
+
resources: {
|
|
1532
|
+
zh: { translation: zhResources },
|
|
1533
|
+
en: { translation: enResources }
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* 翻译
|
|
1539
|
+
*/
|
|
1540
|
+
t(key, options) {
|
|
1541
|
+
return this.instance.t(key, options);
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* 切换语言
|
|
1545
|
+
*/
|
|
1546
|
+
async changeLanguage(lang) {
|
|
1547
|
+
await this.instance.changeLanguage(lang);
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* 监听语言变更,返回取消订阅的函数
|
|
1551
|
+
*/
|
|
1552
|
+
onLanguageChanged(handler) {
|
|
1553
|
+
this.instance.on("languageChanged", handler);
|
|
1554
|
+
return () => this.instance.off("languageChanged", handler);
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* 获取当前语言
|
|
1558
|
+
*/
|
|
1559
|
+
getLanguage() {
|
|
1560
|
+
return this.instance.language || "zh";
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
class ExtensionManager {
|
|
1564
|
+
constructor() {
|
|
1565
|
+
this.extensions = /* @__PURE__ */ new Map();
|
|
1566
|
+
this.nodeViews = /* @__PURE__ */ new Map();
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* 注册扩展
|
|
1570
|
+
*/
|
|
1571
|
+
register(extension, _meta) {
|
|
1572
|
+
const name = extension.name;
|
|
1573
|
+
if (this.extensions.has(name)) {
|
|
1574
|
+
console.warn(`Extension "${name}" already registered, overwriting.`);
|
|
1575
|
+
}
|
|
1576
|
+
this.extensions.set(name, extension);
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* 批量注册扩展
|
|
1580
|
+
*/
|
|
1581
|
+
registerAll(extensions) {
|
|
1582
|
+
extensions.forEach((ext) => this.register(ext));
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* 注册自定义 NodeView
|
|
1586
|
+
*/
|
|
1587
|
+
registerNodeView(config) {
|
|
1588
|
+
if (this.nodeViews.has(config.name)) {
|
|
1589
|
+
console.warn(`NodeView "${config.name}" already registered, overwriting.`);
|
|
1590
|
+
}
|
|
1591
|
+
this.nodeViews.set(config.name, config);
|
|
1592
|
+
const nodeExtension = Node.create({
|
|
1593
|
+
name: config.name,
|
|
1594
|
+
group: config.group || (config.inline ? "inline" : "block"),
|
|
1595
|
+
atom: config.atom ?? true,
|
|
1596
|
+
addAttributes() {
|
|
1597
|
+
return config.attributes || {};
|
|
1598
|
+
},
|
|
1599
|
+
parseHTML() {
|
|
1600
|
+
var _a;
|
|
1601
|
+
return ((_a = config.parseHTML) == null ? void 0 : _a.call(config)) || [{ tag: config.name }];
|
|
1602
|
+
},
|
|
1603
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
1604
|
+
if (config.renderHTML) {
|
|
1605
|
+
return config.renderHTML({ HTMLAttributes, node });
|
|
1606
|
+
}
|
|
1607
|
+
return [config.name, mergeAttributes(HTMLAttributes)];
|
|
1608
|
+
},
|
|
1609
|
+
addNodeView() {
|
|
1610
|
+
return config.nodeView;
|
|
1611
|
+
},
|
|
1612
|
+
// 由 Tiptap 的 MarkdownManager 按节点名调用,签名见 @tiptap/core NodeConfig.renderMarkdown
|
|
1613
|
+
renderMarkdown: config.markdownSerialize ? (node) => config.markdownSerialize(node) : void 0
|
|
1614
|
+
});
|
|
1615
|
+
this.register(nodeExtension, {
|
|
1616
|
+
name: config.name,
|
|
1617
|
+
version: "1.0.0",
|
|
1618
|
+
category: "custom",
|
|
1619
|
+
description: config.description
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* 批量注册 NodeView
|
|
1624
|
+
*/
|
|
1625
|
+
registerNodeViews(configs) {
|
|
1626
|
+
configs.forEach((config) => this.registerNodeView(config));
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* 获取扩展
|
|
1630
|
+
*/
|
|
1631
|
+
get(name) {
|
|
1632
|
+
return this.extensions.get(name);
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* 卸载扩展
|
|
1636
|
+
*/
|
|
1637
|
+
unregister(name) {
|
|
1638
|
+
this.nodeViews.delete(name);
|
|
1639
|
+
return this.extensions.delete(name);
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* 获取所有扩展
|
|
1643
|
+
*/
|
|
1644
|
+
getAll() {
|
|
1645
|
+
return Array.from(this.extensions.values());
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* 获取所有已注册的 NodeView
|
|
1649
|
+
*/
|
|
1650
|
+
getNodeViews() {
|
|
1651
|
+
return this.nodeViews;
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* 是否已注册
|
|
1655
|
+
*/
|
|
1656
|
+
has(name) {
|
|
1657
|
+
return this.extensions.has(name);
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* 清空所有扩展
|
|
1661
|
+
*/
|
|
1662
|
+
clear() {
|
|
1663
|
+
this.extensions.clear();
|
|
1664
|
+
this.nodeViews.clear();
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
const FIELDS = {
|
|
1668
|
+
image: { url: true, alt: true, kind: "image" },
|
|
1669
|
+
attachment: { url: false, alt: false, kind: "attachment" }
|
|
1670
|
+
};
|
|
1671
|
+
function filesOf(transfer) {
|
|
1672
|
+
if (!transfer) return [];
|
|
1673
|
+
return Array.from(transfer.files);
|
|
1674
|
+
}
|
|
1675
|
+
class InsertPanel {
|
|
1676
|
+
constructor(options) {
|
|
1677
|
+
var _a;
|
|
1678
|
+
this.element = null;
|
|
1679
|
+
this.mode = null;
|
|
1680
|
+
this.stopAutoUpdate = null;
|
|
1681
|
+
this.detachDismiss = null;
|
|
1682
|
+
this.labelled = [];
|
|
1683
|
+
this.editor = options.editor;
|
|
1684
|
+
this.runtime = options.runtime;
|
|
1685
|
+
this.i18n = options.i18n;
|
|
1686
|
+
this.onOpenChange = options.onOpenChange;
|
|
1687
|
+
this.unsubscribeLanguage = (_a = this.i18n) == null ? void 0 : _a.onLanguageChanged(() => this.applyLabels());
|
|
1688
|
+
}
|
|
1689
|
+
get openMode() {
|
|
1690
|
+
return this.mode;
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* 同一个按钮再点一次是关闭;换按钮则直接把浮层挪过去
|
|
1694
|
+
*/
|
|
1695
|
+
toggle(anchor, mode) {
|
|
1696
|
+
var _a;
|
|
1697
|
+
if (this.mode === mode) {
|
|
1698
|
+
this.close();
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
this.close();
|
|
1702
|
+
this.mode = mode;
|
|
1703
|
+
this.build(mode);
|
|
1704
|
+
this.position(anchor);
|
|
1705
|
+
this.listenForDismiss(anchor);
|
|
1706
|
+
(_a = this.onOpenChange) == null ? void 0 : _a.call(this, mode);
|
|
1707
|
+
}
|
|
1708
|
+
close() {
|
|
1709
|
+
var _a, _b, _c, _d;
|
|
1710
|
+
(_a = this.detachDismiss) == null ? void 0 : _a.call(this);
|
|
1711
|
+
this.detachDismiss = null;
|
|
1712
|
+
(_b = this.stopAutoUpdate) == null ? void 0 : _b.call(this);
|
|
1713
|
+
this.stopAutoUpdate = null;
|
|
1714
|
+
(_c = this.element) == null ? void 0 : _c.remove();
|
|
1715
|
+
this.element = null;
|
|
1716
|
+
this.labelled = [];
|
|
1717
|
+
if (this.mode === null) return;
|
|
1718
|
+
this.mode = null;
|
|
1719
|
+
(_d = this.onOpenChange) == null ? void 0 : _d.call(this, null);
|
|
1720
|
+
}
|
|
1721
|
+
destroy() {
|
|
1722
|
+
var _a;
|
|
1723
|
+
this.close();
|
|
1724
|
+
(_a = this.unsubscribeLanguage) == null ? void 0 : _a.call(this);
|
|
1725
|
+
this.unsubscribeLanguage = void 0;
|
|
1726
|
+
}
|
|
1727
|
+
t(key, fallback) {
|
|
1728
|
+
var _a;
|
|
1729
|
+
const translated = (_a = this.i18n) == null ? void 0 : _a.t(key);
|
|
1730
|
+
return translated && translated !== key ? translated : fallback;
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* 登记一处随语言重画的文案:input 画到 placeholder,其他画成文字
|
|
1734
|
+
*/
|
|
1735
|
+
label(el, key, fallback) {
|
|
1736
|
+
this.labelled.push({ el, key, fallback });
|
|
1737
|
+
this.applyLabel({ el, key, fallback });
|
|
1738
|
+
}
|
|
1739
|
+
applyLabels() {
|
|
1740
|
+
this.labelled.forEach((entry) => this.applyLabel(entry));
|
|
1741
|
+
}
|
|
1742
|
+
applyLabel({ el, key, fallback }) {
|
|
1743
|
+
const text = this.t(key, fallback);
|
|
1744
|
+
if (el instanceof HTMLInputElement) el.placeholder = text;
|
|
1745
|
+
else el.textContent = text;
|
|
1746
|
+
}
|
|
1747
|
+
build(mode) {
|
|
1748
|
+
const fields = FIELDS[mode];
|
|
1749
|
+
const panel = document.createElement("div");
|
|
1750
|
+
panel.className = "atri-media-panel";
|
|
1751
|
+
panel.setAttribute("role", "dialog");
|
|
1752
|
+
panel.setAttribute(
|
|
1753
|
+
"aria-label",
|
|
1754
|
+
this.t(`editor.${mode === "image" ? "image" : "attachment"}`, "插入")
|
|
1755
|
+
);
|
|
1756
|
+
let urlInput = null;
|
|
1757
|
+
let altInput = null;
|
|
1758
|
+
if (fields.url) {
|
|
1759
|
+
urlInput = document.createElement("input");
|
|
1760
|
+
urlInput.type = "url";
|
|
1761
|
+
urlInput.className = "atri-media-panel-url";
|
|
1762
|
+
this.label(urlInput, "media.urlPlaceholder", "图片地址(https://…)");
|
|
1763
|
+
urlInput.addEventListener("keydown", (event) => {
|
|
1764
|
+
if (event.key === "Enter") {
|
|
1765
|
+
event.preventDefault();
|
|
1766
|
+
this.insertFromUrl(urlInput, altInput);
|
|
1767
|
+
}
|
|
1768
|
+
});
|
|
1769
|
+
panel.appendChild(urlInput);
|
|
1770
|
+
}
|
|
1771
|
+
if (fields.alt) {
|
|
1772
|
+
altInput = document.createElement("input");
|
|
1773
|
+
altInput.type = "text";
|
|
1774
|
+
altInput.className = "atri-media-panel-alt";
|
|
1775
|
+
this.label(altInput, "media.altPlaceholder", "替代文字");
|
|
1776
|
+
panel.appendChild(altInput);
|
|
1777
|
+
}
|
|
1778
|
+
const fileInput = document.createElement("input");
|
|
1779
|
+
fileInput.type = "file";
|
|
1780
|
+
fileInput.className = "atri-media-panel-file";
|
|
1781
|
+
fileInput.hidden = true;
|
|
1782
|
+
fileInput.multiple = true;
|
|
1783
|
+
const accept = this.runtime.acceptFor(fields.kind);
|
|
1784
|
+
if (accept) fileInput.accept = accept;
|
|
1785
|
+
fileInput.addEventListener("change", () => {
|
|
1786
|
+
this.upload(Array.from(fileInput.files ?? []), fields.kind);
|
|
1787
|
+
fileInput.value = "";
|
|
1788
|
+
});
|
|
1789
|
+
panel.appendChild(fileInput);
|
|
1790
|
+
const drop = document.createElement("div");
|
|
1791
|
+
drop.className = "atri-media-panel-drop";
|
|
1792
|
+
this.label(drop, "media.dropHere", "或将文件拖到这里");
|
|
1793
|
+
drop.addEventListener("dragover", (event) => event.preventDefault());
|
|
1794
|
+
drop.addEventListener("drop", (event) => {
|
|
1795
|
+
event.preventDefault();
|
|
1796
|
+
this.upload(filesOf(event.dataTransfer), fields.kind);
|
|
1797
|
+
});
|
|
1798
|
+
const browse = document.createElement("button");
|
|
1799
|
+
browse.type = "button";
|
|
1800
|
+
browse.className = "atri-media-panel-browse";
|
|
1801
|
+
this.label(browse, "media.browse", "选择文件");
|
|
1802
|
+
browse.addEventListener("click", () => fileInput.click());
|
|
1803
|
+
drop.appendChild(browse);
|
|
1804
|
+
panel.appendChild(drop);
|
|
1805
|
+
let insert = null;
|
|
1806
|
+
if (fields.url) {
|
|
1807
|
+
insert = document.createElement("button");
|
|
1808
|
+
insert.type = "button";
|
|
1809
|
+
insert.className = "atri-media-panel-insert";
|
|
1810
|
+
this.label(insert, "media.insert", "插入");
|
|
1811
|
+
insert.addEventListener("click", () => this.insertFromUrl(urlInput, altInput));
|
|
1812
|
+
panel.appendChild(insert);
|
|
1813
|
+
}
|
|
1814
|
+
document.body.appendChild(panel);
|
|
1815
|
+
this.element = panel;
|
|
1816
|
+
this.applyTheme(panel);
|
|
1817
|
+
(urlInput ?? altInput ?? browse).focus();
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* 主题变量写在 .atri-editor 上,浮层挂在 body 上够不着,只能把主题类复制一份
|
|
1821
|
+
*/
|
|
1822
|
+
applyTheme(panel) {
|
|
1823
|
+
const root = this.editor.view.dom.closest(".atri-editor");
|
|
1824
|
+
const themes = Array.from((root == null ? void 0 : root.classList) ?? []).filter(
|
|
1825
|
+
(name) => name.startsWith("atri-theme-")
|
|
1826
|
+
);
|
|
1827
|
+
panel.classList.add(...themes);
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* 外链图片当场就插,不进上传队列:地址本来就是可用的
|
|
1831
|
+
*/
|
|
1832
|
+
insertFromUrl(urlInput, altInput) {
|
|
1833
|
+
const src = urlInput == null ? void 0 : urlInput.value.trim();
|
|
1834
|
+
if (!src) return;
|
|
1835
|
+
this.editor.chain().focus().setImage({ src, alt: (altInput == null ? void 0 : altInput.value.trim()) || void 0 }).run();
|
|
1836
|
+
this.close();
|
|
1837
|
+
}
|
|
1838
|
+
upload(files, kind) {
|
|
1839
|
+
if (!files.length) return;
|
|
1840
|
+
void this.runtime.handleFiles(files, { kind });
|
|
1841
|
+
this.close();
|
|
1842
|
+
}
|
|
1843
|
+
async position(anchor) {
|
|
1844
|
+
const panel = this.element;
|
|
1845
|
+
if (!panel) return;
|
|
1846
|
+
const update = async () => {
|
|
1847
|
+
if (this.element !== panel) return;
|
|
1848
|
+
try {
|
|
1849
|
+
const { x, y } = await computePosition(anchor, panel, {
|
|
1850
|
+
placement: "bottom-start",
|
|
1851
|
+
middleware: [offset(6), flip(), shift({ padding: 8 })]
|
|
1852
|
+
});
|
|
1853
|
+
if (this.element !== panel) return;
|
|
1854
|
+
panel.style.position = "fixed";
|
|
1855
|
+
panel.style.left = `${x}px`;
|
|
1856
|
+
panel.style.top = `${y}px`;
|
|
1857
|
+
} catch {
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
await update();
|
|
1861
|
+
if (this.element === panel) {
|
|
1862
|
+
this.stopAutoUpdate = autoUpdate(anchor, panel, () => void update());
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* 点外面与 Escape 关闭;锚点按钮交给 toggle,否则关了又立刻被这次点击重开
|
|
1867
|
+
*/
|
|
1868
|
+
listenForDismiss(anchor) {
|
|
1869
|
+
const onPointerDown = (event) => {
|
|
1870
|
+
var _a;
|
|
1871
|
+
const target = event.target;
|
|
1872
|
+
if (!target || ((_a = this.element) == null ? void 0 : _a.contains(target)) || anchor.contains(target)) return;
|
|
1873
|
+
this.close();
|
|
1874
|
+
};
|
|
1875
|
+
const onKeyDown = (event) => {
|
|
1876
|
+
if (event.key !== "Escape") return;
|
|
1877
|
+
event.preventDefault();
|
|
1878
|
+
event.stopPropagation();
|
|
1879
|
+
this.close();
|
|
1880
|
+
};
|
|
1881
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
1882
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
1883
|
+
this.detachDismiss = () => {
|
|
1884
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
1885
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
const icons = {
|
|
1890
|
+
undo: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>',
|
|
1891
|
+
redo: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/></svg>',
|
|
1892
|
+
heading1: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17 12l3-2v8"/></svg>',
|
|
1893
|
+
heading2: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>',
|
|
1894
|
+
heading3: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2"/><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2"/></svg>',
|
|
1895
|
+
paragraph: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 4v16"/><path d="M17 4v16"/><path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13"/></svg>',
|
|
1896
|
+
bold: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></svg>',
|
|
1897
|
+
italic: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>',
|
|
1898
|
+
underline: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>',
|
|
1899
|
+
strikethrough: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>',
|
|
1900
|
+
code: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
|
|
1901
|
+
list: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>',
|
|
1902
|
+
listOrdered: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>',
|
|
1903
|
+
quote: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/></svg>',
|
|
1904
|
+
codeBlock: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
|
|
1905
|
+
alignLeft: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="21" x2="3" y1="6" y2="6"/><line x1="15" x2="3" y1="12" y2="12"/><line x1="17" x2="3" y1="18" y2="18"/></svg>',
|
|
1906
|
+
alignCenter: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="21" x2="3" y1="6" y2="6"/><line x1="17" x2="7" y1="12" y2="12"/><line x1="19" x2="5" y1="18" y2="18"/></svg>',
|
|
1907
|
+
alignRight: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="21" x2="3" y1="6" y2="6"/><line x1="21" x2="9" y1="12" y2="12"/><line x1="21" x2="7" y1="18" y2="18"/></svg>',
|
|
1908
|
+
image: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></svg>',
|
|
1909
|
+
paperclip: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 6-8.4 8.6a2 2 0 0 0 2.8 2.8l8.4-8.6a4 4 0 1 0-5.6-5.6l-8.4 8.5a6 6 0 1 0 8.5 8.5l8.4-8.4"/></svg>',
|
|
1910
|
+
link: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7A5 5 0 1 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/></svg>',
|
|
1911
|
+
trash: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>'
|
|
1912
|
+
};
|
|
1913
|
+
const TOOLTIP_KEYS = {
|
|
1914
|
+
undo: "editor.undo",
|
|
1915
|
+
redo: "editor.redo",
|
|
1916
|
+
heading1: "editor.heading1",
|
|
1917
|
+
heading2: "editor.heading2",
|
|
1918
|
+
heading3: "editor.heading3",
|
|
1919
|
+
paragraph: "editor.paragraph",
|
|
1920
|
+
bold: "editor.bold",
|
|
1921
|
+
italic: "editor.italic",
|
|
1922
|
+
underline: "editor.underline",
|
|
1923
|
+
strike: "editor.strikethrough",
|
|
1924
|
+
code: "editor.code",
|
|
1925
|
+
bulletList: "editor.bulletList",
|
|
1926
|
+
orderedList: "editor.orderedList",
|
|
1927
|
+
blockquote: "editor.blockquote",
|
|
1928
|
+
codeBlock: "editor.codeBlock",
|
|
1929
|
+
alignLeft: "editor.alignLeft",
|
|
1930
|
+
alignCenter: "editor.alignCenter",
|
|
1931
|
+
alignRight: "editor.alignRight",
|
|
1932
|
+
insertImage: "editor.image",
|
|
1933
|
+
insertAttachment: "editor.attachment",
|
|
1934
|
+
attachmentDisplay: "editor.attachmentDisplay",
|
|
1935
|
+
delete: "editor.delete"
|
|
1936
|
+
};
|
|
1937
|
+
class ToolbarManager {
|
|
1938
|
+
constructor(editor, container, config, i18n, mediaRuntime) {
|
|
1939
|
+
var _a;
|
|
1940
|
+
this.buttons = /* @__PURE__ */ new Map();
|
|
1941
|
+
this.customTooltips = /* @__PURE__ */ new Map();
|
|
1942
|
+
this.createdElements = [];
|
|
1943
|
+
this.panel = null;
|
|
1944
|
+
this.editor = editor;
|
|
1945
|
+
this.container = container;
|
|
1946
|
+
this.config = config;
|
|
1947
|
+
this.i18n = i18n;
|
|
1948
|
+
this.mediaRuntime = mediaRuntime ?? null;
|
|
1949
|
+
this.itemDefs = this.getDefaultItems();
|
|
1950
|
+
this.createToolbarDOM();
|
|
1951
|
+
this.bindEditorEvents();
|
|
1952
|
+
this.unsubscribeLanguage = (_a = this.i18n) == null ? void 0 : _a.onLanguageChanged(() => this.applyTooltips());
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* 把浮动工具栏的按钮渲染进给定的元素
|
|
1956
|
+
*
|
|
1957
|
+
* 元素由门面创建并持有:BubbleMenu 每次隐藏都会把它从文档里摘掉,重建编辑器后
|
|
1958
|
+
* 还得拿同一个元素重新 attach,所以它不进 createdElements、也不在这里 remove
|
|
1959
|
+
*/
|
|
1960
|
+
attachBubbleToolbar(element) {
|
|
1961
|
+
element.replaceChildren();
|
|
1962
|
+
const groups = [
|
|
1963
|
+
["text", BUBBLE_TEXT_ITEMS],
|
|
1964
|
+
["node", BUBBLE_NODE_ITEMS]
|
|
1965
|
+
];
|
|
1966
|
+
groups.forEach(([groupId, itemIds]) => {
|
|
1967
|
+
const group = document.createElement("div");
|
|
1968
|
+
group.className = "atri-editor-bubble-group";
|
|
1969
|
+
group.setAttribute("data-atri-bubble-group", groupId);
|
|
1970
|
+
itemIds.forEach((itemId) => {
|
|
1971
|
+
const itemDef = this.itemDefs.get(itemId);
|
|
1972
|
+
if (!itemDef) return;
|
|
1973
|
+
if (itemDef.popup) {
|
|
1974
|
+
console.warn(
|
|
1975
|
+
`[Atri Editor] Toolbar item "${itemId}" opens a panel, which bubble toolbar cannot host.`
|
|
1976
|
+
);
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
group.appendChild(this.createButton(itemId, itemDef));
|
|
1980
|
+
});
|
|
1981
|
+
element.appendChild(group);
|
|
1982
|
+
});
|
|
1983
|
+
this.applyTooltips();
|
|
1984
|
+
this.updateButtonStates();
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* 插入浮层按需创建:没点开过就不该有一个实例挂在编辑器上
|
|
1988
|
+
*/
|
|
1989
|
+
get insertPanel() {
|
|
1990
|
+
if (!this.mediaRuntime) return null;
|
|
1991
|
+
this.panel ?? (this.panel = new InsertPanel({
|
|
1992
|
+
editor: this.editor,
|
|
1993
|
+
runtime: this.mediaRuntime,
|
|
1994
|
+
i18n: this.i18n,
|
|
1995
|
+
// 开合不改文档,等不到 transaction,得主动刷一次按钮状态
|
|
1996
|
+
onOpenChange: () => this.updateButtonStates()
|
|
1997
|
+
}));
|
|
1998
|
+
return this.panel;
|
|
1999
|
+
}
|
|
2000
|
+
openInsert(mode, button) {
|
|
2001
|
+
var _a;
|
|
2002
|
+
(_a = this.insertPanel) == null ? void 0 : _a.toggle(button, mode);
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* 创建工具栏 DOM - 直接将按钮插入 container
|
|
2006
|
+
*/
|
|
2007
|
+
createToolbarDOM() {
|
|
2008
|
+
var _a;
|
|
2009
|
+
const items = (_a = this.config) == null ? void 0 : _a.items;
|
|
2010
|
+
if (items) {
|
|
2011
|
+
items.forEach((item) => {
|
|
2012
|
+
const resolved = this.resolveItem(item);
|
|
2013
|
+
if (!resolved) return;
|
|
2014
|
+
const button = this.createButton(resolved.id, resolved.def, resolved.overrides);
|
|
2015
|
+
this.container.appendChild(button);
|
|
2016
|
+
this.createdElements.push(button);
|
|
2017
|
+
});
|
|
2018
|
+
this.applyTooltips();
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
const layout = [
|
|
2022
|
+
["undo", "redo"],
|
|
2023
|
+
["heading1", "heading2", "heading3", "paragraph"],
|
|
2024
|
+
["bold", "italic", "underline", "strike", "code"],
|
|
2025
|
+
["bulletList", "orderedList", "blockquote", "codeBlock"],
|
|
2026
|
+
["alignLeft", "alignCenter", "alignRight"],
|
|
2027
|
+
// 媒体节点没注册时这两项压根不存在,默认布局里也不摆空位
|
|
2028
|
+
...this.itemDefs.has("insertImage") ? [["insertImage", "insertAttachment", "attachmentDisplay"]] : []
|
|
2029
|
+
];
|
|
2030
|
+
layout.forEach((group, groupIndex) => {
|
|
2031
|
+
if (groupIndex > 0) {
|
|
2032
|
+
const separator = document.createElement("div");
|
|
2033
|
+
separator.className = "atri-editor-toolbar-separator";
|
|
2034
|
+
this.container.appendChild(separator);
|
|
2035
|
+
this.createdElements.push(separator);
|
|
2036
|
+
}
|
|
2037
|
+
group.forEach((itemId) => {
|
|
2038
|
+
const itemDef = this.itemDefs.get(itemId);
|
|
2039
|
+
if (!itemDef) return;
|
|
2040
|
+
const button = this.createButton(itemId, itemDef);
|
|
2041
|
+
this.container.appendChild(button);
|
|
2042
|
+
this.createdElements.push(button);
|
|
2043
|
+
});
|
|
2044
|
+
});
|
|
2045
|
+
this.applyTooltips();
|
|
2046
|
+
}
|
|
2047
|
+
/**
|
|
2048
|
+
* 解析配置项:字符串按 id 取内置定义,ToolbarItem 允许覆盖展示文案
|
|
2049
|
+
*/
|
|
2050
|
+
resolveItem(item) {
|
|
2051
|
+
var _a;
|
|
2052
|
+
const id = typeof item === "string" ? item : item.id;
|
|
2053
|
+
const def = this.itemDefs.get(id);
|
|
2054
|
+
if (!def) {
|
|
2055
|
+
console.warn(`[Atri Editor] Unknown toolbar item "${id}" was skipped.`);
|
|
2056
|
+
return null;
|
|
2057
|
+
}
|
|
2058
|
+
if (typeof item === "string") {
|
|
2059
|
+
return { id, def };
|
|
2060
|
+
}
|
|
2061
|
+
if ((_a = item.children) == null ? void 0 : _a.length) {
|
|
2062
|
+
console.warn(`[Atri Editor] Toolbar item "${id}" declares children, which is not supported.`);
|
|
2063
|
+
}
|
|
2064
|
+
return { id, def, overrides: item };
|
|
2065
|
+
}
|
|
2066
|
+
createButton(itemId, itemDef, overrides) {
|
|
2067
|
+
const button = document.createElement("button");
|
|
2068
|
+
button.type = "button";
|
|
2069
|
+
button.className = "atri-editor-toolbar-btn";
|
|
2070
|
+
button.setAttribute("data-toolbar-item", itemId);
|
|
2071
|
+
if (overrides == null ? void 0 : overrides.icon) {
|
|
2072
|
+
button.innerHTML = overrides.icon;
|
|
2073
|
+
} else if (overrides == null ? void 0 : overrides.label) {
|
|
2074
|
+
button.textContent = overrides.label;
|
|
2075
|
+
} else {
|
|
2076
|
+
button.innerHTML = itemDef.icon;
|
|
2077
|
+
}
|
|
2078
|
+
const svg = button.querySelector("svg");
|
|
2079
|
+
if (svg) {
|
|
2080
|
+
svg.setAttribute("width", "18");
|
|
2081
|
+
svg.setAttribute("height", "18");
|
|
2082
|
+
}
|
|
2083
|
+
if (overrides == null ? void 0 : overrides.tooltip) {
|
|
2084
|
+
this.customTooltips.set(itemId, overrides.tooltip);
|
|
2085
|
+
}
|
|
2086
|
+
if (itemDef.popup) {
|
|
2087
|
+
button.setAttribute("aria-haspopup", "dialog");
|
|
2088
|
+
button.setAttribute("aria-expanded", "false");
|
|
2089
|
+
}
|
|
2090
|
+
button.addEventListener("click", (e) => {
|
|
2091
|
+
e.preventDefault();
|
|
2092
|
+
e.stopPropagation();
|
|
2093
|
+
itemDef.command(this.editor, button);
|
|
2094
|
+
if (!itemDef.popup) this.editor.commands.focus();
|
|
2095
|
+
});
|
|
2096
|
+
let group = this.buttons.get(itemId);
|
|
2097
|
+
if (!group) {
|
|
2098
|
+
group = /* @__PURE__ */ new Set();
|
|
2099
|
+
this.buttons.set(itemId, group);
|
|
2100
|
+
}
|
|
2101
|
+
group.add(button);
|
|
2102
|
+
return button;
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* 按当前语言刷新按钮 tooltip
|
|
2106
|
+
*/
|
|
2107
|
+
applyTooltips() {
|
|
2108
|
+
this.buttons.forEach((buttons, itemId) => {
|
|
2109
|
+
var _a;
|
|
2110
|
+
const itemDef = this.itemDefs.get(itemId);
|
|
2111
|
+
if (!itemDef) return;
|
|
2112
|
+
const custom = this.customTooltips.get(itemId);
|
|
2113
|
+
const key = TOOLTIP_KEYS[itemId];
|
|
2114
|
+
const translated = key ? (_a = this.i18n) == null ? void 0 : _a.t(key) : void 0;
|
|
2115
|
+
const title = custom ?? (translated && translated !== key ? translated : itemDef.tooltip);
|
|
2116
|
+
buttons.forEach((button) => {
|
|
2117
|
+
button.title = title;
|
|
2118
|
+
});
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* 获取默认工具栏项定义
|
|
2123
|
+
*/
|
|
2124
|
+
getDefaultItems() {
|
|
2125
|
+
const items = /* @__PURE__ */ new Map();
|
|
2126
|
+
if (this.mediaRuntime) {
|
|
2127
|
+
items.set("insertImage", {
|
|
2128
|
+
id: "insertImage",
|
|
2129
|
+
icon: icons.image,
|
|
2130
|
+
tooltip: "图片",
|
|
2131
|
+
popup: true,
|
|
2132
|
+
command: (_editor, button) => this.openInsert("image", button),
|
|
2133
|
+
isActive: () => {
|
|
2134
|
+
var _a;
|
|
2135
|
+
return ((_a = this.panel) == null ? void 0 : _a.openMode) === "image";
|
|
2136
|
+
},
|
|
2137
|
+
isDisabled: (editor) => !editor.isEditable
|
|
2138
|
+
});
|
|
2139
|
+
items.set("insertAttachment", {
|
|
2140
|
+
id: "insertAttachment",
|
|
2141
|
+
icon: icons.paperclip,
|
|
2142
|
+
tooltip: "附件",
|
|
2143
|
+
popup: true,
|
|
2144
|
+
command: (_editor, button) => this.openInsert("attachment", button),
|
|
2145
|
+
isActive: () => {
|
|
2146
|
+
var _a;
|
|
2147
|
+
return ((_a = this.panel) == null ? void 0 : _a.openMode) === "attachment";
|
|
2148
|
+
},
|
|
2149
|
+
isDisabled: (editor) => !editor.isEditable
|
|
2150
|
+
});
|
|
2151
|
+
items.set("attachmentDisplay", {
|
|
2152
|
+
id: "attachmentDisplay",
|
|
2153
|
+
icon: icons.link,
|
|
2154
|
+
tooltip: "附件样式",
|
|
2155
|
+
command: (editor) => editor.chain().focus().toggleAttachmentDisplay().run(),
|
|
2156
|
+
isActive: (editor) => editor.isActive("attachment") || editor.isActive("attachmentLink"),
|
|
2157
|
+
isDisabled: (editor) => !editor.isActive("attachment") && !editor.isActive("attachmentLink")
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
items.set("delete", {
|
|
2161
|
+
id: "delete",
|
|
2162
|
+
icon: icons.trash,
|
|
2163
|
+
tooltip: "删除",
|
|
2164
|
+
command: (editor) => editor.chain().deleteSelection().run(),
|
|
2165
|
+
isActive: () => false,
|
|
2166
|
+
isDisabled: (editor) => !isNodeSelection(editor.state.selection)
|
|
2167
|
+
});
|
|
2168
|
+
items.set("undo", {
|
|
2169
|
+
id: "undo",
|
|
2170
|
+
icon: icons.undo,
|
|
2171
|
+
tooltip: "撤销",
|
|
2172
|
+
command: (editor) => editor.chain().undo().run(),
|
|
2173
|
+
isActive: () => false,
|
|
2174
|
+
isDisabled: (editor) => !editor.can().undo()
|
|
2175
|
+
});
|
|
2176
|
+
items.set("redo", {
|
|
2177
|
+
id: "redo",
|
|
2178
|
+
icon: icons.redo,
|
|
2179
|
+
tooltip: "重做",
|
|
2180
|
+
command: (editor) => editor.chain().redo().run(),
|
|
2181
|
+
isActive: () => false,
|
|
2182
|
+
isDisabled: (editor) => !editor.can().redo()
|
|
2183
|
+
});
|
|
2184
|
+
items.set("heading1", {
|
|
2185
|
+
id: "heading1",
|
|
2186
|
+
icon: icons.heading1,
|
|
2187
|
+
tooltip: "标题 1",
|
|
2188
|
+
command: (editor) => editor.chain().toggleHeading({ level: 1 }).run(),
|
|
2189
|
+
isActive: (editor) => editor.isActive("heading", { level: 1 }),
|
|
2190
|
+
isDisabled: (editor) => !editor.can().toggleHeading({ level: 1 })
|
|
2191
|
+
});
|
|
2192
|
+
items.set("heading2", {
|
|
2193
|
+
id: "heading2",
|
|
2194
|
+
icon: icons.heading2,
|
|
2195
|
+
tooltip: "标题 2",
|
|
2196
|
+
command: (editor) => editor.chain().toggleHeading({ level: 2 }).run(),
|
|
2197
|
+
isActive: (editor) => editor.isActive("heading", { level: 2 }),
|
|
2198
|
+
isDisabled: (editor) => !editor.can().toggleHeading({ level: 2 })
|
|
2199
|
+
});
|
|
2200
|
+
items.set("heading3", {
|
|
2201
|
+
id: "heading3",
|
|
2202
|
+
icon: icons.heading3,
|
|
2203
|
+
tooltip: "标题 3",
|
|
2204
|
+
command: (editor) => editor.chain().toggleHeading({ level: 3 }).run(),
|
|
2205
|
+
isActive: (editor) => editor.isActive("heading", { level: 3 }),
|
|
2206
|
+
isDisabled: (editor) => !editor.can().toggleHeading({ level: 3 })
|
|
2207
|
+
});
|
|
2208
|
+
items.set("paragraph", {
|
|
2209
|
+
id: "paragraph",
|
|
2210
|
+
icon: icons.paragraph,
|
|
2211
|
+
tooltip: "正文",
|
|
2212
|
+
command: (editor) => editor.chain().setParagraph().run(),
|
|
2213
|
+
isActive: (editor) => editor.isActive("paragraph"),
|
|
2214
|
+
isDisabled: (editor) => !editor.can().setParagraph()
|
|
2215
|
+
});
|
|
2216
|
+
items.set("bold", {
|
|
2217
|
+
id: "bold",
|
|
2218
|
+
icon: icons.bold,
|
|
2219
|
+
tooltip: "加粗",
|
|
2220
|
+
command: (editor) => editor.chain().toggleBold().run(),
|
|
2221
|
+
isActive: (editor) => editor.isActive("bold"),
|
|
2222
|
+
isDisabled: (editor) => !editor.can().toggleBold()
|
|
2223
|
+
});
|
|
2224
|
+
items.set("italic", {
|
|
2225
|
+
id: "italic",
|
|
2226
|
+
icon: icons.italic,
|
|
2227
|
+
tooltip: "斜体",
|
|
2228
|
+
command: (editor) => editor.chain().toggleItalic().run(),
|
|
2229
|
+
isActive: (editor) => editor.isActive("italic"),
|
|
2230
|
+
isDisabled: (editor) => !editor.can().toggleItalic()
|
|
2231
|
+
});
|
|
2232
|
+
items.set("underline", {
|
|
2233
|
+
id: "underline",
|
|
2234
|
+
icon: icons.underline,
|
|
2235
|
+
tooltip: "下划线",
|
|
2236
|
+
command: (editor) => editor.chain().toggleUnderline().run(),
|
|
2237
|
+
isActive: (editor) => editor.isActive("underline"),
|
|
2238
|
+
isDisabled: (editor) => !editor.can().toggleUnderline()
|
|
2239
|
+
});
|
|
2240
|
+
items.set("strike", {
|
|
2241
|
+
id: "strike",
|
|
2242
|
+
icon: icons.strikethrough,
|
|
2243
|
+
tooltip: "删除线",
|
|
2244
|
+
command: (editor) => editor.chain().toggleStrike().run(),
|
|
2245
|
+
isActive: (editor) => editor.isActive("strike"),
|
|
2246
|
+
isDisabled: (editor) => !editor.can().toggleStrike()
|
|
2247
|
+
});
|
|
2248
|
+
items.set("code", {
|
|
2249
|
+
id: "code",
|
|
2250
|
+
icon: icons.code,
|
|
2251
|
+
tooltip: "行内代码",
|
|
2252
|
+
command: (editor) => editor.chain().toggleCode().run(),
|
|
2253
|
+
isActive: (editor) => editor.isActive("code"),
|
|
2254
|
+
isDisabled: (editor) => !editor.can().toggleCode()
|
|
2255
|
+
});
|
|
2256
|
+
items.set("bulletList", {
|
|
2257
|
+
id: "bulletList",
|
|
2258
|
+
icon: icons.list,
|
|
2259
|
+
tooltip: "无序列表",
|
|
2260
|
+
command: (editor) => editor.chain().toggleBulletList().run(),
|
|
2261
|
+
isActive: (editor) => editor.isActive("bulletList"),
|
|
2262
|
+
isDisabled: (editor) => !editor.can().toggleBulletList()
|
|
2263
|
+
});
|
|
2264
|
+
items.set("orderedList", {
|
|
2265
|
+
id: "orderedList",
|
|
2266
|
+
icon: icons.listOrdered,
|
|
2267
|
+
tooltip: "有序列表",
|
|
2268
|
+
command: (editor) => editor.chain().toggleOrderedList().run(),
|
|
2269
|
+
isActive: (editor) => editor.isActive("orderedList"),
|
|
2270
|
+
isDisabled: (editor) => !editor.can().toggleOrderedList()
|
|
2271
|
+
});
|
|
2272
|
+
items.set("blockquote", {
|
|
2273
|
+
id: "blockquote",
|
|
2274
|
+
icon: icons.quote,
|
|
2275
|
+
tooltip: "引用",
|
|
2276
|
+
command: (editor) => editor.chain().toggleBlockquote().run(),
|
|
2277
|
+
isActive: (editor) => editor.isActive("blockquote"),
|
|
2278
|
+
isDisabled: (editor) => !editor.can().toggleBlockquote()
|
|
2279
|
+
});
|
|
2280
|
+
items.set("codeBlock", {
|
|
2281
|
+
id: "codeBlock",
|
|
2282
|
+
icon: icons.codeBlock,
|
|
2283
|
+
tooltip: "代码块",
|
|
2284
|
+
command: (editor) => editor.chain().toggleCodeBlock().run(),
|
|
2285
|
+
isActive: (editor) => editor.isActive("codeBlock"),
|
|
2286
|
+
isDisabled: (editor) => !editor.can().toggleCodeBlock()
|
|
2287
|
+
});
|
|
2288
|
+
items.set("alignLeft", {
|
|
2289
|
+
id: "alignLeft",
|
|
2290
|
+
icon: icons.alignLeft,
|
|
2291
|
+
tooltip: "左对齐",
|
|
2292
|
+
command: (editor) => editor.chain().setTextAlign("left").run(),
|
|
2293
|
+
isActive: (editor) => editor.isActive({ textAlign: "left" }),
|
|
2294
|
+
isDisabled: (editor) => !editor.can().setTextAlign("left")
|
|
2295
|
+
});
|
|
2296
|
+
items.set("alignCenter", {
|
|
2297
|
+
id: "alignCenter",
|
|
2298
|
+
icon: icons.alignCenter,
|
|
2299
|
+
tooltip: "居中",
|
|
2300
|
+
command: (editor) => editor.chain().setTextAlign("center").run(),
|
|
2301
|
+
isActive: (editor) => editor.isActive({ textAlign: "center" }),
|
|
2302
|
+
isDisabled: (editor) => !editor.can().setTextAlign("center")
|
|
2303
|
+
});
|
|
2304
|
+
items.set("alignRight", {
|
|
2305
|
+
id: "alignRight",
|
|
2306
|
+
icon: icons.alignRight,
|
|
2307
|
+
tooltip: "右对齐",
|
|
2308
|
+
command: (editor) => editor.chain().setTextAlign("right").run(),
|
|
2309
|
+
isActive: (editor) => editor.isActive({ textAlign: "right" }),
|
|
2310
|
+
isDisabled: (editor) => !editor.can().setTextAlign("right")
|
|
2311
|
+
});
|
|
2312
|
+
return items;
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* 绑定编辑器事件,更新按钮状态
|
|
2316
|
+
*/
|
|
2317
|
+
bindEditorEvents() {
|
|
2318
|
+
this.editor.on("transaction", () => {
|
|
2319
|
+
this.updateButtonStates();
|
|
2320
|
+
});
|
|
2321
|
+
this.editor.on("selectionUpdate", () => {
|
|
2322
|
+
this.updateButtonStates();
|
|
2323
|
+
});
|
|
2324
|
+
this.updateButtonStates();
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* 更新所有按钮状态
|
|
2328
|
+
*/
|
|
2329
|
+
updateButtonStates() {
|
|
2330
|
+
this.buttons.forEach((buttons, itemId) => {
|
|
2331
|
+
const itemDef = this.itemDefs.get(itemId);
|
|
2332
|
+
if (!itemDef) return;
|
|
2333
|
+
const active = itemDef.isActive(this.editor);
|
|
2334
|
+
const disabled = itemDef.isDisabled(this.editor);
|
|
2335
|
+
buttons.forEach((button) => {
|
|
2336
|
+
button.classList.toggle("active", active);
|
|
2337
|
+
if (itemDef.popup) button.setAttribute("aria-expanded", String(active));
|
|
2338
|
+
button.disabled = disabled;
|
|
2339
|
+
});
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* 销毁工具栏
|
|
2344
|
+
*/
|
|
2345
|
+
destroy() {
|
|
2346
|
+
var _a, _b;
|
|
2347
|
+
(_a = this.unsubscribeLanguage) == null ? void 0 : _a.call(this);
|
|
2348
|
+
this.unsubscribeLanguage = void 0;
|
|
2349
|
+
(_b = this.panel) == null ? void 0 : _b.destroy();
|
|
2350
|
+
this.panel = null;
|
|
2351
|
+
this.buttons.clear();
|
|
2352
|
+
this.customTooltips.clear();
|
|
2353
|
+
this.createdElements.forEach((el) => el.remove());
|
|
2354
|
+
this.createdElements = [];
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
class MediaStatusStrip {
|
|
2358
|
+
constructor(runtime, container, i18n) {
|
|
2359
|
+
const t = (key, fallback, options) => {
|
|
2360
|
+
const translated = i18n == null ? void 0 : i18n.t(key, options);
|
|
2361
|
+
return translated && translated !== key ? translated : fallback;
|
|
2362
|
+
};
|
|
2363
|
+
this.labels = {
|
|
2364
|
+
uploading: (files, percent) => t("media.uploading", "上传中 {{files}} 个 · {{percent}}%", { files, percent }),
|
|
2365
|
+
failed: (files) => t("media.uploadFailed", "{{files}} 个文件上传失败", { files }),
|
|
2366
|
+
inline: (files) => t("media.inlined", "{{files}} 张图片未上传(已随文档保存)", { files }),
|
|
2367
|
+
retry: () => t("media.retry", "重试")
|
|
2368
|
+
};
|
|
2369
|
+
this.element = document.createElement("div");
|
|
2370
|
+
this.element.className = "atri-media-status";
|
|
2371
|
+
this.element.setAttribute("role", "status");
|
|
2372
|
+
this.element.hidden = true;
|
|
2373
|
+
this.text = document.createElement("span");
|
|
2374
|
+
this.text.className = "atri-media-status-text";
|
|
2375
|
+
this.retry = document.createElement("button");
|
|
2376
|
+
this.retry.type = "button";
|
|
2377
|
+
this.retry.className = "atri-media-status-retry";
|
|
2378
|
+
this.retry.hidden = true;
|
|
2379
|
+
this.retry.addEventListener("click", () => void runtime.retryFailed());
|
|
2380
|
+
this.element.append(this.text, this.retry);
|
|
2381
|
+
container.appendChild(this.element);
|
|
2382
|
+
this.unsubscribe = runtime.subscribe((state) => this.render(state));
|
|
2383
|
+
this.unsubscribeLanguage = i18n == null ? void 0 : i18n.onLanguageChanged(() => this.render(runtime.getState()));
|
|
2384
|
+
}
|
|
2385
|
+
destroy() {
|
|
2386
|
+
var _a, _b;
|
|
2387
|
+
(_a = this.unsubscribe) == null ? void 0 : _a.call(this);
|
|
2388
|
+
(_b = this.unsubscribeLanguage) == null ? void 0 : _b.call(this);
|
|
2389
|
+
this.element.remove();
|
|
2390
|
+
}
|
|
2391
|
+
render(state) {
|
|
2392
|
+
const { failed, inline } = state;
|
|
2393
|
+
const parts = [];
|
|
2394
|
+
if (state.uploading) parts.push(this.labels.uploading(state.uploading, state.percent));
|
|
2395
|
+
if (failed) parts.push(this.labels.failed(failed));
|
|
2396
|
+
if (inline) parts.push(this.labels.inline(inline));
|
|
2397
|
+
const needsAttention = failed > 0 || inline > 0;
|
|
2398
|
+
this.element.setAttribute("data-atri-media-status", needsAttention ? "error" : "uploading");
|
|
2399
|
+
this.element.hidden = !parts.length;
|
|
2400
|
+
this.text.replaceChildren(
|
|
2401
|
+
...parts.map((part) => {
|
|
2402
|
+
const span = document.createElement("span");
|
|
2403
|
+
span.textContent = part;
|
|
2404
|
+
return span;
|
|
2405
|
+
})
|
|
2406
|
+
);
|
|
2407
|
+
this.retry.hidden = !needsAttention;
|
|
2408
|
+
this.retry.textContent = this.labels.retry();
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
class AIInterceptor {
|
|
2412
|
+
constructor(interceptors = {}, markdownService = null, autoTranslateMarkdown = true) {
|
|
2413
|
+
this.interceptors = interceptors;
|
|
2414
|
+
this.markdownService = markdownService;
|
|
2415
|
+
this.autoTranslateMarkdown = autoTranslateMarkdown;
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* 执行 beforePost 拦截器
|
|
2419
|
+
* @returns 处理后的 context,返回 false 表示取消请求
|
|
2420
|
+
*/
|
|
2421
|
+
async runBeforePost(context) {
|
|
2422
|
+
if (!this.interceptors.beforePost) {
|
|
2423
|
+
return context;
|
|
2424
|
+
}
|
|
2425
|
+
const result = this.interceptors.beforePost(context);
|
|
2426
|
+
return result;
|
|
2427
|
+
}
|
|
2428
|
+
/**
|
|
2429
|
+
* 执行 afterPost 拦截器
|
|
2430
|
+
*/
|
|
2431
|
+
async runAfterPost(response, context) {
|
|
2432
|
+
if (!this.interceptors.afterPost) {
|
|
2433
|
+
return response;
|
|
2434
|
+
}
|
|
2435
|
+
return this.interceptors.afterPost(response, context);
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* 执行 beforeRender 拦截器
|
|
2439
|
+
*/
|
|
2440
|
+
async runBeforeRender(content, context) {
|
|
2441
|
+
if (!this.interceptors.beforeRender) {
|
|
2442
|
+
return content;
|
|
2443
|
+
}
|
|
2444
|
+
return this.interceptors.beforeRender(content, context);
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* 执行 autoTranslateMarkdownToHTML
|
|
2448
|
+
* 在 beforeRender 之后执行,自动将 Markdown 转为 HTML
|
|
2449
|
+
*/
|
|
2450
|
+
async runAutoTranslateMarkdown(content, response) {
|
|
2451
|
+
if (!this.autoTranslateMarkdown) {
|
|
2452
|
+
return content;
|
|
2453
|
+
}
|
|
2454
|
+
if (response.contentType !== "markdown") {
|
|
2455
|
+
return content;
|
|
2456
|
+
}
|
|
2457
|
+
if (this.markdownService) {
|
|
2458
|
+
return this.markdownService.markdownToHTML(content);
|
|
2459
|
+
}
|
|
2460
|
+
const { simpleMarkdownToHtml: simpleMarkdownToHtml2 } = await Promise.resolve().then(() => markdown);
|
|
2461
|
+
return simpleMarkdownToHtml2(content);
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* 执行 afterRender 回调
|
|
2465
|
+
*/
|
|
2466
|
+
async runAfterRender(content, context) {
|
|
2467
|
+
if (!this.interceptors.afterRender) {
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
this.interceptors.afterRender(content, context);
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* 完整执行拦截器链
|
|
2474
|
+
* 流程: beforePost -> requestEndpoint -> afterPost -> beforeRender -> autoTranslateMarkdown -> insert -> afterRender
|
|
2475
|
+
*/
|
|
2476
|
+
async executeChain(context, requestEndpoint, insertCallback) {
|
|
2477
|
+
const processedContext = await this.runBeforePost(context);
|
|
2478
|
+
if (processedContext === false) {
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
let response = await requestEndpoint(processedContext);
|
|
2482
|
+
response = await this.runAfterPost(response, processedContext);
|
|
2483
|
+
let content = await this.runBeforeRender(response.content, processedContext);
|
|
2484
|
+
content = await this.runAutoTranslateMarkdown(content, response);
|
|
2485
|
+
insertCallback(content);
|
|
2486
|
+
await this.runAfterRender(content, processedContext);
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
function getSelectedText(editor) {
|
|
2490
|
+
const { selection } = editor.state;
|
|
2491
|
+
if (selection.empty) return "";
|
|
2492
|
+
return getTextBetween(editor.state.doc, {
|
|
2493
|
+
from: selection.from,
|
|
2494
|
+
to: selection.to
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
function getCursorContext(editor, maxLength = 500) {
|
|
2498
|
+
const { selection, doc } = editor.state;
|
|
2499
|
+
const pos = selection.from;
|
|
2500
|
+
const startPos = pos - editor.state.doc.resolve(pos).parentOffset;
|
|
2501
|
+
const from = Math.max(0, startPos - maxLength);
|
|
2502
|
+
return getTextBetween(doc, { from, to: pos });
|
|
2503
|
+
}
|
|
2504
|
+
function getDocumentText(editor) {
|
|
2505
|
+
return editor.state.doc.textContent;
|
|
2506
|
+
}
|
|
2507
|
+
class AIService {
|
|
2508
|
+
constructor(editor, config, markdownService = null) {
|
|
2509
|
+
this.markdownService = null;
|
|
2510
|
+
this.editor = editor;
|
|
2511
|
+
this.config = {
|
|
2512
|
+
autoTranslateMarkdownToHTML: true,
|
|
2513
|
+
...config
|
|
2514
|
+
};
|
|
2515
|
+
this.markdownService = markdownService;
|
|
2516
|
+
this.interceptor = new AIInterceptor(
|
|
2517
|
+
config.interceptors || {},
|
|
2518
|
+
markdownService,
|
|
2519
|
+
this.config.autoTranslateMarkdownToHTML
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* 获取所有 AI 功能
|
|
2524
|
+
*/
|
|
2525
|
+
getFunctions() {
|
|
2526
|
+
return this.config.functions || [];
|
|
2527
|
+
}
|
|
2528
|
+
/**
|
|
2529
|
+
* 根据 ID 获取 AI 功能
|
|
2530
|
+
*/
|
|
2531
|
+
getFunction(id) {
|
|
2532
|
+
var _a;
|
|
2533
|
+
return (_a = this.config.functions) == null ? void 0 : _a.find((f) => f.id === id);
|
|
2534
|
+
}
|
|
2535
|
+
/**
|
|
2536
|
+
* 构建请求上下文
|
|
2537
|
+
*/
|
|
2538
|
+
buildContext(func) {
|
|
2539
|
+
var _a;
|
|
2540
|
+
const selection = getSelectedText(this.editor);
|
|
2541
|
+
const cursorContext = getCursorContext(this.editor);
|
|
2542
|
+
const documentText = getDocumentText(this.editor);
|
|
2543
|
+
const scopeContent = {
|
|
2544
|
+
selection,
|
|
2545
|
+
cursor: cursorContext,
|
|
2546
|
+
document: documentText
|
|
2547
|
+
};
|
|
2548
|
+
const values = {
|
|
2549
|
+
selection,
|
|
2550
|
+
cursor: cursorContext,
|
|
2551
|
+
document: documentText,
|
|
2552
|
+
content: func.scope ? scopeContent[func.scope] : selection || cursorContext
|
|
2553
|
+
};
|
|
2554
|
+
const prompt = (_a = func.prompt) == null ? void 0 : _a.replace(/\{(\w+)\}/g, (raw, key) => values[key] ?? raw);
|
|
2555
|
+
return {
|
|
2556
|
+
functionId: func.id,
|
|
2557
|
+
scope: func.scope,
|
|
2558
|
+
selection,
|
|
2559
|
+
cursorContext,
|
|
2560
|
+
document: documentText,
|
|
2561
|
+
prompt,
|
|
2562
|
+
extra: {}
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
/**
|
|
2566
|
+
* 执行 AI 功能
|
|
2567
|
+
*/
|
|
2568
|
+
async execute(functionId) {
|
|
2569
|
+
var _a, _b;
|
|
2570
|
+
const func = this.getFunction(functionId);
|
|
2571
|
+
if (!func) {
|
|
2572
|
+
console.error(`AI function "${functionId}" not found.`);
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
const context = this.buildContext(func);
|
|
2576
|
+
try {
|
|
2577
|
+
await this.interceptor.executeChain(
|
|
2578
|
+
context,
|
|
2579
|
+
this.config.requestEndpoint,
|
|
2580
|
+
(content) => this.insertContent(content, func.outputMode || "insert")
|
|
2581
|
+
);
|
|
2582
|
+
} catch (error) {
|
|
2583
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2584
|
+
(_b = (_a = this.config).onError) == null ? void 0 : _b.call(_a, err, context);
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* 插入内容到编辑器
|
|
2589
|
+
*/
|
|
2590
|
+
insertContent(content, mode) {
|
|
2591
|
+
const { selection } = this.editor.state;
|
|
2592
|
+
switch (mode) {
|
|
2593
|
+
case "replace":
|
|
2594
|
+
if (!selection.empty) {
|
|
2595
|
+
this.editor.chain().deleteRange({ from: selection.from, to: selection.to }).insertContent(content).run();
|
|
2596
|
+
} else {
|
|
2597
|
+
this.editor.commands.insertContent(content);
|
|
2598
|
+
}
|
|
2599
|
+
break;
|
|
2600
|
+
case "insert":
|
|
2601
|
+
this.editor.commands.insertContent(content);
|
|
2602
|
+
break;
|
|
2603
|
+
case "append":
|
|
2604
|
+
this.editor.commands.insertContentAt(this.editor.state.doc.content.size, content);
|
|
2605
|
+
break;
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
/**
|
|
2609
|
+
* 更新配置
|
|
2610
|
+
*/
|
|
2611
|
+
updateConfig(config) {
|
|
2612
|
+
this.config = { ...this.config, ...config };
|
|
2613
|
+
this.interceptor = new AIInterceptor(
|
|
2614
|
+
config.interceptors || this.config.interceptors || {},
|
|
2615
|
+
this.markdownService,
|
|
2616
|
+
this.config.autoTranslateMarkdownToHTML ?? true
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* 设置 Markdown 服务
|
|
2621
|
+
*/
|
|
2622
|
+
setMarkdownService(service) {
|
|
2623
|
+
this.markdownService = service;
|
|
2624
|
+
this.interceptor = new AIInterceptor(
|
|
2625
|
+
this.config.interceptors || {},
|
|
2626
|
+
service,
|
|
2627
|
+
this.config.autoTranslateMarkdownToHTML ?? true
|
|
2628
|
+
);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
class AICommandMenuManager {
|
|
2632
|
+
constructor(editor, functions, triggerChar = "/", onSelect) {
|
|
2633
|
+
this.menuElement = null;
|
|
2634
|
+
this.isVisible = false;
|
|
2635
|
+
this.selectedIndex = 0;
|
|
2636
|
+
this.editor = editor;
|
|
2637
|
+
this.functions = functions;
|
|
2638
|
+
this.triggerChar = triggerChar;
|
|
2639
|
+
this.onSelect = onSelect;
|
|
2640
|
+
this.setupKeyboardListener();
|
|
2641
|
+
this.setupMenuKeyListener();
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* 让键盘导航可达
|
|
2645
|
+
* 监听挂在 document 捕获阶段:ProseMirror 在 view.dom 上已有 keymap,
|
|
2646
|
+
* 若等它先处理,Enter 会被用来拆段而不是选中菜单项
|
|
2647
|
+
*/
|
|
2648
|
+
setupMenuKeyListener() {
|
|
2649
|
+
const handler = (event) => {
|
|
2650
|
+
if (!this.isVisible || !this.editor.isFocused) return;
|
|
2651
|
+
if (this.handleKeyDown(event)) {
|
|
2652
|
+
event.stopPropagation();
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2655
|
+
document.addEventListener("keydown", handler, true);
|
|
2656
|
+
this.detachKeyListener = () => document.removeEventListener("keydown", handler, true);
|
|
2657
|
+
}
|
|
2658
|
+
setupKeyboardListener() {
|
|
2659
|
+
this.editor.on("transaction", ({ transaction }) => {
|
|
2660
|
+
if (transaction.docChanged) {
|
|
2661
|
+
const lastChar = this.getLastTypedChar();
|
|
2662
|
+
if (lastChar === this.triggerChar && !this.isVisible) {
|
|
2663
|
+
this.show();
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
});
|
|
2667
|
+
}
|
|
2668
|
+
getLastTypedChar() {
|
|
2669
|
+
const { doc, selection } = this.editor.state;
|
|
2670
|
+
if (selection.from <= 0) return "";
|
|
2671
|
+
return doc.textBetween(selection.from - 1, selection.from);
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* 显示菜单
|
|
2675
|
+
*/
|
|
2676
|
+
show() {
|
|
2677
|
+
if (this.isVisible) return;
|
|
2678
|
+
this.isVisible = true;
|
|
2679
|
+
this.selectedIndex = 0;
|
|
2680
|
+
this.createMenuElement();
|
|
2681
|
+
this.positionMenu();
|
|
2682
|
+
}
|
|
2683
|
+
/**
|
|
2684
|
+
* 隐藏菜单
|
|
2685
|
+
*/
|
|
2686
|
+
hide() {
|
|
2687
|
+
if (!this.isVisible) return;
|
|
2688
|
+
this.isVisible = false;
|
|
2689
|
+
if (this.menuElement) {
|
|
2690
|
+
this.menuElement.remove();
|
|
2691
|
+
this.menuElement = null;
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
createMenuElement() {
|
|
2695
|
+
this.menuElement = document.createElement("div");
|
|
2696
|
+
this.menuElement.className = "atri-ai-command-menu";
|
|
2697
|
+
this.menuElement.setAttribute("role", "listbox");
|
|
2698
|
+
this.functions.forEach((func, index) => {
|
|
2699
|
+
const item = document.createElement("div");
|
|
2700
|
+
item.className = "atri-ai-command-menu-item";
|
|
2701
|
+
item.setAttribute("role", "option");
|
|
2702
|
+
item.setAttribute("data-index", String(index));
|
|
2703
|
+
if (index === this.selectedIndex) {
|
|
2704
|
+
item.classList.add("active");
|
|
2705
|
+
}
|
|
2706
|
+
if (func.icon) {
|
|
2707
|
+
const iconSpan = document.createElement("span");
|
|
2708
|
+
iconSpan.className = "atri-ai-command-menu-icon";
|
|
2709
|
+
iconSpan.innerHTML = func.icon;
|
|
2710
|
+
item.appendChild(iconSpan);
|
|
2711
|
+
}
|
|
2712
|
+
const nameSpan = document.createElement("span");
|
|
2713
|
+
nameSpan.className = "atri-ai-command-menu-name";
|
|
2714
|
+
nameSpan.textContent = func.name;
|
|
2715
|
+
item.appendChild(nameSpan);
|
|
2716
|
+
if (func.description) {
|
|
2717
|
+
item.title = func.description;
|
|
2718
|
+
}
|
|
2719
|
+
item.addEventListener("click", () => this.selectItem(index));
|
|
2720
|
+
item.addEventListener("mouseenter", () => this.highlightItem(index));
|
|
2721
|
+
this.menuElement.appendChild(item);
|
|
2722
|
+
});
|
|
2723
|
+
document.body.appendChild(this.menuElement);
|
|
2724
|
+
}
|
|
2725
|
+
async positionMenu() {
|
|
2726
|
+
const menuElement = this.menuElement;
|
|
2727
|
+
if (!menuElement) return;
|
|
2728
|
+
const { view } = this.editor;
|
|
2729
|
+
const { from } = view.state.selection;
|
|
2730
|
+
let x;
|
|
2731
|
+
let y;
|
|
2732
|
+
try {
|
|
2733
|
+
const coords = view.coordsAtPos(from);
|
|
2734
|
+
const virtualReference = {
|
|
2735
|
+
getBoundingClientRect() {
|
|
2736
|
+
return new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
|
|
2737
|
+
}
|
|
2738
|
+
};
|
|
2739
|
+
({ x, y } = await computePosition(virtualReference, menuElement, {
|
|
2740
|
+
placement: "bottom-start",
|
|
2741
|
+
middleware: [offset(8), flip(), shift()]
|
|
2742
|
+
}));
|
|
2743
|
+
} catch {
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
if (this.menuElement !== menuElement) return;
|
|
2747
|
+
menuElement.style.position = "fixed";
|
|
2748
|
+
menuElement.style.left = `${x}px`;
|
|
2749
|
+
menuElement.style.top = `${y}px`;
|
|
2750
|
+
}
|
|
2751
|
+
selectItem(index) {
|
|
2752
|
+
const func = this.functions[index];
|
|
2753
|
+
if (func && this.onSelect) {
|
|
2754
|
+
this.deleteTriggerChar();
|
|
2755
|
+
this.onSelect(func);
|
|
2756
|
+
}
|
|
2757
|
+
this.hide();
|
|
2758
|
+
}
|
|
2759
|
+
highlightItem(index) {
|
|
2760
|
+
this.selectedIndex = index;
|
|
2761
|
+
this.updateHighlight();
|
|
2762
|
+
}
|
|
2763
|
+
updateHighlight() {
|
|
2764
|
+
if (!this.menuElement) return;
|
|
2765
|
+
const items = this.menuElement.querySelectorAll(".atri-ai-command-menu-item");
|
|
2766
|
+
items.forEach((item, i) => {
|
|
2767
|
+
if (i === this.selectedIndex) {
|
|
2768
|
+
item.classList.add("active");
|
|
2769
|
+
} else {
|
|
2770
|
+
item.classList.remove("active");
|
|
2771
|
+
}
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
deleteTriggerChar() {
|
|
2775
|
+
const { state, view } = this.editor;
|
|
2776
|
+
const { from } = state.selection;
|
|
2777
|
+
if (from <= 0 || state.doc.textBetween(from - 1, from) !== this.triggerChar) {
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
const tr = state.tr.delete(from - 1, from);
|
|
2781
|
+
view.dispatch(tr);
|
|
2782
|
+
}
|
|
2783
|
+
/**
|
|
2784
|
+
* 处理键盘事件
|
|
2785
|
+
*/
|
|
2786
|
+
handleKeyDown(event) {
|
|
2787
|
+
if (!this.isVisible) return false;
|
|
2788
|
+
switch (event.key) {
|
|
2789
|
+
case "ArrowDown":
|
|
2790
|
+
event.preventDefault();
|
|
2791
|
+
this.selectedIndex = (this.selectedIndex + 1) % this.functions.length;
|
|
2792
|
+
this.updateHighlight();
|
|
2793
|
+
return true;
|
|
2794
|
+
case "ArrowUp":
|
|
2795
|
+
event.preventDefault();
|
|
2796
|
+
this.selectedIndex = (this.selectedIndex - 1 + this.functions.length) % this.functions.length;
|
|
2797
|
+
this.updateHighlight();
|
|
2798
|
+
return true;
|
|
2799
|
+
case "Enter":
|
|
2800
|
+
event.preventDefault();
|
|
2801
|
+
this.selectItem(this.selectedIndex);
|
|
2802
|
+
return true;
|
|
2803
|
+
case "Escape":
|
|
2804
|
+
event.preventDefault();
|
|
2805
|
+
this.hide();
|
|
2806
|
+
return true;
|
|
2807
|
+
}
|
|
2808
|
+
return false;
|
|
2809
|
+
}
|
|
2810
|
+
/**
|
|
2811
|
+
* 销毁
|
|
2812
|
+
*/
|
|
2813
|
+
destroy() {
|
|
2814
|
+
var _a;
|
|
2815
|
+
(_a = this.detachKeyListener) == null ? void 0 : _a.call(this);
|
|
2816
|
+
this.detachKeyListener = void 0;
|
|
2817
|
+
this.hide();
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
function resolveElement(element) {
|
|
2821
|
+
if (typeof element === "string") {
|
|
2822
|
+
const el = document.querySelector(element);
|
|
2823
|
+
if (!el) {
|
|
2824
|
+
throw new Error(`Element not found: ${element}`);
|
|
2825
|
+
}
|
|
2826
|
+
return el;
|
|
2827
|
+
}
|
|
2828
|
+
return element;
|
|
2829
|
+
}
|
|
2830
|
+
function createContainer(className) {
|
|
2831
|
+
const div = document.createElement("div");
|
|
2832
|
+
if (className) {
|
|
2833
|
+
div.className = className;
|
|
2834
|
+
}
|
|
2835
|
+
return div;
|
|
2836
|
+
}
|
|
2837
|
+
function isBrowser() {
|
|
2838
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
2839
|
+
}
|
|
2840
|
+
class AtriEditor {
|
|
2841
|
+
constructor(options) {
|
|
2842
|
+
this.toolbarManager = null;
|
|
2843
|
+
this.aiService = null;
|
|
2844
|
+
this.aiMenuManager = null;
|
|
2845
|
+
this.mediaStatus = null;
|
|
2846
|
+
this.pendingViewState = null;
|
|
2847
|
+
this.bubbleElement = null;
|
|
2848
|
+
this.options = options;
|
|
2849
|
+
const rootElement = resolveElement(options.element);
|
|
2850
|
+
this.container = createContainer("atri-editor");
|
|
2851
|
+
rootElement.appendChild(this.container);
|
|
2852
|
+
this.themeManager = new ThemeManager(this.container, options.theme || "light");
|
|
2853
|
+
this.i18nManager = new I18nManager(options.lang || "zh");
|
|
2854
|
+
this.extensionManager = new ExtensionManager();
|
|
2855
|
+
if (options.nodeViews && options.nodeViews.length > 0) {
|
|
2856
|
+
this.extensionManager.registerNodeViews(options.nodeViews);
|
|
2857
|
+
}
|
|
2858
|
+
let toolbarContainer = null;
|
|
2859
|
+
if (options.toolbar !== false) {
|
|
2860
|
+
toolbarContainer = createContainer("atri-editor-toolbar");
|
|
2861
|
+
this.container.appendChild(toolbarContainer);
|
|
2862
|
+
}
|
|
2863
|
+
const bubbleElement = this.ensureBubbleElement(options.toolbar);
|
|
2864
|
+
const editorElement = createContainer("atri-editor-content-wrapper");
|
|
2865
|
+
this.container.appendChild(editorElement);
|
|
2866
|
+
this.mediaRuntime = options.media === false ? null : new MediaRuntime(options.media);
|
|
2867
|
+
this.mediaStatus = this.mediaRuntime ? new MediaStatusStrip(this.mediaRuntime, this.container, this.i18nManager) : null;
|
|
2868
|
+
this.coreEditor = new CoreEditor({
|
|
2869
|
+
element: editorElement,
|
|
2870
|
+
content: options.content,
|
|
2871
|
+
contentFormat: options.contentFormat,
|
|
2872
|
+
editable: options.editable,
|
|
2873
|
+
placeholder: options.placeholder,
|
|
2874
|
+
extensions: [...options.extensions || [], ...this.extensionManager.getAll()],
|
|
2875
|
+
markdown: options.markdown,
|
|
2876
|
+
media: options.media,
|
|
2877
|
+
mediaRuntime: this.mediaRuntime ?? void 0,
|
|
2878
|
+
bubbleElement,
|
|
2879
|
+
onCreate: () => {
|
|
2880
|
+
this.onEditorCreated();
|
|
2881
|
+
},
|
|
2882
|
+
onUpdate: () => {
|
|
2883
|
+
var _a, _b;
|
|
2884
|
+
(_b = (_a = this.options).onChange) == null ? void 0 : _b.call(_a, this);
|
|
2885
|
+
},
|
|
2886
|
+
onFocus: () => {
|
|
2887
|
+
var _a, _b;
|
|
2888
|
+
(_b = (_a = this.options).onFocus) == null ? void 0 : _b.call(_a, this);
|
|
2889
|
+
},
|
|
2890
|
+
onBlur: () => {
|
|
2891
|
+
var _a, _b;
|
|
2892
|
+
(_b = (_a = this.options).onBlur) == null ? void 0 : _b.call(_a, this);
|
|
2893
|
+
},
|
|
2894
|
+
onDestroy: () => {
|
|
2895
|
+
var _a, _b;
|
|
2896
|
+
(_b = (_a = this.options).onDestroy) == null ? void 0 : _b.call(_a, this);
|
|
2897
|
+
}
|
|
2898
|
+
});
|
|
2899
|
+
this.markdownService = new MarkdownService(this.editor, options.markdown);
|
|
2900
|
+
if (toolbarContainer && options.toolbar !== false) {
|
|
2901
|
+
this.toolbarManager = new ToolbarManager(
|
|
2902
|
+
this.editor,
|
|
2903
|
+
toolbarContainer,
|
|
2904
|
+
options.toolbar,
|
|
2905
|
+
this.i18nManager,
|
|
2906
|
+
this.mediaRuntime
|
|
2907
|
+
);
|
|
2908
|
+
if (bubbleElement) this.toolbarManager.attachBubbleToolbar(bubbleElement);
|
|
2909
|
+
}
|
|
2910
|
+
if (options.ai) {
|
|
2911
|
+
this.initAI(options.ai);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
/** 开了 bubble 才建元素,且故意不插进文档:挂载与定位全归 BubbleMenu 插件管 */
|
|
2915
|
+
ensureBubbleElement(toolbar) {
|
|
2916
|
+
if (toolbar === false || !(toolbar == null ? void 0 : toolbar.bubble)) return null;
|
|
2917
|
+
this.bubbleElement ?? (this.bubbleElement = createContainer("atri-editor-bubble-toolbar"));
|
|
2918
|
+
return this.bubbleElement;
|
|
2919
|
+
}
|
|
2920
|
+
onEditorCreated() {
|
|
2921
|
+
var _a, _b, _c;
|
|
2922
|
+
if (this.pendingViewState) {
|
|
2923
|
+
const { from, to } = this.pendingViewState;
|
|
2924
|
+
this.pendingViewState = null;
|
|
2925
|
+
const limit = this.editor.state.doc.content.size;
|
|
2926
|
+
this.editor.commands.setTextSelection({
|
|
2927
|
+
from: Math.min(from, limit),
|
|
2928
|
+
to: Math.min(to, limit)
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
if ((_a = this.options.ai) == null ? void 0 : _a.functions) {
|
|
2932
|
+
this.aiMenuManager = new AICommandMenuManager(
|
|
2933
|
+
this.editor,
|
|
2934
|
+
this.options.ai.functions,
|
|
2935
|
+
"/",
|
|
2936
|
+
(func) => {
|
|
2937
|
+
var _a2;
|
|
2938
|
+
(_a2 = this.aiService) == null ? void 0 : _a2.execute(func.id);
|
|
2939
|
+
}
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
(_c = (_b = this.options).onCreate) == null ? void 0 : _c.call(_b, this);
|
|
2943
|
+
}
|
|
2944
|
+
initAI(config) {
|
|
2945
|
+
this.aiService = new AIService(this.editor, config, this.markdownService);
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* 重新创建编辑器(用于动态注册 NodeView 后更新 schema)
|
|
2949
|
+
* 会整体替换编辑器视图,因此内容与选区都要手动恢复
|
|
2950
|
+
*/
|
|
2951
|
+
recreateEditor() {
|
|
2952
|
+
var _a, _b;
|
|
2953
|
+
const previousEditor = this.coreEditor.getEditor();
|
|
2954
|
+
const currentContent = this.coreEditor.getHTML();
|
|
2955
|
+
const { from, to } = previousEditor.state.selection;
|
|
2956
|
+
this.pendingViewState = { from, to };
|
|
2957
|
+
this.coreEditor.destroy();
|
|
2958
|
+
(_a = this.toolbarManager) == null ? void 0 : _a.destroy();
|
|
2959
|
+
(_b = this.aiMenuManager) == null ? void 0 : _b.destroy();
|
|
2960
|
+
const editorElement = this.container.querySelector(
|
|
2961
|
+
".atri-editor-content-wrapper"
|
|
2962
|
+
);
|
|
2963
|
+
const toolbarContainer = this.container.querySelector(".atri-editor-toolbar");
|
|
2964
|
+
const newEditorElement = createContainer("atri-editor-content-wrapper");
|
|
2965
|
+
if (editorElement) {
|
|
2966
|
+
editorElement.replaceWith(newEditorElement);
|
|
2967
|
+
} else {
|
|
2968
|
+
this.container.appendChild(newEditorElement);
|
|
2969
|
+
}
|
|
2970
|
+
const bubbleElement = this.ensureBubbleElement(this.options.toolbar);
|
|
2971
|
+
this.coreEditor = new CoreEditor({
|
|
2972
|
+
element: newEditorElement,
|
|
2973
|
+
content: currentContent,
|
|
2974
|
+
// 当前内容取自 getHTML(),重建时按 html 回填,不能沿用用户的 contentFormat
|
|
2975
|
+
contentFormat: "html",
|
|
2976
|
+
editable: this.options.editable,
|
|
2977
|
+
placeholder: this.options.placeholder,
|
|
2978
|
+
extensions: [...this.options.extensions || [], ...this.extensionManager.getAll()],
|
|
2979
|
+
markdown: this.options.markdown,
|
|
2980
|
+
media: this.options.media,
|
|
2981
|
+
mediaRuntime: this.mediaRuntime ?? void 0,
|
|
2982
|
+
bubbleElement,
|
|
2983
|
+
onCreate: () => {
|
|
2984
|
+
this.onEditorCreated();
|
|
2985
|
+
},
|
|
2986
|
+
onUpdate: () => {
|
|
2987
|
+
var _a2, _b2;
|
|
2988
|
+
(_b2 = (_a2 = this.options).onChange) == null ? void 0 : _b2.call(_a2, this);
|
|
2989
|
+
},
|
|
2990
|
+
onFocus: () => {
|
|
2991
|
+
var _a2, _b2;
|
|
2992
|
+
(_b2 = (_a2 = this.options).onFocus) == null ? void 0 : _b2.call(_a2, this);
|
|
2993
|
+
},
|
|
2994
|
+
onBlur: () => {
|
|
2995
|
+
var _a2, _b2;
|
|
2996
|
+
(_b2 = (_a2 = this.options).onBlur) == null ? void 0 : _b2.call(_a2, this);
|
|
2997
|
+
},
|
|
2998
|
+
onDestroy: () => {
|
|
2999
|
+
var _a2, _b2;
|
|
3000
|
+
(_b2 = (_a2 = this.options).onDestroy) == null ? void 0 : _b2.call(_a2, this);
|
|
3001
|
+
}
|
|
3002
|
+
});
|
|
3003
|
+
this.markdownService = new MarkdownService(this.editor, this.options.markdown);
|
|
3004
|
+
if (toolbarContainer && this.options.toolbar !== false) {
|
|
3005
|
+
this.toolbarManager = new ToolbarManager(
|
|
3006
|
+
this.editor,
|
|
3007
|
+
toolbarContainer,
|
|
3008
|
+
this.options.toolbar,
|
|
3009
|
+
this.i18nManager,
|
|
3010
|
+
this.mediaRuntime
|
|
3011
|
+
);
|
|
3012
|
+
if (bubbleElement) this.toolbarManager.attachBubbleToolbar(bubbleElement);
|
|
3013
|
+
}
|
|
3014
|
+
if (this.options.ai) {
|
|
3015
|
+
this.initAI(this.options.ai);
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
/**
|
|
3019
|
+
* 获取 Tiptap Editor 实例
|
|
3020
|
+
*/
|
|
3021
|
+
get editor() {
|
|
3022
|
+
return this.coreEditor.getEditor();
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* 获取 HTML 内容
|
|
3026
|
+
*/
|
|
3027
|
+
getHTML() {
|
|
3028
|
+
return this.coreEditor.getHTML();
|
|
3029
|
+
}
|
|
3030
|
+
/**
|
|
3031
|
+
* 获取 JSON 内容
|
|
3032
|
+
*/
|
|
3033
|
+
getJSON() {
|
|
3034
|
+
return this.coreEditor.getJSON();
|
|
3035
|
+
}
|
|
3036
|
+
/**
|
|
3037
|
+
* 获取 Markdown 内容
|
|
3038
|
+
*/
|
|
3039
|
+
getMarkdown() {
|
|
3040
|
+
return this.markdownService.getMarkdown();
|
|
3041
|
+
}
|
|
3042
|
+
/**
|
|
3043
|
+
* 设置内容
|
|
3044
|
+
*/
|
|
3045
|
+
setContent(content, options) {
|
|
3046
|
+
const format = (options == null ? void 0 : options.format) || this.options.contentFormat;
|
|
3047
|
+
const emitUpdate = (options == null ? void 0 : options.emitUpdate) ?? true;
|
|
3048
|
+
if (format === "markdown" && typeof content === "string") {
|
|
3049
|
+
this.markdownService.setMarkdown(content, emitUpdate);
|
|
3050
|
+
} else {
|
|
3051
|
+
this.coreEditor.setContent(content, emitUpdate);
|
|
3052
|
+
}
|
|
3053
|
+
this.collapseSelectionAfterReplace();
|
|
3054
|
+
}
|
|
3055
|
+
/**
|
|
3056
|
+
* 设置 Markdown 内容
|
|
3057
|
+
*/
|
|
3058
|
+
setMarkdown(content) {
|
|
3059
|
+
this.markdownService.setMarkdown(content);
|
|
3060
|
+
this.collapseSelectionAfterReplace();
|
|
3061
|
+
}
|
|
3062
|
+
/**
|
|
3063
|
+
* 整体替换内容后收拢选区:替换前若是全选(Ctrl+A),ProseMirror 会把它映射成
|
|
3064
|
+
* 覆盖整篇新文档的 AllSelection——下一个字或下一次插入就把整篇吞掉,
|
|
3065
|
+
* 而"设置内容"之后用户的预期是安全的光标态,不是"再输入即全删"
|
|
3066
|
+
*/
|
|
3067
|
+
collapseSelectionAfterReplace() {
|
|
3068
|
+
const editor = this.coreEditor.getEditor();
|
|
3069
|
+
if (!editor || editor.state.selection.empty) return;
|
|
3070
|
+
editor.commands.setTextSelection(editor.state.doc.content.size);
|
|
3071
|
+
}
|
|
3072
|
+
/**
|
|
3073
|
+
* 清空内容
|
|
3074
|
+
*/
|
|
3075
|
+
clearContent() {
|
|
3076
|
+
this.coreEditor.clearContent();
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* 是否为空
|
|
3080
|
+
*/
|
|
3081
|
+
isEmpty() {
|
|
3082
|
+
return this.coreEditor.isEmpty();
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Markdown 转 HTML
|
|
3086
|
+
*/
|
|
3087
|
+
markdownToHTML(markdown2) {
|
|
3088
|
+
return this.markdownService.markdownToHTML(markdown2);
|
|
3089
|
+
}
|
|
3090
|
+
/**
|
|
3091
|
+
* HTML 转 Markdown
|
|
3092
|
+
*/
|
|
3093
|
+
htmlToMarkdown(html) {
|
|
3094
|
+
return this.markdownService.htmlToMarkdown(html);
|
|
3095
|
+
}
|
|
3096
|
+
/**
|
|
3097
|
+
* Markdown 转 JSON
|
|
3098
|
+
*/
|
|
3099
|
+
markdownToJSON(markdown2) {
|
|
3100
|
+
return this.markdownService.markdownToJSON(markdown2);
|
|
3101
|
+
}
|
|
3102
|
+
/**
|
|
3103
|
+
* 获取选中文本
|
|
3104
|
+
*/
|
|
3105
|
+
getSelectedText() {
|
|
3106
|
+
const { selection } = this.editor.state;
|
|
3107
|
+
if (selection.empty) return "";
|
|
3108
|
+
return getTextBetween(this.editor.state.doc, {
|
|
3109
|
+
from: selection.from,
|
|
3110
|
+
to: selection.to
|
|
3111
|
+
});
|
|
3112
|
+
}
|
|
3113
|
+
/**
|
|
3114
|
+
* 插入内容
|
|
3115
|
+
*/
|
|
3116
|
+
insertContent(content) {
|
|
3117
|
+
this.coreEditor.insertContent(content);
|
|
3118
|
+
}
|
|
3119
|
+
/**
|
|
3120
|
+
* 在选区处插入图片
|
|
3121
|
+
*/
|
|
3122
|
+
insertImage(options) {
|
|
3123
|
+
this.editor.chain().focus().setImage(options).run();
|
|
3124
|
+
}
|
|
3125
|
+
/**
|
|
3126
|
+
* 在选区处插入附件(形态由 options.display 或 media.attachment.display 决定)
|
|
3127
|
+
*/
|
|
3128
|
+
insertAttachment(options) {
|
|
3129
|
+
var _a;
|
|
3130
|
+
const media = this.options.media;
|
|
3131
|
+
const configured = media === false ? void 0 : (_a = media == null ? void 0 : media.attachment) == null ? void 0 : _a.display;
|
|
3132
|
+
const display = options.display ?? configured ?? "card";
|
|
3133
|
+
const chain = this.editor.chain().focus();
|
|
3134
|
+
if (display === "link") {
|
|
3135
|
+
chain.setAttachmentLink(options).run();
|
|
3136
|
+
} else {
|
|
3137
|
+
chain.setAttachment(options).run();
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
/**
|
|
3141
|
+
* 走上传管线插入本地文件
|
|
3142
|
+
* kind 缺省时按 MIME 分流;promise 在这批文件全部落定(成功或失败)后 resolve
|
|
3143
|
+
*/
|
|
3144
|
+
async uploadFiles(files, kind) {
|
|
3145
|
+
if (!this.mediaRuntime) {
|
|
3146
|
+
console.warn("[Atri Editor] uploadFiles() ignored: media extensions are disabled.");
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
await this.mediaRuntime.handleFiles(files, { kind });
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* 是否有文件还没落到服务端(上传中与失败都算)
|
|
3153
|
+
* 此时保存会把本地预览地址写进内容,接入方应据此提示或阻断
|
|
3154
|
+
*/
|
|
3155
|
+
hasPendingUploads() {
|
|
3156
|
+
var _a;
|
|
3157
|
+
return ((_a = this.mediaRuntime) == null ? void 0 : _a.hasPendingUploads()) ?? false;
|
|
3158
|
+
}
|
|
3159
|
+
/**
|
|
3160
|
+
* 重试所有失败的上传,卡片原地回到上传中
|
|
3161
|
+
*/
|
|
3162
|
+
async retryFailedUploads() {
|
|
3163
|
+
var _a;
|
|
3164
|
+
await ((_a = this.mediaRuntime) == null ? void 0 : _a.retryFailed());
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* 设置占位符
|
|
3168
|
+
*/
|
|
3169
|
+
setPlaceholder(placeholder) {
|
|
3170
|
+
this.options.placeholder = placeholder;
|
|
3171
|
+
this.coreEditor.setPlaceholder(placeholder);
|
|
3172
|
+
}
|
|
3173
|
+
/**
|
|
3174
|
+
* 设置可编辑状态
|
|
3175
|
+
*/
|
|
3176
|
+
setEditable(editable) {
|
|
3177
|
+
this.coreEditor.setEditable(editable);
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* 是否可编辑
|
|
3181
|
+
*/
|
|
3182
|
+
isEditable() {
|
|
3183
|
+
return this.coreEditor.isEditable();
|
|
3184
|
+
}
|
|
3185
|
+
/**
|
|
3186
|
+
* 聚焦
|
|
3187
|
+
*/
|
|
3188
|
+
focus() {
|
|
3189
|
+
this.coreEditor.focus();
|
|
3190
|
+
}
|
|
3191
|
+
/**
|
|
3192
|
+
* 失焦
|
|
3193
|
+
*/
|
|
3194
|
+
blur() {
|
|
3195
|
+
this.coreEditor.blur();
|
|
3196
|
+
}
|
|
3197
|
+
/**
|
|
3198
|
+
* 获取 AI 服务
|
|
3199
|
+
*/
|
|
3200
|
+
get ai() {
|
|
3201
|
+
return this.aiService;
|
|
3202
|
+
}
|
|
3203
|
+
/**
|
|
3204
|
+
* 获取主题管理器
|
|
3205
|
+
*/
|
|
3206
|
+
get theme() {
|
|
3207
|
+
return this.themeManager;
|
|
3208
|
+
}
|
|
3209
|
+
/**
|
|
3210
|
+
* 获取国际化管理器
|
|
3211
|
+
*/
|
|
3212
|
+
get i18n() {
|
|
3213
|
+
return this.i18nManager;
|
|
3214
|
+
}
|
|
3215
|
+
/**
|
|
3216
|
+
* 获取扩展管理器
|
|
3217
|
+
*/
|
|
3218
|
+
get extensions() {
|
|
3219
|
+
return this.extensionManager;
|
|
3220
|
+
}
|
|
3221
|
+
/**
|
|
3222
|
+
* 设置主题
|
|
3223
|
+
*/
|
|
3224
|
+
setTheme(theme) {
|
|
3225
|
+
this.themeManager.setTheme(theme);
|
|
3226
|
+
}
|
|
3227
|
+
/**
|
|
3228
|
+
* 切换主题
|
|
3229
|
+
*/
|
|
3230
|
+
toggleTheme() {
|
|
3231
|
+
this.themeManager.toggleTheme();
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* 切换语言
|
|
3235
|
+
*/
|
|
3236
|
+
async setLanguage(lang) {
|
|
3237
|
+
await this.i18nManager.changeLanguage(lang);
|
|
3238
|
+
}
|
|
3239
|
+
/**
|
|
3240
|
+
* 更新 AI 配置
|
|
3241
|
+
*/
|
|
3242
|
+
updateAIConfig(config) {
|
|
3243
|
+
if (this.aiService) {
|
|
3244
|
+
this.aiService.updateConfig(config);
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* 注册自定义 NodeView 组件
|
|
3249
|
+
* 注意:编辑器已创建时,每次调用都会重建一次编辑器以更新 schema;
|
|
3250
|
+
* 连续注册多个组件请改用 registerNodeViews,只需重建一次
|
|
3251
|
+
*/
|
|
3252
|
+
registerNodeView(config) {
|
|
3253
|
+
this.extensionManager.registerNodeView(config);
|
|
3254
|
+
if (this.coreEditor) {
|
|
3255
|
+
this.recreateEditor();
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
/**
|
|
3259
|
+
* 批量注册 NodeView
|
|
3260
|
+
* 注意:如果编辑器已创建,会重新创建编辑器以更新 schema
|
|
3261
|
+
*/
|
|
3262
|
+
registerNodeViews(configs) {
|
|
3263
|
+
this.extensionManager.registerNodeViews(configs);
|
|
3264
|
+
if (this.coreEditor) {
|
|
3265
|
+
this.recreateEditor();
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* 获取所有已注册的 NodeView
|
|
3270
|
+
*/
|
|
3271
|
+
getNodeViews() {
|
|
3272
|
+
return this.extensionManager.getNodeViews();
|
|
3273
|
+
}
|
|
3274
|
+
/**
|
|
3275
|
+
* 销毁编辑器
|
|
3276
|
+
*/
|
|
3277
|
+
destroy() {
|
|
3278
|
+
var _a, _b, _c, _d;
|
|
3279
|
+
(_a = this.toolbarManager) == null ? void 0 : _a.destroy();
|
|
3280
|
+
(_b = this.aiMenuManager) == null ? void 0 : _b.destroy();
|
|
3281
|
+
this.coreEditor.destroy();
|
|
3282
|
+
(_c = this.mediaStatus) == null ? void 0 : _c.destroy();
|
|
3283
|
+
(_d = this.mediaRuntime) == null ? void 0 : _d.destroy();
|
|
3284
|
+
this.bubbleElement = null;
|
|
3285
|
+
this.container.remove();
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
const ELEMENT_NAME = "atri-editor";
|
|
3289
|
+
const AtriEditorBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {
|
|
3290
|
+
};
|
|
3291
|
+
function registerAtriElement() {
|
|
3292
|
+
if (typeof customElements === "undefined") {
|
|
3293
|
+
console.warn("Custom Elements not supported");
|
|
3294
|
+
return;
|
|
3295
|
+
}
|
|
3296
|
+
if (!customElements.get(ELEMENT_NAME)) {
|
|
3297
|
+
customElements.define(ELEMENT_NAME, AtriEditorElement);
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
class AtriEditorElement extends AtriEditorBase {
|
|
3301
|
+
constructor() {
|
|
3302
|
+
super();
|
|
3303
|
+
this.editor = null;
|
|
3304
|
+
this._options = {};
|
|
3305
|
+
}
|
|
3306
|
+
static get observedAttributes() {
|
|
3307
|
+
return ["theme", "editable", "lang", "placeholder"];
|
|
3308
|
+
}
|
|
3309
|
+
connectedCallback() {
|
|
3310
|
+
this.initEditor();
|
|
3311
|
+
}
|
|
3312
|
+
disconnectedCallback() {
|
|
3313
|
+
this.destroyEditor();
|
|
3314
|
+
}
|
|
3315
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
3316
|
+
if (oldValue === newValue) return;
|
|
3317
|
+
switch (name) {
|
|
3318
|
+
case "theme":
|
|
3319
|
+
if (this.editor && newValue) {
|
|
3320
|
+
this.editor.setTheme(newValue);
|
|
3321
|
+
}
|
|
3322
|
+
break;
|
|
3323
|
+
case "editable":
|
|
3324
|
+
if (this.editor) {
|
|
3325
|
+
this.editor.setEditable(newValue !== "false");
|
|
3326
|
+
}
|
|
3327
|
+
break;
|
|
3328
|
+
case "lang":
|
|
3329
|
+
if (this.editor && newValue) {
|
|
3330
|
+
this.editor.setLanguage(newValue);
|
|
3331
|
+
}
|
|
3332
|
+
break;
|
|
3333
|
+
case "placeholder":
|
|
3334
|
+
if (this.editor) {
|
|
3335
|
+
this.editor.setPlaceholder(newValue ?? "");
|
|
3336
|
+
}
|
|
3337
|
+
break;
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
/**
|
|
3341
|
+
* 设置编辑器选项(在 connectedCallback 之前调用)
|
|
3342
|
+
*/
|
|
3343
|
+
setOptions(options) {
|
|
3344
|
+
this._options = { ...this._options, ...options };
|
|
3345
|
+
}
|
|
3346
|
+
initEditor() {
|
|
3347
|
+
if (this.editor) return;
|
|
3348
|
+
const options = {
|
|
3349
|
+
element: this,
|
|
3350
|
+
...this._options
|
|
3351
|
+
};
|
|
3352
|
+
const theme = this.getAttribute("theme");
|
|
3353
|
+
if (theme) options.theme = theme;
|
|
3354
|
+
const editable = this.getAttribute("editable");
|
|
3355
|
+
if (editable !== null) options.editable = editable !== "false";
|
|
3356
|
+
const lang = this.getAttribute("lang");
|
|
3357
|
+
if (lang) options.lang = lang;
|
|
3358
|
+
const placeholder = this.getAttribute("placeholder");
|
|
3359
|
+
if (placeholder) options.placeholder = placeholder;
|
|
3360
|
+
const content = this.getAttribute("data-content");
|
|
3361
|
+
if (content) options.content = content;
|
|
3362
|
+
const contentFormat = this.getAttribute("data-content-format");
|
|
3363
|
+
if (contentFormat) options.contentFormat = contentFormat;
|
|
3364
|
+
this.editor = new AtriEditor(options);
|
|
3365
|
+
this.dispatchEvent(
|
|
3366
|
+
new CustomEvent("editor-created", {
|
|
3367
|
+
detail: { editor: this.editor },
|
|
3368
|
+
bubbles: true
|
|
3369
|
+
})
|
|
3370
|
+
);
|
|
3371
|
+
}
|
|
3372
|
+
destroyEditor() {
|
|
3373
|
+
if (this.editor) {
|
|
3374
|
+
this.editor.destroy();
|
|
3375
|
+
this.editor = null;
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
/**
|
|
3379
|
+
* 获取编辑器实例
|
|
3380
|
+
*/
|
|
3381
|
+
getEditor() {
|
|
3382
|
+
return this.editor;
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* 获取 HTML 内容
|
|
3386
|
+
*/
|
|
3387
|
+
getHTML() {
|
|
3388
|
+
var _a;
|
|
3389
|
+
return ((_a = this.editor) == null ? void 0 : _a.getHTML()) || "";
|
|
3390
|
+
}
|
|
3391
|
+
/**
|
|
3392
|
+
* 获取 JSON 内容
|
|
3393
|
+
*/
|
|
3394
|
+
getJSON() {
|
|
3395
|
+
var _a;
|
|
3396
|
+
return ((_a = this.editor) == null ? void 0 : _a.getJSON()) || null;
|
|
3397
|
+
}
|
|
3398
|
+
/**
|
|
3399
|
+
* 获取 Markdown 内容
|
|
3400
|
+
*/
|
|
3401
|
+
getMarkdown() {
|
|
3402
|
+
var _a;
|
|
3403
|
+
return ((_a = this.editor) == null ? void 0 : _a.getMarkdown()) || "";
|
|
3404
|
+
}
|
|
3405
|
+
/**
|
|
3406
|
+
* 设置内容
|
|
3407
|
+
*/
|
|
3408
|
+
setContent(content, format) {
|
|
3409
|
+
var _a;
|
|
3410
|
+
(_a = this.editor) == null ? void 0 : _a.setContent(content, { format });
|
|
3411
|
+
}
|
|
3412
|
+
/**
|
|
3413
|
+
* 清空内容
|
|
3414
|
+
*/
|
|
3415
|
+
clearContent() {
|
|
3416
|
+
var _a;
|
|
3417
|
+
(_a = this.editor) == null ? void 0 : _a.clearContent();
|
|
3418
|
+
}
|
|
3419
|
+
/**
|
|
3420
|
+
* 在选区处插入图片
|
|
3421
|
+
*/
|
|
3422
|
+
insertImage(options) {
|
|
3423
|
+
var _a;
|
|
3424
|
+
(_a = this.editor) == null ? void 0 : _a.insertImage(options);
|
|
3425
|
+
}
|
|
3426
|
+
/**
|
|
3427
|
+
* 在选区处插入附件卡片
|
|
3428
|
+
*/
|
|
3429
|
+
insertAttachment(options) {
|
|
3430
|
+
var _a;
|
|
3431
|
+
(_a = this.editor) == null ? void 0 : _a.insertAttachment(options);
|
|
3432
|
+
}
|
|
3433
|
+
/**
|
|
3434
|
+
* 走上传管线插入本地文件
|
|
3435
|
+
*/
|
|
3436
|
+
async uploadFiles(files, kind) {
|
|
3437
|
+
var _a;
|
|
3438
|
+
await ((_a = this.editor) == null ? void 0 : _a.uploadFiles(files, kind));
|
|
3439
|
+
}
|
|
3440
|
+
/**
|
|
3441
|
+
* 重试所有失败的上传
|
|
3442
|
+
*/
|
|
3443
|
+
async retryFailedUploads() {
|
|
3444
|
+
var _a;
|
|
3445
|
+
await ((_a = this.editor) == null ? void 0 : _a.retryFailedUploads());
|
|
3446
|
+
}
|
|
3447
|
+
/**
|
|
3448
|
+
* 是否有文件还没落到服务端(上传中与失败都算),保存前可据此提示
|
|
3449
|
+
*/
|
|
3450
|
+
hasPendingUploads() {
|
|
3451
|
+
var _a;
|
|
3452
|
+
return ((_a = this.editor) == null ? void 0 : _a.hasPendingUploads()) ?? false;
|
|
3453
|
+
}
|
|
3454
|
+
/**
|
|
3455
|
+
* 聚焦
|
|
3456
|
+
*/
|
|
3457
|
+
focus() {
|
|
3458
|
+
var _a;
|
|
3459
|
+
(_a = this.editor) == null ? void 0 : _a.focus();
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
registerAtriElement();
|
|
3463
|
+
export {
|
|
3464
|
+
AICommandMenuManager,
|
|
3465
|
+
AIInterceptor,
|
|
3466
|
+
AIService,
|
|
3467
|
+
AtriEditor,
|
|
3468
|
+
AtriEditorElement,
|
|
3469
|
+
AtriImage,
|
|
3470
|
+
Attachment,
|
|
3471
|
+
CoreEditor,
|
|
3472
|
+
ExtensionManager,
|
|
3473
|
+
I18nManager,
|
|
3474
|
+
MarkdownService,
|
|
3475
|
+
MediaRuntime,
|
|
3476
|
+
ThemeManager,
|
|
3477
|
+
ToolbarManager,
|
|
3478
|
+
createContainer,
|
|
3479
|
+
createMediaExtensions,
|
|
3480
|
+
getCursorContext,
|
|
3481
|
+
getDocumentText,
|
|
3482
|
+
getSelectedText,
|
|
3483
|
+
isBrowser,
|
|
3484
|
+
registerAtriElement,
|
|
3485
|
+
resolveElement,
|
|
3486
|
+
simpleHtmlToMarkdown,
|
|
3487
|
+
simpleMarkdownToHtml
|
|
3488
|
+
};
|
|
3489
|
+
//# sourceMappingURL=index.js.map
|