@slash-editor/core 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +1191 -0
- package/dist/index.mjs +2514 -0
- package/package.json +40 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2514 @@
|
|
|
1
|
+
import { Extension, Mark, Node, callOrReturn, combineTransactionSteps, findChildrenInRange, getChangedRanges, getExtensionField, isTextSelection, mergeAttributes, posToDOMRect } from "@tiptap/core";
|
|
2
|
+
import { Fragment } from "@tiptap/pm/model";
|
|
3
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
4
|
+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
|
5
|
+
import { StarterKit } from "@tiptap/starter-kit";
|
|
6
|
+
import { Details, DetailsContent, DetailsSummary } from "@tiptap/extension-details";
|
|
7
|
+
import { TaskItem, TaskList } from "@tiptap/extension-list";
|
|
8
|
+
import { Collaboration } from "@tiptap/extension-collaboration";
|
|
9
|
+
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
|
|
10
|
+
import { Suggestion } from "@tiptap/suggestion";
|
|
11
|
+
import { TableKit } from "@tiptap/extension-table";
|
|
12
|
+
//#region src/upload.ts
|
|
13
|
+
/**
|
|
14
|
+
* Finds a node by its `BlockId`-assigned `id` attribute. Pure: takes a doc,
|
|
15
|
+
* not an editor, so it is testable without a live view.
|
|
16
|
+
*/
|
|
17
|
+
function findNodeById(doc, id) {
|
|
18
|
+
let found = null;
|
|
19
|
+
doc.descendants((node, pos) => {
|
|
20
|
+
if (found) return false;
|
|
21
|
+
if (node.attrs.id === id) {
|
|
22
|
+
found = {
|
|
23
|
+
pos,
|
|
24
|
+
node
|
|
25
|
+
};
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
});
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Per-node-type registry of in-flight/failed uploads, keyed by the node's
|
|
34
|
+
* `id`. Retrying re-sends the same `File` without asking the user to pick it
|
|
35
|
+
* again; entries are dropped on success, kept on failure and on abort.
|
|
36
|
+
*
|
|
37
|
+
* Files are never stored in node attrs: attrs must stay JSON-serializable
|
|
38
|
+
* for Yjs (M4), so the pending `File` lives here instead.
|
|
39
|
+
*/
|
|
40
|
+
var PendingUploadRegistry = class {
|
|
41
|
+
pending = /* @__PURE__ */ new Map();
|
|
42
|
+
set(id, entry) {
|
|
43
|
+
this.pending.get(id)?.controller.abort();
|
|
44
|
+
this.pending.set(id, entry);
|
|
45
|
+
}
|
|
46
|
+
get(id) {
|
|
47
|
+
return this.pending.get(id);
|
|
48
|
+
}
|
|
49
|
+
delete(id) {
|
|
50
|
+
this.pending.delete(id);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
function applyAttrs(editor, typeName, id, patch) {
|
|
54
|
+
const location = findNodeById(editor.state.doc, id);
|
|
55
|
+
if (!location || location.node.type.name !== typeName) return;
|
|
56
|
+
const tr = editor.state.tr.setNodeMarkup(location.pos, void 0, {
|
|
57
|
+
...location.node.attrs,
|
|
58
|
+
...patch
|
|
59
|
+
}).setMeta("addToHistory", false);
|
|
60
|
+
editor.view.dispatch(tr);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Starts (or restarts) an upload for a node already present in the
|
|
64
|
+
* document. Resolves by locating the node through `id` — not a captured
|
|
65
|
+
* position — since the doc may change while the network request is in
|
|
66
|
+
* flight. Completion never creates a separate undo step.
|
|
67
|
+
*/
|
|
68
|
+
function runUpload(options) {
|
|
69
|
+
const { editor, typeName, id, file, adapter, pending, toAttrs } = options;
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
pending.set(id, {
|
|
72
|
+
file,
|
|
73
|
+
adapter,
|
|
74
|
+
controller
|
|
75
|
+
});
|
|
76
|
+
adapter.upload(file, { signal: controller.signal }).then((result) => {
|
|
77
|
+
if (controller.signal.aborted) return;
|
|
78
|
+
pending.delete(id);
|
|
79
|
+
applyAttrs(editor, typeName, id, {
|
|
80
|
+
status: "ready",
|
|
81
|
+
error: null,
|
|
82
|
+
...toAttrs(result)
|
|
83
|
+
});
|
|
84
|
+
}).catch((error) => {
|
|
85
|
+
if (controller.signal.aborted) return;
|
|
86
|
+
applyAttrs(editor, typeName, id, {
|
|
87
|
+
status: "error",
|
|
88
|
+
error: error instanceof Error ? error.message : "Upload failed"
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* (Re)starts an upload for a node already in the document: from the last
|
|
94
|
+
* `File` passed to `runUpload` for this id, or from `override` when the
|
|
95
|
+
* node has never had an upload attempt (e.g. a placeholder that just had a
|
|
96
|
+
* file attached). Returns `false` — a no-op — when neither is available.
|
|
97
|
+
*/
|
|
98
|
+
function retryUpload(options) {
|
|
99
|
+
const { editor, typeName, id, pending, toAttrs, override } = options;
|
|
100
|
+
const entry = override ?? pending.get(id);
|
|
101
|
+
if (!entry) return false;
|
|
102
|
+
applyAttrs(editor, typeName, id, {
|
|
103
|
+
status: "uploading",
|
|
104
|
+
error: null
|
|
105
|
+
});
|
|
106
|
+
runUpload({
|
|
107
|
+
editor,
|
|
108
|
+
typeName,
|
|
109
|
+
id,
|
|
110
|
+
file: entry.file,
|
|
111
|
+
adapter: entry.adapter,
|
|
112
|
+
pending,
|
|
113
|
+
toAttrs
|
|
114
|
+
});
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/ai-block.ts
|
|
119
|
+
/**
|
|
120
|
+
* Per-node registry of in-flight/completed AI requests, keyed by the node's
|
|
121
|
+
* `id`. Unlike `PendingUploadRegistry`, entries are kept on success too —
|
|
122
|
+
* "try again" always needs the last request/adapter to replay — and are
|
|
123
|
+
* only dropped (aborting anything still in flight) when the block is
|
|
124
|
+
* discarded or its streamed text is accepted into the document.
|
|
125
|
+
*/
|
|
126
|
+
var PendingAiRegistry = class {
|
|
127
|
+
pending = /* @__PURE__ */ new Map();
|
|
128
|
+
set(id, entry) {
|
|
129
|
+
this.pending.get(id)?.controller.abort();
|
|
130
|
+
this.pending.set(id, entry);
|
|
131
|
+
}
|
|
132
|
+
get(id) {
|
|
133
|
+
return this.pending.get(id);
|
|
134
|
+
}
|
|
135
|
+
delete(id) {
|
|
136
|
+
this.pending.get(id)?.controller.abort();
|
|
137
|
+
this.pending.delete(id);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
function applyAiAttrs(editor, id, patch) {
|
|
141
|
+
const location = findNodeById(editor.state.doc, id);
|
|
142
|
+
if (!location || location.node.type.name !== AiBlock.name) return;
|
|
143
|
+
const tr = editor.state.tr.setNodeMarkup(location.pos, void 0, {
|
|
144
|
+
...location.node.attrs,
|
|
145
|
+
...patch
|
|
146
|
+
}).setMeta("addToHistory", false);
|
|
147
|
+
editor.view.dispatch(tr);
|
|
148
|
+
}
|
|
149
|
+
/** Starts (or restarts) a stream for a node already present in the document. */
|
|
150
|
+
function runAiStream(editor, id, request, adapter, pending) {
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
pending.set(id, {
|
|
153
|
+
request,
|
|
154
|
+
adapter,
|
|
155
|
+
controller
|
|
156
|
+
});
|
|
157
|
+
(async () => {
|
|
158
|
+
try {
|
|
159
|
+
let text = "";
|
|
160
|
+
for await (const chunk of adapter.stream(request, { signal: controller.signal })) {
|
|
161
|
+
if (controller.signal.aborted) return;
|
|
162
|
+
text += chunk;
|
|
163
|
+
applyAiAttrs(editor, id, {
|
|
164
|
+
text,
|
|
165
|
+
status: "streaming",
|
|
166
|
+
error: null
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (controller.signal.aborted) return;
|
|
170
|
+
applyAiAttrs(editor, id, { status: "done" });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (controller.signal.aborted) return;
|
|
173
|
+
applyAiAttrs(editor, id, {
|
|
174
|
+
status: "error",
|
|
175
|
+
error: error instanceof Error ? error.message : "AI request failed"
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* A transient block that streams an AI response into the document: never
|
|
182
|
+
* meant to be the doc's final shape, only a staging area. `status`/`text`/
|
|
183
|
+
* `error` live in node attrs (part of the doc) so streaming re-renders
|
|
184
|
+
* through the normal transaction pipeline, the same reasoning `Image`
|
|
185
|
+
* documents for upload progress — until the user accepts (replaced by real
|
|
186
|
+
* paragraphs) or discards (removed) it.
|
|
187
|
+
*/
|
|
188
|
+
const AiBlock = Node.create({
|
|
189
|
+
name: "aiBlock",
|
|
190
|
+
group: "block",
|
|
191
|
+
atom: true,
|
|
192
|
+
addOptions() {
|
|
193
|
+
return { HTMLAttributes: {} };
|
|
194
|
+
},
|
|
195
|
+
addStorage() {
|
|
196
|
+
return { pending: new PendingAiRegistry() };
|
|
197
|
+
},
|
|
198
|
+
addAttributes() {
|
|
199
|
+
return {
|
|
200
|
+
action: { default: "" },
|
|
201
|
+
prompt: { default: "" },
|
|
202
|
+
text: { default: "" },
|
|
203
|
+
status: {
|
|
204
|
+
default: "streaming",
|
|
205
|
+
parseHTML: (element) => element.getAttribute("data-status") ?? "streaming",
|
|
206
|
+
renderHTML: (attributes) => ({ "data-status": attributes.status })
|
|
207
|
+
},
|
|
208
|
+
error: {
|
|
209
|
+
default: null,
|
|
210
|
+
parseHTML: (element) => element.getAttribute("data-error"),
|
|
211
|
+
renderHTML: (attributes) => attributes.error ? { "data-error": attributes.error } : {}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
parseHTML() {
|
|
216
|
+
return [{ tag: `div[data-type="${this.name}"]` }];
|
|
217
|
+
},
|
|
218
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
219
|
+
return [
|
|
220
|
+
"div",
|
|
221
|
+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-type": this.name }),
|
|
222
|
+
node.attrs.text || ""
|
|
223
|
+
];
|
|
224
|
+
},
|
|
225
|
+
addCommands() {
|
|
226
|
+
return {
|
|
227
|
+
runAiAction: (options) => ({ commands, dispatch }) => {
|
|
228
|
+
const id = crypto.randomUUID();
|
|
229
|
+
const request = {
|
|
230
|
+
action: options.action,
|
|
231
|
+
prompt: options.prompt,
|
|
232
|
+
context: options.context
|
|
233
|
+
};
|
|
234
|
+
const inserted = commands.insertContent({
|
|
235
|
+
type: this.name,
|
|
236
|
+
attrs: {
|
|
237
|
+
id,
|
|
238
|
+
action: request.action,
|
|
239
|
+
prompt: request.prompt,
|
|
240
|
+
text: "",
|
|
241
|
+
status: "streaming",
|
|
242
|
+
error: null
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
if (inserted && dispatch) queueMicrotask(() => {
|
|
246
|
+
runAiStream(this.editor, id, request, options.adapter, this.storage.pending);
|
|
247
|
+
});
|
|
248
|
+
return inserted;
|
|
249
|
+
},
|
|
250
|
+
retryAiAction: (id) => ({ state, dispatch }) => {
|
|
251
|
+
const entry = this.storage.pending.get(id);
|
|
252
|
+
const location = findNodeById(state.doc, id);
|
|
253
|
+
if (!entry || !location || location.node.type.name !== this.name) return false;
|
|
254
|
+
if (dispatch) queueMicrotask(() => {
|
|
255
|
+
applyAiAttrs(this.editor, id, {
|
|
256
|
+
text: "",
|
|
257
|
+
status: "streaming",
|
|
258
|
+
error: null
|
|
259
|
+
});
|
|
260
|
+
runAiStream(this.editor, id, entry.request, entry.adapter, this.storage.pending);
|
|
261
|
+
});
|
|
262
|
+
return true;
|
|
263
|
+
},
|
|
264
|
+
acceptAiAction: (id) => ({ state, tr, dispatch }) => {
|
|
265
|
+
const location = findNodeById(state.doc, id);
|
|
266
|
+
if (!location || location.node.type.name !== this.name) return false;
|
|
267
|
+
if (dispatch) {
|
|
268
|
+
const text = location.node.attrs.text ?? "";
|
|
269
|
+
const paragraphNode = state.schema.nodes.paragraph;
|
|
270
|
+
const lines = text.split(/\n{2,}/).map((line) => line.trim()).filter(Boolean);
|
|
271
|
+
const nodes = paragraphNode && (lines.length > 0 ? lines.map((line) => paragraphNode.create(null, state.schema.text(line))) : [paragraphNode.create()]);
|
|
272
|
+
tr.replaceWith(location.pos, location.pos + location.node.nodeSize, nodes ? Fragment.fromArray(nodes) : Fragment.empty);
|
|
273
|
+
}
|
|
274
|
+
this.storage.pending.delete(id);
|
|
275
|
+
return true;
|
|
276
|
+
},
|
|
277
|
+
discardAiAction: (id) => ({ state, tr, dispatch }) => {
|
|
278
|
+
const location = findNodeById(state.doc, id);
|
|
279
|
+
if (!location || location.node.type.name !== this.name) return false;
|
|
280
|
+
if (dispatch) tr.delete(location.pos, location.pos + location.node.nodeSize);
|
|
281
|
+
this.storage.pending.delete(id);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
/** Configures the AI block node. */
|
|
288
|
+
function aiBlock(options = {}) {
|
|
289
|
+
return AiBlock.configure(options);
|
|
290
|
+
}
|
|
291
|
+
const defaultAiSlashActions = [
|
|
292
|
+
{
|
|
293
|
+
id: "continue-writing",
|
|
294
|
+
title: "Continue writing",
|
|
295
|
+
description: "AI extends the text above the cursor",
|
|
296
|
+
icon: "sparkles",
|
|
297
|
+
prompt: "Continue writing the document naturally, matching its tone and style. Write only the continuation, with no preamble."
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: "summarize",
|
|
301
|
+
title: "Summarize",
|
|
302
|
+
description: "AI summarizes the text above the cursor",
|
|
303
|
+
icon: "sparkles",
|
|
304
|
+
prompt: "Summarize the following text in a few concise sentences."
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: "brainstorm-ideas",
|
|
308
|
+
title: "Brainstorm ideas",
|
|
309
|
+
description: "AI lists ideas related to the text above the cursor",
|
|
310
|
+
icon: "sparkles",
|
|
311
|
+
prompt: "Brainstorm a short bullet list of ideas related to the following text."
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: "fix-spelling-grammar",
|
|
315
|
+
title: "Fix spelling & grammar",
|
|
316
|
+
description: "AI rewrites the text above the cursor, correcting mistakes",
|
|
317
|
+
icon: "sparkles",
|
|
318
|
+
prompt: "Rewrite the following text, correcting spelling and grammar mistakes only, and preserve its meaning and tone."
|
|
319
|
+
}
|
|
320
|
+
];
|
|
321
|
+
const AI_GROUP = "AI";
|
|
322
|
+
/**
|
|
323
|
+
* Builds slash items for each configured action, bound to `options.adapter`.
|
|
324
|
+
* Every action operates on the document text up to the slash trigger — a
|
|
325
|
+
* slash command never carries a real user text selection the way a bubble
|
|
326
|
+
* toolbar action does, so there is nothing else to extract context from.
|
|
327
|
+
*/
|
|
328
|
+
function createAiSlashItems(options) {
|
|
329
|
+
return (options.actions ?? defaultAiSlashActions).map((action) => ({
|
|
330
|
+
id: action.id,
|
|
331
|
+
title: action.title,
|
|
332
|
+
group: AI_GROUP,
|
|
333
|
+
description: action.description,
|
|
334
|
+
aliases: ["ai"],
|
|
335
|
+
keywords: ["ai", "assistant"],
|
|
336
|
+
icon: action.icon ?? "sparkles",
|
|
337
|
+
when: (editor) => editor.schema.nodes[AiBlock.name] !== void 0,
|
|
338
|
+
run: ({ editor, range }) => {
|
|
339
|
+
const context = editor.state.doc.textBetween(0, range.from, "\n\n").slice(-4e3);
|
|
340
|
+
editor.chain().focus().deleteRange(range).runAiAction({
|
|
341
|
+
action: action.id,
|
|
342
|
+
prompt: action.prompt,
|
|
343
|
+
context,
|
|
344
|
+
adapter: options.adapter
|
|
345
|
+
}).run();
|
|
346
|
+
}
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/block-drag.ts
|
|
351
|
+
/**
|
|
352
|
+
* Pure geometry: given the current block rects, the pointer position, and
|
|
353
|
+
* the block being dragged, decides where it would land. No DOM or schema
|
|
354
|
+
* access beyond the injected `canNest` predicate, so the gesture rules are
|
|
355
|
+
* testable without a browser.
|
|
356
|
+
*/
|
|
357
|
+
function resolveDropTarget(blocks, point, source, options) {
|
|
358
|
+
const candidates = blocks.filter((block) => block.pos < source.pos || block.pos >= source.pos + source.size);
|
|
359
|
+
if (candidates.length === 0) return null;
|
|
360
|
+
let closest = candidates[0];
|
|
361
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
362
|
+
for (const block of candidates) {
|
|
363
|
+
const distance = Math.abs(point.y - (block.top + block.bottom) / 2);
|
|
364
|
+
if (distance < closestDistance) {
|
|
365
|
+
closest = block;
|
|
366
|
+
closestDistance = distance;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (point.x - source.left > options.indentThreshold && options.canNest(source, closest)) return {
|
|
370
|
+
pos: closest.pos + closest.size - 1,
|
|
371
|
+
mode: "inside",
|
|
372
|
+
rect: closest
|
|
373
|
+
};
|
|
374
|
+
const after = point.y >= (closest.top + closest.bottom) / 2;
|
|
375
|
+
if (options.canPlaceBeside(source, closest)) return after ? {
|
|
376
|
+
pos: closest.pos + closest.size,
|
|
377
|
+
mode: "after",
|
|
378
|
+
rect: closest
|
|
379
|
+
} : {
|
|
380
|
+
pos: closest.pos,
|
|
381
|
+
mode: "before",
|
|
382
|
+
rect: closest
|
|
383
|
+
};
|
|
384
|
+
return after ? {
|
|
385
|
+
pos: closest.containerPos + closest.containerSize,
|
|
386
|
+
mode: "after",
|
|
387
|
+
rect: closest
|
|
388
|
+
} : {
|
|
389
|
+
pos: closest.containerPos,
|
|
390
|
+
mode: "before",
|
|
391
|
+
rect: closest
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/** Deletes `[from, from + size)` and reinserts it at `to`, adjusting for the shift the deletion causes. */
|
|
395
|
+
function performMove(tr, from, size, to) {
|
|
396
|
+
const slice = tr.doc.slice(from, from + size);
|
|
397
|
+
const adjustedTo = to > from ? to - size : to;
|
|
398
|
+
tr.delete(from, from + size);
|
|
399
|
+
tr.insert(adjustedTo, slice.content);
|
|
400
|
+
}
|
|
401
|
+
/** Whether `source` can be inserted as one more child at the end of `target`'s existing content. */
|
|
402
|
+
function canAppendChild(target, source) {
|
|
403
|
+
return target.contentMatchAt(target.childCount).matchType(source) !== null;
|
|
404
|
+
}
|
|
405
|
+
/** Whether a node of `sourceType` can appear anywhere in `parentType`'s content, per its starting content match. */
|
|
406
|
+
function canBeChildType(schema, sourceType, parentType) {
|
|
407
|
+
const parent = schema.nodes[parentType];
|
|
408
|
+
const source = schema.nodes[sourceType];
|
|
409
|
+
return parent !== void 0 && source !== void 0 && parent.contentMatch.matchType(source) !== null;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The draggable unit containing `$pos`: a direct child of the document, or a
|
|
413
|
+
* list item at any nesting depth. A paragraph inside a list item carries its
|
|
414
|
+
* own block id (see `BlockId`) but is not itself a drag unit — dragging
|
|
415
|
+
* moves the whole list item, matching Notion's per-row handle.
|
|
416
|
+
*/
|
|
417
|
+
function resolveBlockAt($pos) {
|
|
418
|
+
for (let depth = $pos.depth; depth >= 1; depth--) {
|
|
419
|
+
const node = $pos.node(depth);
|
|
420
|
+
if (depth === 1 || node.type.name === "listItem") return {
|
|
421
|
+
pos: $pos.before(depth),
|
|
422
|
+
node
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
/** Every drag unit's live position, for sibling lookups and the rect cache. */
|
|
428
|
+
function findSibling(doc, target, direction) {
|
|
429
|
+
const $inside = doc.resolve(target.pos + 1);
|
|
430
|
+
const parentDepth = $inside.depth - 1;
|
|
431
|
+
if (parentDepth < 0) return null;
|
|
432
|
+
const parent = $inside.node(parentDepth);
|
|
433
|
+
const siblingIndex = $inside.index(parentDepth) + direction;
|
|
434
|
+
if (siblingIndex < 0 || siblingIndex >= parent.childCount) return null;
|
|
435
|
+
let siblingPos = $inside.start(parentDepth);
|
|
436
|
+
for (let i = 0; i < siblingIndex; i++) siblingPos += parent.child(i).nodeSize;
|
|
437
|
+
return {
|
|
438
|
+
pos: siblingPos,
|
|
439
|
+
size: parent.child(siblingIndex).nodeSize
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
const blockDragPluginKey = new PluginKey("blockDrag");
|
|
443
|
+
const CLOSED_STATE = Object.freeze({
|
|
444
|
+
hovered: null,
|
|
445
|
+
dragging: null,
|
|
446
|
+
drop: null
|
|
447
|
+
});
|
|
448
|
+
function findScrollParent(element) {
|
|
449
|
+
let node = element.parentElement;
|
|
450
|
+
while (node) {
|
|
451
|
+
const overflowY = getComputedStyle(node).overflowY;
|
|
452
|
+
if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight) return node;
|
|
453
|
+
node = node.parentElement;
|
|
454
|
+
}
|
|
455
|
+
return document.scrollingElement instanceof HTMLElement ? document.scrollingElement : null;
|
|
456
|
+
}
|
|
457
|
+
function autoScroll(view, clientY, margin) {
|
|
458
|
+
const scrollParent = findScrollParent(view.dom);
|
|
459
|
+
if (!scrollParent) return;
|
|
460
|
+
const box = scrollParent.getBoundingClientRect();
|
|
461
|
+
if (clientY < box.top + margin) scrollParent.scrollTop -= box.top + margin - clientY;
|
|
462
|
+
else if (clientY > box.bottom - margin) scrollParent.scrollTop += clientY - (box.bottom - margin);
|
|
463
|
+
}
|
|
464
|
+
function toBlockTarget(view, pos) {
|
|
465
|
+
const node = view.state.doc.nodeAt(pos);
|
|
466
|
+
if (!node) return null;
|
|
467
|
+
return {
|
|
468
|
+
pos,
|
|
469
|
+
size: node.nodeSize,
|
|
470
|
+
type: node.type.name,
|
|
471
|
+
id: typeof node.attrs.id === "string" ? node.attrs.id : null,
|
|
472
|
+
getClientRect: () => {
|
|
473
|
+
const dom = view.nodeDOM(pos);
|
|
474
|
+
return dom instanceof HTMLElement ? dom.getBoundingClientRect() : null;
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Resolves the block under viewport coordinates from cached rects, not
|
|
480
|
+
* `posAtCoords`: the gutter and a block's own left padding are empty space
|
|
481
|
+
* with no caret position there, where `posAtCoords` reliably returns
|
|
482
|
+
* nothing. Matches the smallest (most specific) rect whose vertical range
|
|
483
|
+
* contains the pointer, so a list item wins over its enclosing list.
|
|
484
|
+
*/
|
|
485
|
+
function resolveHover(view, clientX, clientY, gutterWidth, rects) {
|
|
486
|
+
const box = view.dom.getBoundingClientRect();
|
|
487
|
+
if (clientY < box.top || clientY > box.bottom || clientX < box.left - gutterWidth || clientX > box.right) return null;
|
|
488
|
+
let match = null;
|
|
489
|
+
for (const rect of rects) {
|
|
490
|
+
if (clientY < rect.top || clientY > rect.bottom) continue;
|
|
491
|
+
if (!match || rect.bottom - rect.top < match.bottom - match.top) match = rect;
|
|
492
|
+
}
|
|
493
|
+
return match ? toBlockTarget(view, match.pos) : null;
|
|
494
|
+
}
|
|
495
|
+
function computeRects(view) {
|
|
496
|
+
const rects = [];
|
|
497
|
+
view.state.doc.descendants((node, pos, parent) => {
|
|
498
|
+
if (!parent || parent.type.name !== "doc" && node.type.name !== "listItem") return;
|
|
499
|
+
const dom = view.nodeDOM(pos);
|
|
500
|
+
if (dom instanceof HTMLElement) {
|
|
501
|
+
const rect = dom.getBoundingClientRect();
|
|
502
|
+
const $pos = view.state.doc.resolve(pos);
|
|
503
|
+
const depth = $pos.depth;
|
|
504
|
+
const container = depth === 0 ? {
|
|
505
|
+
pos,
|
|
506
|
+
size: node.nodeSize
|
|
507
|
+
} : {
|
|
508
|
+
pos: $pos.before(depth),
|
|
509
|
+
size: $pos.node(depth).nodeSize
|
|
510
|
+
};
|
|
511
|
+
rects.push({
|
|
512
|
+
pos,
|
|
513
|
+
size: node.nodeSize,
|
|
514
|
+
type: node.type.name,
|
|
515
|
+
left: rect.left,
|
|
516
|
+
right: rect.right,
|
|
517
|
+
top: rect.top,
|
|
518
|
+
bottom: rect.bottom,
|
|
519
|
+
parentType: $pos.node(depth).type.name,
|
|
520
|
+
containerPos: container.pos,
|
|
521
|
+
containerSize: container.size
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
return rects;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Hover targeting, pointer-driven reordering, and list nesting for the
|
|
529
|
+
* block gutter. Geometry is pointer-driven (not HTML5 DnD) so the drop
|
|
530
|
+
* indicator and the nest gesture stay fully in our control; the gutter
|
|
531
|
+
* itself is rendered by the registry layer, anchored to `state.hovered`.
|
|
532
|
+
*/
|
|
533
|
+
const BlockDrag = Extension.create({
|
|
534
|
+
name: "blockDrag",
|
|
535
|
+
addOptions() {
|
|
536
|
+
return {
|
|
537
|
+
gutterWidth: 48,
|
|
538
|
+
indentThreshold: 32,
|
|
539
|
+
autoScrollMargin: 48
|
|
540
|
+
};
|
|
541
|
+
},
|
|
542
|
+
addStorage() {
|
|
543
|
+
return {
|
|
544
|
+
state: CLOSED_STATE,
|
|
545
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
546
|
+
editor: null,
|
|
547
|
+
subscribe(listener) {
|
|
548
|
+
this.listeners.add(listener);
|
|
549
|
+
return () => this.listeners.delete(listener);
|
|
550
|
+
},
|
|
551
|
+
setHovered(target) {
|
|
552
|
+
if (this.state.hovered === target) return;
|
|
553
|
+
this.state = {
|
|
554
|
+
...this.state,
|
|
555
|
+
hovered: target
|
|
556
|
+
};
|
|
557
|
+
this.listeners.forEach((listener) => listener());
|
|
558
|
+
},
|
|
559
|
+
setDragging(target) {
|
|
560
|
+
this.state = {
|
|
561
|
+
...this.state,
|
|
562
|
+
dragging: target,
|
|
563
|
+
drop: target ? this.state.drop : null
|
|
564
|
+
};
|
|
565
|
+
this.listeners.forEach((listener) => listener());
|
|
566
|
+
const { editor } = this;
|
|
567
|
+
if (editor && !editor.isDestroyed) editor.view.dispatch(editor.state.tr.setMeta("addToHistory", false));
|
|
568
|
+
},
|
|
569
|
+
setDrop(drop) {
|
|
570
|
+
this.state = {
|
|
571
|
+
...this.state,
|
|
572
|
+
drop
|
|
573
|
+
};
|
|
574
|
+
this.listeners.forEach((listener) => listener());
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
},
|
|
578
|
+
onCreate() {
|
|
579
|
+
this.storage.editor = this.editor;
|
|
580
|
+
},
|
|
581
|
+
addCommands() {
|
|
582
|
+
return {
|
|
583
|
+
moveBlock: ({ from, size, to }) => ({ tr, dispatch, state }) => {
|
|
584
|
+
if (!state.doc.nodeAt(from)) return false;
|
|
585
|
+
if (dispatch) performMove(tr, from, size, to);
|
|
586
|
+
return true;
|
|
587
|
+
},
|
|
588
|
+
moveBlockUp: () => ({ tr, dispatch, state }) => {
|
|
589
|
+
const target = resolveBlockAt(state.selection.$from);
|
|
590
|
+
const sibling = target && findSibling(state.doc, target, -1);
|
|
591
|
+
if (!target || !sibling) return false;
|
|
592
|
+
if (dispatch) performMove(tr, target.pos, target.node.nodeSize, sibling.pos);
|
|
593
|
+
return true;
|
|
594
|
+
},
|
|
595
|
+
moveBlockDown: () => ({ tr, dispatch, state }) => {
|
|
596
|
+
const target = resolveBlockAt(state.selection.$from);
|
|
597
|
+
const sibling = target && findSibling(state.doc, target, 1);
|
|
598
|
+
if (!target || !sibling) return false;
|
|
599
|
+
if (dispatch) performMove(tr, target.pos, target.node.nodeSize, sibling.pos + sibling.size);
|
|
600
|
+
return true;
|
|
601
|
+
},
|
|
602
|
+
duplicateBlock: ({ pos, size }) => ({ tr, dispatch, state }) => {
|
|
603
|
+
const node = state.doc.nodeAt(pos);
|
|
604
|
+
if (!node || node.nodeSize !== size) return false;
|
|
605
|
+
if (dispatch) tr.insert(pos + size, node.copy(node.content));
|
|
606
|
+
return true;
|
|
607
|
+
},
|
|
608
|
+
deleteBlock: ({ pos, size }) => ({ tr, dispatch, state }) => {
|
|
609
|
+
const node = state.doc.nodeAt(pos);
|
|
610
|
+
if (!node || node.nodeSize !== size) return false;
|
|
611
|
+
if (dispatch) tr.delete(pos, pos + size);
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
},
|
|
616
|
+
addKeyboardShortcuts() {
|
|
617
|
+
return {
|
|
618
|
+
"Alt-Shift-ArrowUp": () => this.editor.commands.moveBlockUp(),
|
|
619
|
+
"Alt-Shift-ArrowDown": () => this.editor.commands.moveBlockDown()
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
addProseMirrorPlugins() {
|
|
623
|
+
const { editor } = this;
|
|
624
|
+
const { gutterWidth, indentThreshold, autoScrollMargin, onError } = this.options;
|
|
625
|
+
const getStorage = () => editor.storage.blockDrag;
|
|
626
|
+
return [new Plugin({
|
|
627
|
+
key: blockDragPluginKey,
|
|
628
|
+
props: { decorations: (state) => {
|
|
629
|
+
const dragging = getStorage().state.dragging;
|
|
630
|
+
if (!dragging) return null;
|
|
631
|
+
const node = state.doc.nodeAt(dragging.pos);
|
|
632
|
+
if (!node) return null;
|
|
633
|
+
return DecorationSet.create(state.doc, [Decoration.node(dragging.pos, dragging.pos + node.nodeSize, { "data-dragging": "" })]);
|
|
634
|
+
} },
|
|
635
|
+
view: (view) => {
|
|
636
|
+
let rects = [];
|
|
637
|
+
let rectsDirty = true;
|
|
638
|
+
const handlePointerMove = (event) => {
|
|
639
|
+
if (event.pointerType === "touch") return;
|
|
640
|
+
const storage = getStorage();
|
|
641
|
+
if (rectsDirty) {
|
|
642
|
+
rects = computeRects(view);
|
|
643
|
+
rectsDirty = false;
|
|
644
|
+
}
|
|
645
|
+
if (!storage.state.dragging) {
|
|
646
|
+
storage.setHovered(resolveHover(view, event.clientX, event.clientY, gutterWidth, rects));
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const { dragging } = storage.state;
|
|
650
|
+
const source = rects.find((rect) => rect.pos === dragging.pos);
|
|
651
|
+
if (!source) {
|
|
652
|
+
storage.setDrop(null);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const drop = resolveDropTarget(rects, {
|
|
656
|
+
x: event.clientX,
|
|
657
|
+
y: event.clientY
|
|
658
|
+
}, source, {
|
|
659
|
+
indentThreshold,
|
|
660
|
+
canNest: (fromRect, toRect) => {
|
|
661
|
+
const targetNode = view.state.doc.nodeAt(toRect.pos);
|
|
662
|
+
const sourceType = view.state.schema.nodes[fromRect.type];
|
|
663
|
+
return targetNode !== null && sourceType !== void 0 && canAppendChild(targetNode, sourceType);
|
|
664
|
+
},
|
|
665
|
+
canPlaceBeside: (fromRect, toRect) => canBeChildType(view.state.schema, fromRect.type, toRect.parentType)
|
|
666
|
+
});
|
|
667
|
+
storage.setDrop(drop && {
|
|
668
|
+
...drop,
|
|
669
|
+
getClientRect: () => {
|
|
670
|
+
const { rect, mode } = drop;
|
|
671
|
+
if (mode === "before") return new DOMRect(rect.left, rect.top - 1, rect.right - rect.left, 2);
|
|
672
|
+
if (mode === "after") return new DOMRect(rect.left, rect.bottom - 1, rect.right - rect.left, 2);
|
|
673
|
+
return new DOMRect(rect.left + 24, rect.bottom - 1, rect.right - rect.left - 24, 2);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
autoScroll(view, event.clientY, autoScrollMargin);
|
|
677
|
+
};
|
|
678
|
+
const handlePointerUp = () => {
|
|
679
|
+
const storage = getStorage();
|
|
680
|
+
const { dragging, drop } = storage.state;
|
|
681
|
+
if (dragging && drop) try {
|
|
682
|
+
editor.commands.moveBlock({
|
|
683
|
+
from: dragging.pos,
|
|
684
|
+
size: dragging.size,
|
|
685
|
+
to: drop.pos
|
|
686
|
+
});
|
|
687
|
+
} catch (error) {
|
|
688
|
+
if (onError) onError(error, { editor });
|
|
689
|
+
else throw error;
|
|
690
|
+
}
|
|
691
|
+
storage.setDragging(null);
|
|
692
|
+
storage.setDrop(null);
|
|
693
|
+
};
|
|
694
|
+
const handleKeyDown = (event) => {
|
|
695
|
+
if (event.key === "Escape" && getStorage().state.dragging) {
|
|
696
|
+
getStorage().setDragging(null);
|
|
697
|
+
getStorage().setDrop(null);
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
document.addEventListener("pointermove", handlePointerMove);
|
|
701
|
+
document.addEventListener("pointerup", handlePointerUp);
|
|
702
|
+
document.addEventListener("pointercancel", handlePointerUp);
|
|
703
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
704
|
+
return {
|
|
705
|
+
update: () => {
|
|
706
|
+
rectsDirty = true;
|
|
707
|
+
},
|
|
708
|
+
destroy: () => {
|
|
709
|
+
document.removeEventListener("pointermove", handlePointerMove);
|
|
710
|
+
document.removeEventListener("pointerup", handlePointerUp);
|
|
711
|
+
document.removeEventListener("pointercancel", handlePointerUp);
|
|
712
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
})];
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
/** Configures the block drag extension. */
|
|
720
|
+
function blockDrag(options = {}) {
|
|
721
|
+
return BlockDrag.configure(options);
|
|
722
|
+
}
|
|
723
|
+
//#endregion
|
|
724
|
+
//#region src/block-id.ts
|
|
725
|
+
const blockIdPluginKey = new PluginKey("blockId");
|
|
726
|
+
/**
|
|
727
|
+
* Transactions carrying this meta flag are skipped by the assignment pass.
|
|
728
|
+
* A future collaboration provider sets it on transactions that apply a
|
|
729
|
+
* remote change, keeping ids deterministic across peers.
|
|
730
|
+
*/
|
|
731
|
+
const BLOCK_ID_REMOTE_META = "blockId:remote";
|
|
732
|
+
/**
|
|
733
|
+
* y-prosemirror stores its sync plugin's meta under this literal string: a
|
|
734
|
+
* `PluginKey("y-sync")` resolves to `"y-sync$"` the first time one is
|
|
735
|
+
* constructed. Checking the string lets remote Yjs transactions be
|
|
736
|
+
* recognized without depending on `@tiptap/y-tiptap`/`y-prosemirror` at M1.
|
|
737
|
+
*/
|
|
738
|
+
const Y_SYNC_META_KEY = "y-sync$";
|
|
739
|
+
const ID_LENGTH = 12;
|
|
740
|
+
const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_";
|
|
741
|
+
/** 12 chars from a 64-symbol alphabet: ~72 bits, collision-safe without a registry check. */
|
|
742
|
+
function generateBlockId() {
|
|
743
|
+
const bytes = new Uint8Array(ID_LENGTH);
|
|
744
|
+
crypto.getRandomValues(bytes);
|
|
745
|
+
let id = "";
|
|
746
|
+
for (const byte of bytes) id += ID_ALPHABET[byte % 64];
|
|
747
|
+
return id;
|
|
748
|
+
}
|
|
749
|
+
function isBlockExpression(value) {
|
|
750
|
+
return typeof value === "string" && /\bblock\b/.test(value);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Node types eligible for a block id: `group` membership in `block`, or a
|
|
754
|
+
* `content` expression that requires block children (list items, future
|
|
755
|
+
* `details`/`callout`). Depth is irrelevant — a paragraph inside a list item
|
|
756
|
+
* gets its own id alongside the list item's, since comment anchoring and
|
|
757
|
+
* block permissions (M4) need identity at every level, not just drag units.
|
|
758
|
+
*/
|
|
759
|
+
function deriveBlockTypes(extensions, exclude) {
|
|
760
|
+
const types = [];
|
|
761
|
+
for (const extension of extensions) {
|
|
762
|
+
if (extension.type !== "node" || exclude.includes(extension.name)) continue;
|
|
763
|
+
const context = {
|
|
764
|
+
name: extension.name,
|
|
765
|
+
options: extension.options,
|
|
766
|
+
storage: extension.storage
|
|
767
|
+
};
|
|
768
|
+
if (callOrReturn(getExtensionField(extension, "topNode", context))) continue;
|
|
769
|
+
const group = callOrReturn(getExtensionField(extension, "group", context));
|
|
770
|
+
const content = callOrReturn(getExtensionField(extension, "content", context));
|
|
771
|
+
if (isBlockExpression(group) || isBlockExpression(content)) types.push(extension.name);
|
|
772
|
+
}
|
|
773
|
+
return types;
|
|
774
|
+
}
|
|
775
|
+
/** Counts live ids per type set, so a pasted duplicate can be told apart from the original it was copied from. */
|
|
776
|
+
function collectIdCounts(doc, types) {
|
|
777
|
+
const counts = /* @__PURE__ */ new Map();
|
|
778
|
+
doc.descendants((node) => {
|
|
779
|
+
if (types.has(node.type.name) && typeof node.attrs.id === "string") counts.set(node.attrs.id, (counts.get(node.attrs.id) ?? 0) + 1);
|
|
780
|
+
});
|
|
781
|
+
return counts;
|
|
782
|
+
}
|
|
783
|
+
/** Node types carrying an `id` attribute in the live schema — the set `addGlobalAttributes` produced. */
|
|
784
|
+
function blockIdTypes(schema) {
|
|
785
|
+
const types = /* @__PURE__ */ new Set();
|
|
786
|
+
for (const [name, type] of Object.entries(schema.nodes)) if (type.spec.attrs && "id" in type.spec.attrs) types.add(name);
|
|
787
|
+
return types;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Assigns a fresh id to every node in `ranges` that has none, or whose id is
|
|
791
|
+
* shared with another node in the document. Attribute-only changes never
|
|
792
|
+
* move content, so positions collected up front stay valid for the whole
|
|
793
|
+
* pass without remapping.
|
|
794
|
+
*/
|
|
795
|
+
function assignBlockIds(tr, ranges, types, counts) {
|
|
796
|
+
let changed = false;
|
|
797
|
+
for (const range of ranges) for (const { node, pos } of findChildrenInRange(tr.doc, range, (candidate) => types.has(candidate.type.name))) {
|
|
798
|
+
const id = node.attrs.id;
|
|
799
|
+
if (!(typeof id !== "string" || (counts.get(id) ?? 0) > 1)) continue;
|
|
800
|
+
if (typeof id === "string") counts.set(id, (counts.get(id) ?? 1) - 1);
|
|
801
|
+
const nextId = generateBlockId();
|
|
802
|
+
counts.set(nextId, 1);
|
|
803
|
+
tr.setNodeAttribute(pos, "id", nextId);
|
|
804
|
+
changed = true;
|
|
805
|
+
}
|
|
806
|
+
return changed;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Injects a stable `attrs.id` into every block-capable node and renders it
|
|
810
|
+
* as `data-block-id`, alongside a constant `data-block-type` per node type
|
|
811
|
+
* for the styling seam. Ids are assigned on insert/parse only, never
|
|
812
|
+
* regenerated on attribute update or remote change — the invariant the
|
|
813
|
+
* eventual Yjs identity (M4) depends on.
|
|
814
|
+
*/
|
|
815
|
+
const BlockId = Extension.create({
|
|
816
|
+
name: "blockId",
|
|
817
|
+
addOptions() {
|
|
818
|
+
return {
|
|
819
|
+
types: "auto",
|
|
820
|
+
exclude: []
|
|
821
|
+
};
|
|
822
|
+
},
|
|
823
|
+
addGlobalAttributes() {
|
|
824
|
+
return (this.options.types === "auto" ? deriveBlockTypes(this.extensions, this.options.exclude) : this.options.types).map((type) => ({
|
|
825
|
+
types: [type],
|
|
826
|
+
attributes: { id: {
|
|
827
|
+
default: null,
|
|
828
|
+
parseHTML: (element) => element.getAttribute("data-block-id"),
|
|
829
|
+
renderHTML: (attrs) => ({
|
|
830
|
+
"data-block-type": type,
|
|
831
|
+
...typeof attrs.id === "string" ? { "data-block-id": attrs.id } : {}
|
|
832
|
+
})
|
|
833
|
+
} }
|
|
834
|
+
}));
|
|
835
|
+
},
|
|
836
|
+
onCreate() {
|
|
837
|
+
const { editor } = this;
|
|
838
|
+
const types = blockIdTypes(editor.schema);
|
|
839
|
+
const tr = editor.state.tr;
|
|
840
|
+
const counts = collectIdCounts(tr.doc, types);
|
|
841
|
+
if (assignBlockIds(tr, [{
|
|
842
|
+
from: 0,
|
|
843
|
+
to: tr.doc.content.size
|
|
844
|
+
}], types, counts)) {
|
|
845
|
+
tr.setMeta("addToHistory", false);
|
|
846
|
+
editor.view.dispatch(tr);
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
addProseMirrorPlugins() {
|
|
850
|
+
const { editor } = this;
|
|
851
|
+
return [new Plugin({
|
|
852
|
+
key: blockIdPluginKey,
|
|
853
|
+
appendTransaction: (transactions, oldState, newState) => {
|
|
854
|
+
if (transactions.some((tr) => tr.getMeta("blockId:remote") || tr.getMeta(Y_SYNC_META_KEY))) return null;
|
|
855
|
+
if (!transactions.some((tr) => tr.docChanged)) return null;
|
|
856
|
+
const types = blockIdTypes(editor.schema);
|
|
857
|
+
const tr = newState.tr;
|
|
858
|
+
const counts = collectIdCounts(tr.doc, types);
|
|
859
|
+
return assignBlockIds(tr, getChangedRanges(combineTransactionSteps(oldState.doc, [...transactions])).map((change) => change.newRange), types, counts) ? tr : null;
|
|
860
|
+
}
|
|
861
|
+
})];
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
/** Configures the block id extension. */
|
|
865
|
+
function blockId(options = {}) {
|
|
866
|
+
return BlockId.configure(options);
|
|
867
|
+
}
|
|
868
|
+
//#endregion
|
|
869
|
+
//#region src/link-editor.ts
|
|
870
|
+
function hasLinkMark(editor) {
|
|
871
|
+
return editor.schema.marks.link !== void 0;
|
|
872
|
+
}
|
|
873
|
+
/** Whether a link can be created (non-empty text selection) or edited (cursor inside one) right now. */
|
|
874
|
+
function canOpenLinkEditor(editor) {
|
|
875
|
+
if (!hasLinkMark(editor) || !editor.isEditable) return false;
|
|
876
|
+
const { from, to, empty } = editor.state.selection;
|
|
877
|
+
return editor.isActive("link") || !empty && editor.state.doc.textBetween(from, to).length > 0;
|
|
878
|
+
}
|
|
879
|
+
const CLOSED$3 = Object.freeze({
|
|
880
|
+
open: false,
|
|
881
|
+
href: "",
|
|
882
|
+
editing: false,
|
|
883
|
+
getClientRect: null
|
|
884
|
+
});
|
|
885
|
+
function computeAutoState(editor) {
|
|
886
|
+
if (!editor.isEditable || !editor.isActive("link")) return CLOSED$3;
|
|
887
|
+
const { from, to } = editor.state.selection;
|
|
888
|
+
return {
|
|
889
|
+
open: true,
|
|
890
|
+
href: editor.getAttributes("link").href ?? "",
|
|
891
|
+
editing: true,
|
|
892
|
+
getClientRect: () => posToDOMRect(editor.view, from, to)
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Selection-anchored link editing popover. Two entry points feed the same
|
|
897
|
+
* state: a bubble-toolbar "Link" button calls `openLinkEditor` over a fresh
|
|
898
|
+
* text selection (`editing: false`), and this extension auto-opens itself
|
|
899
|
+
* (`editing: true`) whenever the cursor lands inside an existing link — a
|
|
900
|
+
* case a plain "selection is non-empty" check (à la `BubbleToolbar`) can't
|
|
901
|
+
* catch, since placing a cursor inside a link selects no text.
|
|
902
|
+
*
|
|
903
|
+
* Applying or removing the link itself is not a custom command here: once
|
|
904
|
+
* open, a UI layer calls the `link` mark's own `setLink`/`unsetLink` (from
|
|
905
|
+
* `@tiptap/extension-link`) directly, the same way `BubbleToolbarItem.run`
|
|
906
|
+
* calls `toggleBold` directly — this extension only owns popover
|
|
907
|
+
* visibility and the draft href.
|
|
908
|
+
*
|
|
909
|
+
* The popover is never closed by `onTransaction`/`onBlur` alone once open:
|
|
910
|
+
* its own input needs real DOM focus to type a URL, and a blur there must
|
|
911
|
+
* not read as "dismiss". Only an explicit command (`closeLinkEditor`, or a
|
|
912
|
+
* document selection change while `editing`) closes it.
|
|
913
|
+
*/
|
|
914
|
+
const LinkEditor = Extension.create({
|
|
915
|
+
name: "linkEditor",
|
|
916
|
+
addOptions() {
|
|
917
|
+
return { autoOpenOnLinkActive: true };
|
|
918
|
+
},
|
|
919
|
+
addStorage() {
|
|
920
|
+
return {
|
|
921
|
+
state: CLOSED$3,
|
|
922
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
923
|
+
subscribe(listener) {
|
|
924
|
+
this.listeners.add(listener);
|
|
925
|
+
return () => this.listeners.delete(listener);
|
|
926
|
+
},
|
|
927
|
+
setState(next) {
|
|
928
|
+
if (!this.state.open && !next.open) return;
|
|
929
|
+
this.state = next;
|
|
930
|
+
this.listeners.forEach((listener) => listener());
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
},
|
|
934
|
+
addCommands() {
|
|
935
|
+
return {
|
|
936
|
+
openLinkEditor: () => ({ editor }) => {
|
|
937
|
+
if (!canOpenLinkEditor(editor)) return false;
|
|
938
|
+
const { from, to } = editor.state.selection;
|
|
939
|
+
editor.storage.linkEditor.setState({
|
|
940
|
+
open: true,
|
|
941
|
+
href: editor.getAttributes("link").href ?? "",
|
|
942
|
+
editing: editor.isActive("link"),
|
|
943
|
+
getClientRect: () => posToDOMRect(editor.view, from, to)
|
|
944
|
+
});
|
|
945
|
+
return true;
|
|
946
|
+
},
|
|
947
|
+
setLinkEditorHref: (href) => ({ editor }) => {
|
|
948
|
+
if (!editor.storage.linkEditor.state.open) return false;
|
|
949
|
+
editor.storage.linkEditor.setState({
|
|
950
|
+
...editor.storage.linkEditor.state,
|
|
951
|
+
href
|
|
952
|
+
});
|
|
953
|
+
return true;
|
|
954
|
+
},
|
|
955
|
+
closeLinkEditor: () => ({ editor }) => {
|
|
956
|
+
editor.view.focus();
|
|
957
|
+
editor.storage.linkEditor.setState(CLOSED$3);
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
},
|
|
962
|
+
onTransaction() {
|
|
963
|
+
if (this.storage.state.open && !this.storage.state.editing) return;
|
|
964
|
+
if (!this.options.autoOpenOnLinkActive) {
|
|
965
|
+
if (this.storage.state.open) this.storage.setState(CLOSED$3);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
this.storage.setState(computeAutoState(this.editor));
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
/** Configures the link editor extension. */
|
|
972
|
+
function linkEditor(options = {}) {
|
|
973
|
+
return LinkEditor.configure(options);
|
|
974
|
+
}
|
|
975
|
+
//#endregion
|
|
976
|
+
//#region src/bubble-toolbar.ts
|
|
977
|
+
function hasMark(editor, name) {
|
|
978
|
+
return editor.schema.marks[name] !== void 0;
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Ranks are irrelevant here (there's no query to match against); this just
|
|
982
|
+
* hides items the current schema or selection context can't run.
|
|
983
|
+
*/
|
|
984
|
+
function filterBubbleToolbarItems(items, editor) {
|
|
985
|
+
return items.filter((item) => item.when?.(editor) ?? true);
|
|
986
|
+
}
|
|
987
|
+
const defaultBubbleToolbarItems = [
|
|
988
|
+
{
|
|
989
|
+
id: "bold",
|
|
990
|
+
label: "Bold",
|
|
991
|
+
icon: "bold",
|
|
992
|
+
when: (editor) => hasMark(editor, "bold"),
|
|
993
|
+
isActive: (editor) => editor.isActive("bold"),
|
|
994
|
+
run: (editor) => {
|
|
995
|
+
editor.chain().focus().toggleBold().run();
|
|
996
|
+
}
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
id: "italic",
|
|
1000
|
+
label: "Italic",
|
|
1001
|
+
icon: "italic",
|
|
1002
|
+
when: (editor) => hasMark(editor, "italic"),
|
|
1003
|
+
isActive: (editor) => editor.isActive("italic"),
|
|
1004
|
+
run: (editor) => {
|
|
1005
|
+
editor.chain().focus().toggleItalic().run();
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
id: "strike",
|
|
1010
|
+
label: "Strikethrough",
|
|
1011
|
+
icon: "strikethrough",
|
|
1012
|
+
when: (editor) => hasMark(editor, "strike"),
|
|
1013
|
+
isActive: (editor) => editor.isActive("strike"),
|
|
1014
|
+
run: (editor) => {
|
|
1015
|
+
editor.chain().focus().toggleStrike().run();
|
|
1016
|
+
}
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
id: "code",
|
|
1020
|
+
label: "Inline code",
|
|
1021
|
+
icon: "code",
|
|
1022
|
+
when: (editor) => hasMark(editor, "code"),
|
|
1023
|
+
isActive: (editor) => editor.isActive("code"),
|
|
1024
|
+
run: (editor) => {
|
|
1025
|
+
editor.chain().focus().toggleCode().run();
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
id: "link",
|
|
1030
|
+
label: "Link",
|
|
1031
|
+
icon: "link",
|
|
1032
|
+
when: (editor) => canOpenLinkEditor(editor),
|
|
1033
|
+
isActive: (editor) => editor.isActive("link"),
|
|
1034
|
+
run: (editor) => {
|
|
1035
|
+
editor.commands.openLinkEditor();
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
];
|
|
1039
|
+
const CLOSED$2 = Object.freeze({
|
|
1040
|
+
open: false,
|
|
1041
|
+
items: Object.freeze([]),
|
|
1042
|
+
getClientRect: null
|
|
1043
|
+
});
|
|
1044
|
+
/**
|
|
1045
|
+
* Computes toolbar visibility from the live selection: open only for a
|
|
1046
|
+
* non-empty text selection in a focused, editable view with at least one
|
|
1047
|
+
* runnable item. Node selections (e.g. a whole callout) never show it.
|
|
1048
|
+
*/
|
|
1049
|
+
function computeState(editor, options) {
|
|
1050
|
+
const { view, state } = editor;
|
|
1051
|
+
const { selection, doc } = state;
|
|
1052
|
+
const { empty, from, to } = selection;
|
|
1053
|
+
if (!editor.isEditable || !view.hasFocus() || empty || !isTextSelection(selection) || !doc.textBetween(from, to).length) return CLOSED$2;
|
|
1054
|
+
const items = filterBubbleToolbarItems(typeof options.items === "function" ? options.items(editor) : options.items, editor);
|
|
1055
|
+
if (items.length === 0) return CLOSED$2;
|
|
1056
|
+
return {
|
|
1057
|
+
open: true,
|
|
1058
|
+
items,
|
|
1059
|
+
getClientRect: () => posToDOMRect(view, from, to)
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Selection-anchored inline formatting toolbar. Unlike the slash menu, there
|
|
1064
|
+
* is no query to rank: core only decides *whether* the toolbar is visible
|
|
1065
|
+
* and *which* items apply, and the React layer renders it fully controlled
|
|
1066
|
+
* (`item.isActive`/`item.run` are called straight from the demo on click).
|
|
1067
|
+
*/
|
|
1068
|
+
const BubbleToolbar = Extension.create({
|
|
1069
|
+
name: "bubbleToolbar",
|
|
1070
|
+
addOptions() {
|
|
1071
|
+
return { items: defaultBubbleToolbarItems };
|
|
1072
|
+
},
|
|
1073
|
+
addStorage() {
|
|
1074
|
+
return {
|
|
1075
|
+
state: CLOSED$2,
|
|
1076
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
1077
|
+
subscribe(listener) {
|
|
1078
|
+
this.listeners.add(listener);
|
|
1079
|
+
return () => this.listeners.delete(listener);
|
|
1080
|
+
},
|
|
1081
|
+
setState(next) {
|
|
1082
|
+
if (!this.state.open && !next.open) return;
|
|
1083
|
+
this.state = next;
|
|
1084
|
+
this.listeners.forEach((listener) => listener());
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
},
|
|
1088
|
+
onTransaction() {
|
|
1089
|
+
this.storage.setState(computeState(this.editor, this.options));
|
|
1090
|
+
},
|
|
1091
|
+
onFocus() {
|
|
1092
|
+
this.storage.setState(computeState(this.editor, this.options));
|
|
1093
|
+
},
|
|
1094
|
+
onBlur() {
|
|
1095
|
+
this.storage.setState(CLOSED$2);
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
/** Configures the bubble toolbar extension. */
|
|
1099
|
+
function bubbleToolbar(options = {}) {
|
|
1100
|
+
return BubbleToolbar.configure(options);
|
|
1101
|
+
}
|
|
1102
|
+
//#endregion
|
|
1103
|
+
//#region src/callout.ts
|
|
1104
|
+
/**
|
|
1105
|
+
* A highlighted aside with a leading emoji, wrapping arbitrary block content
|
|
1106
|
+
* (`content: block+`) rather than a flat textblock. Nesting follows the
|
|
1107
|
+
* container-node approach used across M1: no universal block wrapper.
|
|
1108
|
+
*/
|
|
1109
|
+
const Callout = Node.create({
|
|
1110
|
+
name: "callout",
|
|
1111
|
+
group: "block",
|
|
1112
|
+
content: "block+",
|
|
1113
|
+
defining: true,
|
|
1114
|
+
addOptions() {
|
|
1115
|
+
return {
|
|
1116
|
+
defaultIcon: "💡",
|
|
1117
|
+
HTMLAttributes: {}
|
|
1118
|
+
};
|
|
1119
|
+
},
|
|
1120
|
+
addAttributes() {
|
|
1121
|
+
return { icon: {
|
|
1122
|
+
default: this.options.defaultIcon,
|
|
1123
|
+
parseHTML: (element) => element.getAttribute("data-icon") ?? this.options.defaultIcon,
|
|
1124
|
+
renderHTML: (attributes) => ({ "data-icon": attributes.icon })
|
|
1125
|
+
} };
|
|
1126
|
+
},
|
|
1127
|
+
parseHTML() {
|
|
1128
|
+
return [{ tag: `div[data-type="${this.name}"]` }];
|
|
1129
|
+
},
|
|
1130
|
+
renderHTML({ HTMLAttributes }) {
|
|
1131
|
+
return [
|
|
1132
|
+
"div",
|
|
1133
|
+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-type": this.name }),
|
|
1134
|
+
0
|
|
1135
|
+
];
|
|
1136
|
+
},
|
|
1137
|
+
addCommands() {
|
|
1138
|
+
return {
|
|
1139
|
+
setCallout: () => ({ commands }) => commands.wrapIn(this.name),
|
|
1140
|
+
toggleCallout: () => ({ commands }) => commands.toggleWrap(this.name),
|
|
1141
|
+
unsetCallout: () => ({ commands }) => commands.lift(this.name)
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
/** Configures the callout node. */
|
|
1146
|
+
function callout(options = {}) {
|
|
1147
|
+
return Callout.configure(options);
|
|
1148
|
+
}
|
|
1149
|
+
//#endregion
|
|
1150
|
+
//#region src/collaboration.ts
|
|
1151
|
+
const DEFAULT_USER = {
|
|
1152
|
+
name: "Anonymous",
|
|
1153
|
+
color: "#94A3B8"
|
|
1154
|
+
};
|
|
1155
|
+
function isHexColor(value) {
|
|
1156
|
+
return typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value);
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Renders a remote caret as `data-*` attributes rather than the upstream
|
|
1160
|
+
* extension's default `collaboration-carets__*` class names, matching this
|
|
1161
|
+
* project's rule that core emits no class names — the demo styles carets
|
|
1162
|
+
* through `.slash-content` like every other node.
|
|
1163
|
+
*/
|
|
1164
|
+
function buildCaret(user) {
|
|
1165
|
+
const color = isHexColor(user.color) ? user.color : "transparent";
|
|
1166
|
+
const name = typeof user.name === "string" && user.name.length > 0 ? user.name : "Anonymous";
|
|
1167
|
+
const caret = document.createElement("span");
|
|
1168
|
+
caret.setAttribute("data-collab-caret", "");
|
|
1169
|
+
caret.style.borderColor = color;
|
|
1170
|
+
const label = document.createElement("span");
|
|
1171
|
+
label.setAttribute("data-collab-caret-label", "");
|
|
1172
|
+
label.style.backgroundColor = color;
|
|
1173
|
+
label.textContent = name;
|
|
1174
|
+
caret.append(label);
|
|
1175
|
+
return caret;
|
|
1176
|
+
}
|
|
1177
|
+
function buildSelection(user) {
|
|
1178
|
+
if (!isHexColor(user.color)) return {};
|
|
1179
|
+
return {
|
|
1180
|
+
nodeName: "span",
|
|
1181
|
+
"data-collab-selection": "",
|
|
1182
|
+
style: `background-color: ${user.color}33`
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Wires a shared Yjs document into the editor via Tiptap's official
|
|
1187
|
+
* `Collaboration`/`CollaborationCaret` extensions (themselves a thin layer
|
|
1188
|
+
* over `y-prosemirror`). `BlockId`'s remote-skip check already recognizes
|
|
1189
|
+
* the `"y-sync$"` transaction meta these extensions set, so ids stay
|
|
1190
|
+
* deterministic across peers with no further wiring.
|
|
1191
|
+
*
|
|
1192
|
+
* Callers must disable local undo/redo (`createBlockKit`'s `history:
|
|
1193
|
+
* false`): Yjs owns the undo stack once a document is shared, and running
|
|
1194
|
+
* both corrupts it.
|
|
1195
|
+
*/
|
|
1196
|
+
function collaboration(options) {
|
|
1197
|
+
const { document, field = "content", provider, user } = options;
|
|
1198
|
+
return [Collaboration.configure({
|
|
1199
|
+
document,
|
|
1200
|
+
field
|
|
1201
|
+
}), ...provider ? [CollaborationCaret.configure({
|
|
1202
|
+
provider,
|
|
1203
|
+
user: user ?? DEFAULT_USER,
|
|
1204
|
+
render: buildCaret,
|
|
1205
|
+
selectionRender: buildSelection
|
|
1206
|
+
})] : []];
|
|
1207
|
+
}
|
|
1208
|
+
//#endregion
|
|
1209
|
+
//#region src/columns.ts
|
|
1210
|
+
const MIN_COLUMNS = 2;
|
|
1211
|
+
const MAX_COLUMNS = 6;
|
|
1212
|
+
const DEFAULT_COLUMNS = 2;
|
|
1213
|
+
/**
|
|
1214
|
+
* A single column inside `Columns`. Not a `block`-group node itself — it
|
|
1215
|
+
* only exists as `columns`'s child — but its `block+` content still picks
|
|
1216
|
+
* up a `BlockId` (auto mode matches on `content`, not just `group`), giving
|
|
1217
|
+
* every nesting depth stable identity for comment anchoring (M4).
|
|
1218
|
+
*/
|
|
1219
|
+
const Column = Node.create({
|
|
1220
|
+
name: "column",
|
|
1221
|
+
content: "block+",
|
|
1222
|
+
isolating: true,
|
|
1223
|
+
addOptions() {
|
|
1224
|
+
return { HTMLAttributes: {} };
|
|
1225
|
+
},
|
|
1226
|
+
parseHTML() {
|
|
1227
|
+
return [{ tag: `div[data-type="${this.name}"]` }];
|
|
1228
|
+
},
|
|
1229
|
+
renderHTML({ HTMLAttributes }) {
|
|
1230
|
+
return [
|
|
1231
|
+
"div",
|
|
1232
|
+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-type": this.name }),
|
|
1233
|
+
0
|
|
1234
|
+
];
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
/**
|
|
1238
|
+
* Side-by-side layout container: the first M2 nesting surface beyond
|
|
1239
|
+
* lists. `content: "column{2,}"` bakes the two-column floor into the
|
|
1240
|
+
* schema itself, so there is no separate "remove last column" guard to
|
|
1241
|
+
* maintain — deleting a column below the minimum is simply not a legal
|
|
1242
|
+
* document.
|
|
1243
|
+
*/
|
|
1244
|
+
const Columns = Node.create({
|
|
1245
|
+
name: "columns",
|
|
1246
|
+
group: "block",
|
|
1247
|
+
content: "column{2,}",
|
|
1248
|
+
isolating: true,
|
|
1249
|
+
defining: true,
|
|
1250
|
+
addOptions() {
|
|
1251
|
+
return { HTMLAttributes: {} };
|
|
1252
|
+
},
|
|
1253
|
+
parseHTML() {
|
|
1254
|
+
return [{ tag: `div[data-type="${this.name}"]` }];
|
|
1255
|
+
},
|
|
1256
|
+
renderHTML({ HTMLAttributes }) {
|
|
1257
|
+
return [
|
|
1258
|
+
"div",
|
|
1259
|
+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-type": this.name }),
|
|
1260
|
+
0
|
|
1261
|
+
];
|
|
1262
|
+
},
|
|
1263
|
+
addCommands() {
|
|
1264
|
+
return { setColumns: (count = DEFAULT_COLUMNS) => ({ commands }) => {
|
|
1265
|
+
const clamped = Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, Math.round(count)));
|
|
1266
|
+
return commands.insertContent({
|
|
1267
|
+
type: this.name,
|
|
1268
|
+
content: Array.from({ length: clamped }, () => ({
|
|
1269
|
+
type: "column",
|
|
1270
|
+
content: [{ type: "paragraph" }]
|
|
1271
|
+
}))
|
|
1272
|
+
});
|
|
1273
|
+
} };
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
/** Configures the columns container node. */
|
|
1277
|
+
function columns(options = {}) {
|
|
1278
|
+
return Columns.configure(options);
|
|
1279
|
+
}
|
|
1280
|
+
/** Configures the column node. */
|
|
1281
|
+
function column(options = {}) {
|
|
1282
|
+
return Column.configure(options);
|
|
1283
|
+
}
|
|
1284
|
+
//#endregion
|
|
1285
|
+
//#region src/comment.ts
|
|
1286
|
+
const EMPTY_STATE = Object.freeze({ activeThreadIds: [] });
|
|
1287
|
+
/**
|
|
1288
|
+
* Distinct `threadId`s anchored under `state`'s selection: every mark
|
|
1289
|
+
* instance touching a non-empty range, or the marks that would apply to
|
|
1290
|
+
* text typed at a collapsed cursor. Pure and DOM-free so it is testable
|
|
1291
|
+
* against a bare `EditorState`.
|
|
1292
|
+
*/
|
|
1293
|
+
function activeThreadIds(state) {
|
|
1294
|
+
const markType = state.schema.marks.comment;
|
|
1295
|
+
if (!markType) return [];
|
|
1296
|
+
const { selection } = state;
|
|
1297
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1298
|
+
if (selection.empty) {
|
|
1299
|
+
const marks = state.storedMarks ?? selection.$from.marks();
|
|
1300
|
+
for (const mark of marks) if (mark.type === markType) ids.add(mark.attrs.threadId);
|
|
1301
|
+
return [...ids];
|
|
1302
|
+
}
|
|
1303
|
+
state.doc.nodesBetween(selection.from, selection.to, (node) => {
|
|
1304
|
+
for (const mark of node.marks) if (mark.type === markType) ids.add(mark.attrs.threadId);
|
|
1305
|
+
});
|
|
1306
|
+
return [...ids];
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Comment anchors are a mark, not a node: `threadId` is the only attribute,
|
|
1310
|
+
* so multiple distinct threads can anchor overlapping ranges (`excludes:
|
|
1311
|
+
* ""` opts out of ProseMirror's default same-type exclusion). Thread
|
|
1312
|
+
* bodies, authors, and resolved state never live here — see
|
|
1313
|
+
* `CommentThreadStore`.
|
|
1314
|
+
*/
|
|
1315
|
+
const Comment = Mark.create({
|
|
1316
|
+
name: "comment",
|
|
1317
|
+
excludes: "",
|
|
1318
|
+
inclusive: false,
|
|
1319
|
+
addOptions() {
|
|
1320
|
+
return { HTMLAttributes: {} };
|
|
1321
|
+
},
|
|
1322
|
+
addAttributes() {
|
|
1323
|
+
return { threadId: {
|
|
1324
|
+
default: null,
|
|
1325
|
+
parseHTML: (element) => element.getAttribute("data-thread-id"),
|
|
1326
|
+
renderHTML: (attributes) => attributes.threadId ? { "data-thread-id": attributes.threadId } : {}
|
|
1327
|
+
} };
|
|
1328
|
+
},
|
|
1329
|
+
parseHTML() {
|
|
1330
|
+
return [{ tag: `span[data-type="${this.name}"]` }];
|
|
1331
|
+
},
|
|
1332
|
+
renderHTML({ HTMLAttributes }) {
|
|
1333
|
+
return [
|
|
1334
|
+
"span",
|
|
1335
|
+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-type": this.name }),
|
|
1336
|
+
0
|
|
1337
|
+
];
|
|
1338
|
+
},
|
|
1339
|
+
addStorage() {
|
|
1340
|
+
return {
|
|
1341
|
+
state: EMPTY_STATE,
|
|
1342
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
1343
|
+
subscribe(listener) {
|
|
1344
|
+
this.listeners.add(listener);
|
|
1345
|
+
return () => this.listeners.delete(listener);
|
|
1346
|
+
},
|
|
1347
|
+
setState(next) {
|
|
1348
|
+
if (this.state.activeThreadIds.length === 0 && next.activeThreadIds.length === 0) return;
|
|
1349
|
+
this.state = next;
|
|
1350
|
+
this.listeners.forEach((listener) => listener());
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
},
|
|
1354
|
+
addCommands() {
|
|
1355
|
+
return {
|
|
1356
|
+
setComment: (threadId) => ({ commands }) => commands.setMark(this.name, { threadId }),
|
|
1357
|
+
unsetComment: (threadId) => ({ tr, state, dispatch }) => {
|
|
1358
|
+
const markType = state.schema.marks[this.name];
|
|
1359
|
+
if (!markType) return false;
|
|
1360
|
+
if (dispatch) {
|
|
1361
|
+
const { from, to } = state.selection;
|
|
1362
|
+
tr.removeMark(from, to, markType.create({ threadId }));
|
|
1363
|
+
}
|
|
1364
|
+
return true;
|
|
1365
|
+
},
|
|
1366
|
+
toggleComment: (threadId) => ({ state, commands }) => activeThreadIds(state).includes(threadId) ? commands.unsetComment(threadId) : commands.setComment(threadId)
|
|
1367
|
+
};
|
|
1368
|
+
},
|
|
1369
|
+
onTransaction() {
|
|
1370
|
+
this.storage.setState({ activeThreadIds: activeThreadIds(this.editor.state) });
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
/** Configures the comment mark. */
|
|
1374
|
+
function comment(options = {}) {
|
|
1375
|
+
return Comment.configure(options);
|
|
1376
|
+
}
|
|
1377
|
+
//#endregion
|
|
1378
|
+
//#region src/embed.ts
|
|
1379
|
+
function optionalAttrs(node) {
|
|
1380
|
+
const attrs = {};
|
|
1381
|
+
if (node.attrs.title) attrs["data-title"] = node.attrs.title;
|
|
1382
|
+
if (node.attrs.description) attrs["data-description"] = node.attrs.description;
|
|
1383
|
+
if (node.attrs.thumbnail) attrs["data-thumbnail"] = node.attrs.thumbnail;
|
|
1384
|
+
return attrs;
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* A block-level external embed: a bookmark link card or a sandboxed
|
|
1388
|
+
* iframe, chosen by `mode`. No upload adapter — the URL (and, for
|
|
1389
|
+
* bookmarks, the optional title/description/thumbnail metadata) is set
|
|
1390
|
+
* directly, either at insert time or later by a `NodeView` reading a
|
|
1391
|
+
* pasted link.
|
|
1392
|
+
*/
|
|
1393
|
+
const Embed = Node.create({
|
|
1394
|
+
name: "embed",
|
|
1395
|
+
group: "block",
|
|
1396
|
+
atom: true,
|
|
1397
|
+
addOptions() {
|
|
1398
|
+
return { HTMLAttributes: {} };
|
|
1399
|
+
},
|
|
1400
|
+
addAttributes() {
|
|
1401
|
+
return {
|
|
1402
|
+
url: { default: null },
|
|
1403
|
+
mode: { default: "bookmark" },
|
|
1404
|
+
title: { default: null },
|
|
1405
|
+
description: { default: null },
|
|
1406
|
+
thumbnail: { default: null }
|
|
1407
|
+
};
|
|
1408
|
+
},
|
|
1409
|
+
parseHTML() {
|
|
1410
|
+
return [{
|
|
1411
|
+
tag: `iframe[data-type="${this.name}"]`,
|
|
1412
|
+
getAttrs: (element) => ({
|
|
1413
|
+
url: element.getAttribute("src"),
|
|
1414
|
+
mode: "iframe",
|
|
1415
|
+
title: element.getAttribute("data-title"),
|
|
1416
|
+
description: element.getAttribute("data-description"),
|
|
1417
|
+
thumbnail: element.getAttribute("data-thumbnail")
|
|
1418
|
+
})
|
|
1419
|
+
}, {
|
|
1420
|
+
tag: `a[data-type="${this.name}"]`,
|
|
1421
|
+
getAttrs: (element) => ({
|
|
1422
|
+
url: element.getAttribute("href"),
|
|
1423
|
+
mode: "bookmark",
|
|
1424
|
+
title: element.getAttribute("data-title"),
|
|
1425
|
+
description: element.getAttribute("data-description"),
|
|
1426
|
+
thumbnail: element.getAttribute("data-thumbnail")
|
|
1427
|
+
})
|
|
1428
|
+
}];
|
|
1429
|
+
},
|
|
1430
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
1431
|
+
const shared = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
|
1432
|
+
"data-type": this.name,
|
|
1433
|
+
"data-mode": node.attrs.mode,
|
|
1434
|
+
...optionalAttrs(node)
|
|
1435
|
+
});
|
|
1436
|
+
if (node.attrs.mode === "iframe") return ["iframe", mergeAttributes(shared, {
|
|
1437
|
+
...node.attrs.url ? { src: node.attrs.url } : {},
|
|
1438
|
+
loading: "lazy",
|
|
1439
|
+
referrerpolicy: "no-referrer",
|
|
1440
|
+
sandbox: "allow-scripts allow-same-origin allow-popups"
|
|
1441
|
+
})];
|
|
1442
|
+
return [
|
|
1443
|
+
"a",
|
|
1444
|
+
mergeAttributes(shared, {
|
|
1445
|
+
...node.attrs.url ? { href: node.attrs.url } : {},
|
|
1446
|
+
target: "_blank",
|
|
1447
|
+
rel: "noopener noreferrer"
|
|
1448
|
+
}),
|
|
1449
|
+
node.attrs.title ?? node.attrs.url ?? "Untitled link"
|
|
1450
|
+
];
|
|
1451
|
+
},
|
|
1452
|
+
addCommands() {
|
|
1453
|
+
return { setEmbed: (options = {}) => ({ commands }) => commands.insertContent({
|
|
1454
|
+
type: this.name,
|
|
1455
|
+
attrs: {
|
|
1456
|
+
id: crypto.randomUUID(),
|
|
1457
|
+
url: options.url ?? null,
|
|
1458
|
+
mode: options.mode ?? "bookmark",
|
|
1459
|
+
title: options.title ?? null,
|
|
1460
|
+
description: options.description ?? null,
|
|
1461
|
+
thumbnail: options.thumbnail ?? null
|
|
1462
|
+
}
|
|
1463
|
+
}) };
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
/** Configures the embed node. */
|
|
1467
|
+
function embed(options = {}) {
|
|
1468
|
+
return Embed.configure(options);
|
|
1469
|
+
}
|
|
1470
|
+
//#endregion
|
|
1471
|
+
//#region src/file.ts
|
|
1472
|
+
function initialAttrs$2(id, options) {
|
|
1473
|
+
if (options && "file" in options) {
|
|
1474
|
+
const { file } = options;
|
|
1475
|
+
return {
|
|
1476
|
+
id,
|
|
1477
|
+
src: null,
|
|
1478
|
+
name: file.name,
|
|
1479
|
+
size: file.size,
|
|
1480
|
+
mime: file.type || null,
|
|
1481
|
+
status: "uploading",
|
|
1482
|
+
error: null
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
return {
|
|
1486
|
+
id,
|
|
1487
|
+
src: options?.src ?? null,
|
|
1488
|
+
name: options?.name ?? null,
|
|
1489
|
+
size: options?.size ?? null,
|
|
1490
|
+
mime: options?.mime ?? null,
|
|
1491
|
+
status: "ready",
|
|
1492
|
+
error: null
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* A block-level generic-file attachment. Same upload/retry shape as
|
|
1497
|
+
* `Image`: `status`/`error` live in doc attrs, the picked `File` lives in
|
|
1498
|
+
* `storage.pending` keyed by `id`. See `image.ts` for the full rationale.
|
|
1499
|
+
*/
|
|
1500
|
+
const File = Node.create({
|
|
1501
|
+
name: "file",
|
|
1502
|
+
group: "block",
|
|
1503
|
+
atom: true,
|
|
1504
|
+
addOptions() {
|
|
1505
|
+
return { HTMLAttributes: {} };
|
|
1506
|
+
},
|
|
1507
|
+
addStorage() {
|
|
1508
|
+
return { pending: new PendingUploadRegistry() };
|
|
1509
|
+
},
|
|
1510
|
+
addAttributes() {
|
|
1511
|
+
return {
|
|
1512
|
+
src: { default: null },
|
|
1513
|
+
name: { default: null },
|
|
1514
|
+
size: {
|
|
1515
|
+
default: null,
|
|
1516
|
+
parseHTML: (element) => {
|
|
1517
|
+
const value = element.getAttribute("data-size");
|
|
1518
|
+
return value ? Number(value) : null;
|
|
1519
|
+
},
|
|
1520
|
+
renderHTML: (attributes) => attributes.size != null ? { "data-size": String(attributes.size) } : {}
|
|
1521
|
+
},
|
|
1522
|
+
mime: {
|
|
1523
|
+
default: null,
|
|
1524
|
+
parseHTML: (element) => element.getAttribute("data-mime"),
|
|
1525
|
+
renderHTML: (attributes) => attributes.mime ? { "data-mime": attributes.mime } : {}
|
|
1526
|
+
},
|
|
1527
|
+
status: {
|
|
1528
|
+
default: "ready",
|
|
1529
|
+
parseHTML: (element) => element.getAttribute("data-status") ?? "ready",
|
|
1530
|
+
renderHTML: (attributes) => ({ "data-status": attributes.status })
|
|
1531
|
+
},
|
|
1532
|
+
error: {
|
|
1533
|
+
default: null,
|
|
1534
|
+
parseHTML: (element) => element.getAttribute("data-error"),
|
|
1535
|
+
renderHTML: (attributes) => attributes.error ? { "data-error": attributes.error } : {}
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
},
|
|
1539
|
+
parseHTML() {
|
|
1540
|
+
return [{ tag: `div[data-type="${this.name}"]` }];
|
|
1541
|
+
},
|
|
1542
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
1543
|
+
return [
|
|
1544
|
+
"div",
|
|
1545
|
+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-type": this.name }),
|
|
1546
|
+
[
|
|
1547
|
+
"a",
|
|
1548
|
+
{
|
|
1549
|
+
href: node.attrs.src ?? void 0,
|
|
1550
|
+
download: node.attrs.name ?? void 0,
|
|
1551
|
+
target: "_blank",
|
|
1552
|
+
rel: "noopener noreferrer"
|
|
1553
|
+
},
|
|
1554
|
+
node.attrs.name ?? "Untitled file"
|
|
1555
|
+
]
|
|
1556
|
+
];
|
|
1557
|
+
},
|
|
1558
|
+
addCommands() {
|
|
1559
|
+
return {
|
|
1560
|
+
setFile: (options) => ({ commands, dispatch }) => {
|
|
1561
|
+
const id = crypto.randomUUID();
|
|
1562
|
+
const inserted = commands.insertContent({
|
|
1563
|
+
type: this.name,
|
|
1564
|
+
attrs: initialAttrs$2(id, options)
|
|
1565
|
+
});
|
|
1566
|
+
if (inserted && dispatch && options && "file" in options) {
|
|
1567
|
+
const { file, adapter } = options;
|
|
1568
|
+
queueMicrotask(() => {
|
|
1569
|
+
runUpload({
|
|
1570
|
+
editor: this.editor,
|
|
1571
|
+
typeName: this.name,
|
|
1572
|
+
id,
|
|
1573
|
+
file,
|
|
1574
|
+
adapter,
|
|
1575
|
+
pending: this.storage.pending,
|
|
1576
|
+
toAttrs: (result) => ({ src: result.url })
|
|
1577
|
+
});
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
return inserted;
|
|
1581
|
+
},
|
|
1582
|
+
retryFile: (id, override) => ({ dispatch }) => {
|
|
1583
|
+
const hasEntry = override !== void 0 || this.storage.pending.get(id) !== void 0;
|
|
1584
|
+
if (!dispatch || !hasEntry) return hasEntry;
|
|
1585
|
+
queueMicrotask(() => {
|
|
1586
|
+
retryUpload({
|
|
1587
|
+
editor: this.editor,
|
|
1588
|
+
typeName: this.name,
|
|
1589
|
+
id,
|
|
1590
|
+
pending: this.storage.pending,
|
|
1591
|
+
toAttrs: (result) => ({ src: result.url }),
|
|
1592
|
+
override
|
|
1593
|
+
});
|
|
1594
|
+
});
|
|
1595
|
+
return true;
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
/** Configures the file node. */
|
|
1601
|
+
function file(options = {}) {
|
|
1602
|
+
return File.configure(options);
|
|
1603
|
+
}
|
|
1604
|
+
//#endregion
|
|
1605
|
+
//#region src/image.ts
|
|
1606
|
+
function initialAttrs$1(id, options) {
|
|
1607
|
+
if (options && "file" in options) return {
|
|
1608
|
+
id,
|
|
1609
|
+
src: null,
|
|
1610
|
+
alt: options.alt ?? null,
|
|
1611
|
+
width: null,
|
|
1612
|
+
status: "uploading",
|
|
1613
|
+
error: null
|
|
1614
|
+
};
|
|
1615
|
+
return {
|
|
1616
|
+
id,
|
|
1617
|
+
src: options?.src ?? null,
|
|
1618
|
+
alt: options?.alt ?? null,
|
|
1619
|
+
width: options?.width ?? null,
|
|
1620
|
+
status: "ready",
|
|
1621
|
+
error: null
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* A block-level image, optionally backed by an in-flight `UploadAdapter`
|
|
1626
|
+
* upload. `status`/`error` live in node attrs (part of the doc) so a
|
|
1627
|
+
* completed or failed upload re-renders through the normal transaction
|
|
1628
|
+
* pipeline — no separate subscribe/storage channel like the slash menu or
|
|
1629
|
+
* bubble toolbar, which track ephemeral UI state instead of document state.
|
|
1630
|
+
*
|
|
1631
|
+
* The picked `File` itself never touches attrs (not JSON-serializable, not
|
|
1632
|
+
* Yjs-safe); it lives in `storage.pending`, keyed by the node's `BlockId`,
|
|
1633
|
+
* so `retryImage` can resend it without the user picking again.
|
|
1634
|
+
*/
|
|
1635
|
+
const Image = Node.create({
|
|
1636
|
+
name: "image",
|
|
1637
|
+
group: "block",
|
|
1638
|
+
atom: true,
|
|
1639
|
+
addOptions() {
|
|
1640
|
+
return { HTMLAttributes: {} };
|
|
1641
|
+
},
|
|
1642
|
+
addStorage() {
|
|
1643
|
+
return { pending: new PendingUploadRegistry() };
|
|
1644
|
+
},
|
|
1645
|
+
addAttributes() {
|
|
1646
|
+
return {
|
|
1647
|
+
src: { default: null },
|
|
1648
|
+
alt: { default: null },
|
|
1649
|
+
width: {
|
|
1650
|
+
default: null,
|
|
1651
|
+
parseHTML: (element) => {
|
|
1652
|
+
const value = element.getAttribute("width");
|
|
1653
|
+
return value ? Number(value) : null;
|
|
1654
|
+
}
|
|
1655
|
+
},
|
|
1656
|
+
status: {
|
|
1657
|
+
default: "ready",
|
|
1658
|
+
parseHTML: (element) => element.getAttribute("data-status") ?? "ready",
|
|
1659
|
+
renderHTML: (attributes) => ({ "data-status": attributes.status })
|
|
1660
|
+
},
|
|
1661
|
+
error: {
|
|
1662
|
+
default: null,
|
|
1663
|
+
parseHTML: (element) => element.getAttribute("data-error"),
|
|
1664
|
+
renderHTML: (attributes) => attributes.error ? { "data-error": attributes.error } : {}
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1667
|
+
},
|
|
1668
|
+
parseHTML() {
|
|
1669
|
+
return [{ tag: "img[src]" }];
|
|
1670
|
+
},
|
|
1671
|
+
renderHTML({ HTMLAttributes }) {
|
|
1672
|
+
return ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-type": this.name })];
|
|
1673
|
+
},
|
|
1674
|
+
addCommands() {
|
|
1675
|
+
return {
|
|
1676
|
+
setImage: (options) => ({ commands, dispatch }) => {
|
|
1677
|
+
const id = crypto.randomUUID();
|
|
1678
|
+
const inserted = commands.insertContent({
|
|
1679
|
+
type: this.name,
|
|
1680
|
+
attrs: initialAttrs$1(id, options)
|
|
1681
|
+
});
|
|
1682
|
+
if (inserted && dispatch && options && "file" in options) {
|
|
1683
|
+
const { file, adapter } = options;
|
|
1684
|
+
queueMicrotask(() => {
|
|
1685
|
+
runUpload({
|
|
1686
|
+
editor: this.editor,
|
|
1687
|
+
typeName: this.name,
|
|
1688
|
+
id,
|
|
1689
|
+
file,
|
|
1690
|
+
adapter,
|
|
1691
|
+
pending: this.storage.pending,
|
|
1692
|
+
toAttrs: (result) => ({ src: result.url })
|
|
1693
|
+
});
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
return inserted;
|
|
1697
|
+
},
|
|
1698
|
+
retryImage: (id, override) => ({ dispatch }) => {
|
|
1699
|
+
const hasEntry = override !== void 0 || this.storage.pending.get(id) !== void 0;
|
|
1700
|
+
if (!dispatch || !hasEntry) return hasEntry;
|
|
1701
|
+
queueMicrotask(() => {
|
|
1702
|
+
retryUpload({
|
|
1703
|
+
editor: this.editor,
|
|
1704
|
+
typeName: this.name,
|
|
1705
|
+
id,
|
|
1706
|
+
pending: this.storage.pending,
|
|
1707
|
+
toAttrs: (result) => ({ src: result.url }),
|
|
1708
|
+
override
|
|
1709
|
+
});
|
|
1710
|
+
});
|
|
1711
|
+
return true;
|
|
1712
|
+
}
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
/** Configures the image node. */
|
|
1717
|
+
function image(options = {}) {
|
|
1718
|
+
return Image.configure(options);
|
|
1719
|
+
}
|
|
1720
|
+
//#endregion
|
|
1721
|
+
//#region src/mention.ts
|
|
1722
|
+
const mentionPluginKey = new PluginKey("mention");
|
|
1723
|
+
const CLOSED$1 = Object.freeze({
|
|
1724
|
+
open: false,
|
|
1725
|
+
query: "",
|
|
1726
|
+
items: Object.freeze([]),
|
|
1727
|
+
activeIndex: -1,
|
|
1728
|
+
loading: false,
|
|
1729
|
+
getClientRect: null
|
|
1730
|
+
});
|
|
1731
|
+
/**
|
|
1732
|
+
* `@`-mention node backed by an async, bring-your-own provider. Mirrors
|
|
1733
|
+
* `SlashCommand`'s architecture (a `Suggestion` plugin, per-editor storage
|
|
1734
|
+
* with a `subscribe`/state-machine shape) with one addition: `items` may
|
|
1735
|
+
* return a `Promise`, so `state.loading` reflects an in-flight request the
|
|
1736
|
+
* slash menu never has to model.
|
|
1737
|
+
*/
|
|
1738
|
+
const Mention = Node.create({
|
|
1739
|
+
name: "mention",
|
|
1740
|
+
group: "inline",
|
|
1741
|
+
inline: true,
|
|
1742
|
+
atom: true,
|
|
1743
|
+
selectable: true,
|
|
1744
|
+
addOptions() {
|
|
1745
|
+
return {
|
|
1746
|
+
char: "@",
|
|
1747
|
+
items: () => [],
|
|
1748
|
+
debounce: 150,
|
|
1749
|
+
minQueryLength: 0,
|
|
1750
|
+
HTMLAttributes: {}
|
|
1751
|
+
};
|
|
1752
|
+
},
|
|
1753
|
+
addAttributes() {
|
|
1754
|
+
return {
|
|
1755
|
+
id: {
|
|
1756
|
+
default: null,
|
|
1757
|
+
parseHTML: (element) => element.getAttribute("data-id")
|
|
1758
|
+
},
|
|
1759
|
+
label: {
|
|
1760
|
+
default: null,
|
|
1761
|
+
parseHTML: (element) => element.textContent?.replace(/^@/, "") ?? null
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
},
|
|
1765
|
+
parseHTML() {
|
|
1766
|
+
return [{ tag: `span[data-type="${this.name}"]` }];
|
|
1767
|
+
},
|
|
1768
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
1769
|
+
return [
|
|
1770
|
+
"span",
|
|
1771
|
+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
|
1772
|
+
"data-type": this.name,
|
|
1773
|
+
"data-id": node.attrs.id
|
|
1774
|
+
}),
|
|
1775
|
+
`@${node.attrs.label}`
|
|
1776
|
+
];
|
|
1777
|
+
},
|
|
1778
|
+
renderText({ node }) {
|
|
1779
|
+
return `@${node.attrs.label}`;
|
|
1780
|
+
},
|
|
1781
|
+
addCommands() {
|
|
1782
|
+
return { insertMention: (item) => ({ chain }) => chain().insertContent({
|
|
1783
|
+
type: this.name,
|
|
1784
|
+
attrs: {
|
|
1785
|
+
id: item.id,
|
|
1786
|
+
label: item.label
|
|
1787
|
+
}
|
|
1788
|
+
}).insertContent(" ").run() };
|
|
1789
|
+
},
|
|
1790
|
+
addStorage() {
|
|
1791
|
+
return {
|
|
1792
|
+
state: CLOSED$1,
|
|
1793
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
1794
|
+
active: null,
|
|
1795
|
+
subscribe(listener) {
|
|
1796
|
+
this.listeners.add(listener);
|
|
1797
|
+
return () => this.listeners.delete(listener);
|
|
1798
|
+
},
|
|
1799
|
+
setActive(props) {
|
|
1800
|
+
this.active = props;
|
|
1801
|
+
if (!props) {
|
|
1802
|
+
if (this.state !== CLOSED$1) {
|
|
1803
|
+
this.state = CLOSED$1;
|
|
1804
|
+
this.listeners.forEach((listener) => listener());
|
|
1805
|
+
}
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
const sameItems = this.state.items.length === props.items.length && this.state.items.every((item, index) => item === props.items[index]);
|
|
1809
|
+
this.state = {
|
|
1810
|
+
open: true,
|
|
1811
|
+
query: props.query,
|
|
1812
|
+
items: props.items,
|
|
1813
|
+
activeIndex: props.items.length === 0 ? -1 : sameItems ? this.state.activeIndex : 0,
|
|
1814
|
+
loading: props.loading,
|
|
1815
|
+
getClientRect: props.clientRect ?? null
|
|
1816
|
+
};
|
|
1817
|
+
this.listeners.forEach((listener) => listener());
|
|
1818
|
+
},
|
|
1819
|
+
setActiveIndex(index) {
|
|
1820
|
+
const { items } = this.state;
|
|
1821
|
+
if (!this.state.open || items.length === 0) return;
|
|
1822
|
+
const wrapped = (index % items.length + items.length) % items.length;
|
|
1823
|
+
if (wrapped !== this.state.activeIndex) {
|
|
1824
|
+
this.state = {
|
|
1825
|
+
...this.state,
|
|
1826
|
+
activeIndex: wrapped
|
|
1827
|
+
};
|
|
1828
|
+
this.listeners.forEach((listener) => listener());
|
|
1829
|
+
}
|
|
1830
|
+
},
|
|
1831
|
+
select(index) {
|
|
1832
|
+
const item = this.state.items[index ?? this.state.activeIndex];
|
|
1833
|
+
if (this.active && item) this.active.command(item);
|
|
1834
|
+
},
|
|
1835
|
+
close() {
|
|
1836
|
+
this.active?.editor.commands.focus();
|
|
1837
|
+
this.setActive(null);
|
|
1838
|
+
}
|
|
1839
|
+
};
|
|
1840
|
+
},
|
|
1841
|
+
addProseMirrorPlugins() {
|
|
1842
|
+
const { editor } = this;
|
|
1843
|
+
const getStorage = () => editor.storage.mention;
|
|
1844
|
+
const { onError } = this.options;
|
|
1845
|
+
return [Suggestion({
|
|
1846
|
+
editor,
|
|
1847
|
+
char: this.options.char,
|
|
1848
|
+
pluginKey: mentionPluginKey,
|
|
1849
|
+
allowSpaces: false,
|
|
1850
|
+
debounce: this.options.debounce,
|
|
1851
|
+
minQueryLength: this.options.minQueryLength,
|
|
1852
|
+
items: async ({ query, signal }) => {
|
|
1853
|
+
try {
|
|
1854
|
+
return await this.options.items(query, {
|
|
1855
|
+
editor,
|
|
1856
|
+
signal
|
|
1857
|
+
});
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
if (signal.aborted) return [];
|
|
1860
|
+
onError?.(error, { editor });
|
|
1861
|
+
return [];
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
command: ({ editor: instance, range, props: item }) => {
|
|
1865
|
+
instance.chain().focus().insertContentAt(range, [{
|
|
1866
|
+
type: "mention",
|
|
1867
|
+
attrs: {
|
|
1868
|
+
id: item.id,
|
|
1869
|
+
label: item.label
|
|
1870
|
+
}
|
|
1871
|
+
}, {
|
|
1872
|
+
type: "text",
|
|
1873
|
+
text: " "
|
|
1874
|
+
}]).run();
|
|
1875
|
+
},
|
|
1876
|
+
render: () => ({
|
|
1877
|
+
onStart: (props) => getStorage().setActive(props),
|
|
1878
|
+
onUpdate: (props) => getStorage().setActive(props),
|
|
1879
|
+
onExit: () => getStorage().setActive(null),
|
|
1880
|
+
onKeyDown: ({ event }) => {
|
|
1881
|
+
const storage = getStorage();
|
|
1882
|
+
if (!storage.state.open) return false;
|
|
1883
|
+
switch (event.key) {
|
|
1884
|
+
case "ArrowDown":
|
|
1885
|
+
storage.setActiveIndex(storage.state.activeIndex + 1);
|
|
1886
|
+
return true;
|
|
1887
|
+
case "ArrowUp":
|
|
1888
|
+
storage.setActiveIndex(storage.state.activeIndex - 1);
|
|
1889
|
+
return true;
|
|
1890
|
+
case "Enter":
|
|
1891
|
+
case "Tab":
|
|
1892
|
+
if (storage.state.items.length === 0) return false;
|
|
1893
|
+
storage.select();
|
|
1894
|
+
return true;
|
|
1895
|
+
default: return false;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
})
|
|
1899
|
+
})];
|
|
1900
|
+
}
|
|
1901
|
+
});
|
|
1902
|
+
/** Configures the mention node. `items` is required — there is no default provider. */
|
|
1903
|
+
function mention(options) {
|
|
1904
|
+
return Mention.configure(options);
|
|
1905
|
+
}
|
|
1906
|
+
//#endregion
|
|
1907
|
+
//#region src/slash-items.ts
|
|
1908
|
+
const TITLE_PREFIX = 100;
|
|
1909
|
+
const TITLE_WORD_PREFIX = 80;
|
|
1910
|
+
const ALIAS_PREFIX = 60;
|
|
1911
|
+
const TITLE_SUBSTRING = 40;
|
|
1912
|
+
const KEYWORD_MATCH = 20;
|
|
1913
|
+
function score(item, query) {
|
|
1914
|
+
const title = item.title.toLowerCase();
|
|
1915
|
+
if (title.startsWith(query)) return TITLE_PREFIX;
|
|
1916
|
+
if (title.split(/\s+/).some((word) => word.startsWith(query))) return TITLE_WORD_PREFIX;
|
|
1917
|
+
if (item.aliases?.some((alias) => alias.toLowerCase().startsWith(query))) return ALIAS_PREFIX;
|
|
1918
|
+
if (title.includes(query)) return TITLE_SUBSTRING;
|
|
1919
|
+
if (item.keywords?.some((keyword) => keyword.toLowerCase().includes(query))) return KEYWORD_MATCH;
|
|
1920
|
+
return 0;
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Ranks items against a slash query. Ties keep declaration order, so the item
|
|
1924
|
+
* list doubles as the default ordering of the menu.
|
|
1925
|
+
*/
|
|
1926
|
+
function filterSlashItems(items, query, editor) {
|
|
1927
|
+
const available = editor ? items.filter((item) => item.when?.(editor) ?? true) : items;
|
|
1928
|
+
const normalized = query.trim().toLowerCase();
|
|
1929
|
+
if (!normalized) return available;
|
|
1930
|
+
return available.map((item) => ({
|
|
1931
|
+
item,
|
|
1932
|
+
score: score(item, normalized)
|
|
1933
|
+
})).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score).map((entry) => entry.item);
|
|
1934
|
+
}
|
|
1935
|
+
const BASIC = "Basic blocks";
|
|
1936
|
+
const MEDIA = "Media";
|
|
1937
|
+
const STRUCTURE = "Structure";
|
|
1938
|
+
function hasNode(editor, name) {
|
|
1939
|
+
return editor.schema.nodes[name] !== void 0;
|
|
1940
|
+
}
|
|
1941
|
+
const defaultSlashItems = [
|
|
1942
|
+
{
|
|
1943
|
+
id: "paragraph",
|
|
1944
|
+
title: "Text",
|
|
1945
|
+
group: BASIC,
|
|
1946
|
+
description: "Plain paragraph",
|
|
1947
|
+
aliases: ["paragraph", "plain"],
|
|
1948
|
+
keywords: ["body", "p"],
|
|
1949
|
+
icon: "text",
|
|
1950
|
+
when: (editor) => hasNode(editor, "paragraph"),
|
|
1951
|
+
run: ({ editor, range }) => {
|
|
1952
|
+
editor.chain().focus().deleteRange(range).setParagraph().run();
|
|
1953
|
+
}
|
|
1954
|
+
},
|
|
1955
|
+
{
|
|
1956
|
+
id: "heading-1",
|
|
1957
|
+
title: "Heading 1",
|
|
1958
|
+
group: BASIC,
|
|
1959
|
+
description: "Large section title",
|
|
1960
|
+
aliases: ["h1", "title"],
|
|
1961
|
+
keywords: ["#"],
|
|
1962
|
+
icon: "heading-1",
|
|
1963
|
+
when: (editor) => hasNode(editor, "heading"),
|
|
1964
|
+
run: ({ editor, range }) => {
|
|
1965
|
+
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
|
1966
|
+
}
|
|
1967
|
+
},
|
|
1968
|
+
{
|
|
1969
|
+
id: "heading-2",
|
|
1970
|
+
title: "Heading 2",
|
|
1971
|
+
group: BASIC,
|
|
1972
|
+
description: "Medium section title",
|
|
1973
|
+
aliases: ["h2", "subtitle"],
|
|
1974
|
+
keywords: ["##"],
|
|
1975
|
+
icon: "heading-2",
|
|
1976
|
+
when: (editor) => hasNode(editor, "heading"),
|
|
1977
|
+
run: ({ editor, range }) => {
|
|
1978
|
+
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
|
1979
|
+
}
|
|
1980
|
+
},
|
|
1981
|
+
{
|
|
1982
|
+
id: "heading-3",
|
|
1983
|
+
title: "Heading 3",
|
|
1984
|
+
group: BASIC,
|
|
1985
|
+
description: "Small section title",
|
|
1986
|
+
aliases: ["h3"],
|
|
1987
|
+
keywords: ["###"],
|
|
1988
|
+
icon: "heading-3",
|
|
1989
|
+
when: (editor) => hasNode(editor, "heading"),
|
|
1990
|
+
run: ({ editor, range }) => {
|
|
1991
|
+
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
|
1992
|
+
}
|
|
1993
|
+
},
|
|
1994
|
+
{
|
|
1995
|
+
id: "bullet-list",
|
|
1996
|
+
title: "Bulleted list",
|
|
1997
|
+
group: BASIC,
|
|
1998
|
+
description: "Unordered list",
|
|
1999
|
+
aliases: ["ul", "bullet"],
|
|
2000
|
+
keywords: ["-", "*"],
|
|
2001
|
+
icon: "list",
|
|
2002
|
+
when: (editor) => hasNode(editor, "bulletList"),
|
|
2003
|
+
run: ({ editor, range }) => {
|
|
2004
|
+
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
|
2005
|
+
}
|
|
2006
|
+
},
|
|
2007
|
+
{
|
|
2008
|
+
id: "ordered-list",
|
|
2009
|
+
title: "Numbered list",
|
|
2010
|
+
group: BASIC,
|
|
2011
|
+
description: "Ordered list",
|
|
2012
|
+
aliases: ["ol", "numbered"],
|
|
2013
|
+
keywords: ["1."],
|
|
2014
|
+
icon: "list-ordered",
|
|
2015
|
+
when: (editor) => hasNode(editor, "orderedList"),
|
|
2016
|
+
run: ({ editor, range }) => {
|
|
2017
|
+
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
|
2018
|
+
}
|
|
2019
|
+
},
|
|
2020
|
+
{
|
|
2021
|
+
id: "task-list",
|
|
2022
|
+
title: "To-do list",
|
|
2023
|
+
group: BASIC,
|
|
2024
|
+
description: "Checklist with checkboxes",
|
|
2025
|
+
aliases: [
|
|
2026
|
+
"todo",
|
|
2027
|
+
"checklist",
|
|
2028
|
+
"checkbox"
|
|
2029
|
+
],
|
|
2030
|
+
keywords: ["[]", "[ ]"],
|
|
2031
|
+
icon: "list-checks",
|
|
2032
|
+
when: (editor) => hasNode(editor, "taskList"),
|
|
2033
|
+
run: ({ editor, range }) => {
|
|
2034
|
+
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
|
2035
|
+
}
|
|
2036
|
+
},
|
|
2037
|
+
{
|
|
2038
|
+
id: "blockquote",
|
|
2039
|
+
title: "Quote",
|
|
2040
|
+
group: BASIC,
|
|
2041
|
+
description: "Capture a quotation",
|
|
2042
|
+
aliases: ["quote", "citation"],
|
|
2043
|
+
keywords: [">"],
|
|
2044
|
+
icon: "quote",
|
|
2045
|
+
when: (editor) => hasNode(editor, "blockquote"),
|
|
2046
|
+
run: ({ editor, range }) => {
|
|
2047
|
+
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
|
2048
|
+
}
|
|
2049
|
+
},
|
|
2050
|
+
{
|
|
2051
|
+
id: "callout",
|
|
2052
|
+
title: "Callout",
|
|
2053
|
+
group: BASIC,
|
|
2054
|
+
description: "Highlighted aside",
|
|
2055
|
+
aliases: [
|
|
2056
|
+
"note",
|
|
2057
|
+
"info",
|
|
2058
|
+
"tip"
|
|
2059
|
+
],
|
|
2060
|
+
keywords: ["!"],
|
|
2061
|
+
icon: "message-square",
|
|
2062
|
+
when: (editor) => hasNode(editor, "callout"),
|
|
2063
|
+
run: ({ editor, range }) => {
|
|
2064
|
+
editor.chain().focus().deleteRange(range).setCallout().run();
|
|
2065
|
+
}
|
|
2066
|
+
},
|
|
2067
|
+
{
|
|
2068
|
+
id: "toggle",
|
|
2069
|
+
title: "Toggle list",
|
|
2070
|
+
group: BASIC,
|
|
2071
|
+
description: "Collapsible content",
|
|
2072
|
+
aliases: [
|
|
2073
|
+
"details",
|
|
2074
|
+
"collapse",
|
|
2075
|
+
"dropdown"
|
|
2076
|
+
],
|
|
2077
|
+
keywords: ["toggle"],
|
|
2078
|
+
icon: "chevron-right",
|
|
2079
|
+
when: (editor) => hasNode(editor, "details"),
|
|
2080
|
+
run: ({ editor, range }) => {
|
|
2081
|
+
editor.chain().focus().deleteRange(range).setDetails().run();
|
|
2082
|
+
}
|
|
2083
|
+
},
|
|
2084
|
+
{
|
|
2085
|
+
id: "code-block",
|
|
2086
|
+
title: "Code block",
|
|
2087
|
+
group: BASIC,
|
|
2088
|
+
description: "Monospaced code",
|
|
2089
|
+
aliases: ["code", "snippet"],
|
|
2090
|
+
keywords: ["```"],
|
|
2091
|
+
icon: "code",
|
|
2092
|
+
when: (editor) => hasNode(editor, "codeBlock"),
|
|
2093
|
+
run: ({ editor, range }) => {
|
|
2094
|
+
editor.chain().focus().deleteRange(range).setCodeBlock().run();
|
|
2095
|
+
}
|
|
2096
|
+
},
|
|
2097
|
+
{
|
|
2098
|
+
id: "horizontal-rule",
|
|
2099
|
+
title: "Divider",
|
|
2100
|
+
group: BASIC,
|
|
2101
|
+
description: "Visual separator",
|
|
2102
|
+
aliases: [
|
|
2103
|
+
"divider",
|
|
2104
|
+
"hr",
|
|
2105
|
+
"rule"
|
|
2106
|
+
],
|
|
2107
|
+
keywords: ["---"],
|
|
2108
|
+
icon: "minus",
|
|
2109
|
+
when: (editor) => hasNode(editor, "horizontalRule"),
|
|
2110
|
+
run: ({ editor, range }) => {
|
|
2111
|
+
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
|
2112
|
+
}
|
|
2113
|
+
},
|
|
2114
|
+
{
|
|
2115
|
+
id: "image",
|
|
2116
|
+
title: "Image",
|
|
2117
|
+
group: MEDIA,
|
|
2118
|
+
description: "Upload or embed an image",
|
|
2119
|
+
aliases: [
|
|
2120
|
+
"image",
|
|
2121
|
+
"picture",
|
|
2122
|
+
"photo"
|
|
2123
|
+
],
|
|
2124
|
+
keywords: ["img"],
|
|
2125
|
+
icon: "image",
|
|
2126
|
+
when: (editor) => hasNode(editor, "image"),
|
|
2127
|
+
run: ({ editor, range }) => {
|
|
2128
|
+
editor.chain().focus().deleteRange(range).setImage().run();
|
|
2129
|
+
}
|
|
2130
|
+
},
|
|
2131
|
+
{
|
|
2132
|
+
id: "file",
|
|
2133
|
+
title: "File",
|
|
2134
|
+
group: MEDIA,
|
|
2135
|
+
description: "Upload or attach a file",
|
|
2136
|
+
aliases: [
|
|
2137
|
+
"file",
|
|
2138
|
+
"attachment",
|
|
2139
|
+
"upload"
|
|
2140
|
+
],
|
|
2141
|
+
keywords: ["doc", "pdf"],
|
|
2142
|
+
icon: "paperclip",
|
|
2143
|
+
when: (editor) => hasNode(editor, "file"),
|
|
2144
|
+
run: ({ editor, range }) => {
|
|
2145
|
+
editor.chain().focus().deleteRange(range).setFile().run();
|
|
2146
|
+
}
|
|
2147
|
+
},
|
|
2148
|
+
{
|
|
2149
|
+
id: "video",
|
|
2150
|
+
title: "Video",
|
|
2151
|
+
group: MEDIA,
|
|
2152
|
+
description: "Upload or embed a video",
|
|
2153
|
+
aliases: ["video", "movie"],
|
|
2154
|
+
keywords: ["mp4"],
|
|
2155
|
+
icon: "video",
|
|
2156
|
+
when: (editor) => hasNode(editor, "video"),
|
|
2157
|
+
run: ({ editor, range }) => {
|
|
2158
|
+
editor.chain().focus().deleteRange(range).setVideo().run();
|
|
2159
|
+
}
|
|
2160
|
+
},
|
|
2161
|
+
{
|
|
2162
|
+
id: "embed",
|
|
2163
|
+
title: "Embed",
|
|
2164
|
+
group: MEDIA,
|
|
2165
|
+
description: "Bookmark or iframe a link",
|
|
2166
|
+
aliases: [
|
|
2167
|
+
"embed",
|
|
2168
|
+
"bookmark",
|
|
2169
|
+
"link",
|
|
2170
|
+
"iframe"
|
|
2171
|
+
],
|
|
2172
|
+
keywords: ["url"],
|
|
2173
|
+
icon: "link",
|
|
2174
|
+
when: (editor) => hasNode(editor, "embed"),
|
|
2175
|
+
run: ({ editor, range }) => {
|
|
2176
|
+
editor.chain().focus().deleteRange(range).setEmbed().run();
|
|
2177
|
+
}
|
|
2178
|
+
},
|
|
2179
|
+
{
|
|
2180
|
+
id: "table",
|
|
2181
|
+
title: "Table",
|
|
2182
|
+
group: STRUCTURE,
|
|
2183
|
+
description: "Rows and columns of cells",
|
|
2184
|
+
aliases: ["table", "grid"],
|
|
2185
|
+
keywords: ["spreadsheet"],
|
|
2186
|
+
icon: "table",
|
|
2187
|
+
when: (editor) => hasNode(editor, "table"),
|
|
2188
|
+
run: ({ editor, range }) => {
|
|
2189
|
+
editor.chain().focus().deleteRange(range).insertTable({
|
|
2190
|
+
rows: 3,
|
|
2191
|
+
cols: 3,
|
|
2192
|
+
withHeaderRow: true
|
|
2193
|
+
}).run();
|
|
2194
|
+
}
|
|
2195
|
+
},
|
|
2196
|
+
{
|
|
2197
|
+
id: "columns",
|
|
2198
|
+
title: "Columns",
|
|
2199
|
+
group: STRUCTURE,
|
|
2200
|
+
description: "Side-by-side layout",
|
|
2201
|
+
aliases: ["columns", "layout"],
|
|
2202
|
+
keywords: ["split"],
|
|
2203
|
+
icon: "columns",
|
|
2204
|
+
when: (editor) => hasNode(editor, "columns"),
|
|
2205
|
+
run: ({ editor, range }) => {
|
|
2206
|
+
editor.chain().focus().deleteRange(range).setColumns(2).run();
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
];
|
|
2210
|
+
//#endregion
|
|
2211
|
+
//#region src/slash-command.ts
|
|
2212
|
+
const slashCommandPluginKey = new PluginKey("slashCommand");
|
|
2213
|
+
const CLOSED = Object.freeze({
|
|
2214
|
+
open: false,
|
|
2215
|
+
query: "",
|
|
2216
|
+
items: Object.freeze([]),
|
|
2217
|
+
activeIndex: -1,
|
|
2218
|
+
getClientRect: null
|
|
2219
|
+
});
|
|
2220
|
+
const SlashCommand = Extension.create({
|
|
2221
|
+
name: "slashCommand",
|
|
2222
|
+
addOptions() {
|
|
2223
|
+
return {
|
|
2224
|
+
char: "/",
|
|
2225
|
+
items: defaultSlashItems
|
|
2226
|
+
};
|
|
2227
|
+
},
|
|
2228
|
+
addStorage() {
|
|
2229
|
+
return {
|
|
2230
|
+
state: CLOSED,
|
|
2231
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
2232
|
+
active: null,
|
|
2233
|
+
subscribe(listener) {
|
|
2234
|
+
this.listeners.add(listener);
|
|
2235
|
+
return () => this.listeners.delete(listener);
|
|
2236
|
+
},
|
|
2237
|
+
setActive(props) {
|
|
2238
|
+
this.active = props;
|
|
2239
|
+
if (!props) {
|
|
2240
|
+
if (this.state !== CLOSED) {
|
|
2241
|
+
this.state = CLOSED;
|
|
2242
|
+
this.listeners.forEach((listener) => listener());
|
|
2243
|
+
}
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
const sameItems = this.state.items.length === props.items.length && this.state.items.every((item, index) => item === props.items[index]);
|
|
2247
|
+
this.state = {
|
|
2248
|
+
open: true,
|
|
2249
|
+
query: props.query,
|
|
2250
|
+
items: props.items,
|
|
2251
|
+
activeIndex: props.items.length === 0 ? -1 : sameItems ? this.state.activeIndex : 0,
|
|
2252
|
+
getClientRect: props.clientRect ?? null
|
|
2253
|
+
};
|
|
2254
|
+
this.listeners.forEach((listener) => listener());
|
|
2255
|
+
},
|
|
2256
|
+
setActiveIndex(index) {
|
|
2257
|
+
const { items } = this.state;
|
|
2258
|
+
if (!this.state.open || items.length === 0) return;
|
|
2259
|
+
const wrapped = (index % items.length + items.length) % items.length;
|
|
2260
|
+
if (wrapped !== this.state.activeIndex) {
|
|
2261
|
+
this.state = {
|
|
2262
|
+
...this.state,
|
|
2263
|
+
activeIndex: wrapped
|
|
2264
|
+
};
|
|
2265
|
+
this.listeners.forEach((listener) => listener());
|
|
2266
|
+
}
|
|
2267
|
+
},
|
|
2268
|
+
select(index) {
|
|
2269
|
+
const item = this.state.items[index ?? this.state.activeIndex];
|
|
2270
|
+
if (this.active && item) this.active.command(item);
|
|
2271
|
+
},
|
|
2272
|
+
close() {
|
|
2273
|
+
this.active?.editor.commands.focus();
|
|
2274
|
+
this.setActive(null);
|
|
2275
|
+
}
|
|
2276
|
+
};
|
|
2277
|
+
},
|
|
2278
|
+
addProseMirrorPlugins() {
|
|
2279
|
+
const { editor } = this;
|
|
2280
|
+
const getStorage = () => editor.storage.slashCommand;
|
|
2281
|
+
const { onError } = this.options;
|
|
2282
|
+
const resolveItems = () => typeof this.options.items === "function" ? this.options.items(editor) : this.options.items;
|
|
2283
|
+
return [Suggestion({
|
|
2284
|
+
editor,
|
|
2285
|
+
char: this.options.char,
|
|
2286
|
+
pluginKey: slashCommandPluginKey,
|
|
2287
|
+
allowSpaces: false,
|
|
2288
|
+
allow: ({ state, range }) => !state.doc.resolve(range.from).parent.type.spec.code,
|
|
2289
|
+
items: ({ query }) => filterSlashItems(resolveItems(), query, editor),
|
|
2290
|
+
command: ({ editor: instance, range, props: item }) => {
|
|
2291
|
+
try {
|
|
2292
|
+
item.run({
|
|
2293
|
+
editor: instance,
|
|
2294
|
+
range
|
|
2295
|
+
});
|
|
2296
|
+
} catch (error) {
|
|
2297
|
+
if (onError) onError(error, {
|
|
2298
|
+
item,
|
|
2299
|
+
editor: instance
|
|
2300
|
+
});
|
|
2301
|
+
else throw error;
|
|
2302
|
+
}
|
|
2303
|
+
},
|
|
2304
|
+
render: () => ({
|
|
2305
|
+
onStart: (props) => getStorage().setActive(props),
|
|
2306
|
+
onUpdate: (props) => getStorage().setActive(props),
|
|
2307
|
+
onExit: () => getStorage().setActive(null),
|
|
2308
|
+
onKeyDown: ({ event }) => {
|
|
2309
|
+
const storage = getStorage();
|
|
2310
|
+
if (!storage.state.open) return false;
|
|
2311
|
+
switch (event.key) {
|
|
2312
|
+
case "ArrowDown":
|
|
2313
|
+
storage.setActiveIndex(storage.state.activeIndex + 1);
|
|
2314
|
+
return true;
|
|
2315
|
+
case "ArrowUp":
|
|
2316
|
+
storage.setActiveIndex(storage.state.activeIndex - 1);
|
|
2317
|
+
return true;
|
|
2318
|
+
case "Enter":
|
|
2319
|
+
case "Tab":
|
|
2320
|
+
if (storage.state.items.length === 0) return false;
|
|
2321
|
+
storage.select();
|
|
2322
|
+
return true;
|
|
2323
|
+
default: return false;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
})
|
|
2327
|
+
})];
|
|
2328
|
+
}
|
|
2329
|
+
});
|
|
2330
|
+
/** Configures the slash menu extension. */
|
|
2331
|
+
function slashCommand(options = {}) {
|
|
2332
|
+
return SlashCommand.configure(options);
|
|
2333
|
+
}
|
|
2334
|
+
//#endregion
|
|
2335
|
+
//#region src/table.ts
|
|
2336
|
+
/**
|
|
2337
|
+
* Configures Tiptap's table kit (table/tableRow/tableHeader/tableCell) with
|
|
2338
|
+
* resizable columns on by default — the one behavior worth diverging from
|
|
2339
|
+
* upstream's default, since a fixed-width table is the common Notion-parity
|
|
2340
|
+
* expectation. `insertTable`/`addColumnBefore`/`deleteRow`/… ship from the
|
|
2341
|
+
* upstream extension; slash-editor adds no table-specific commands.
|
|
2342
|
+
*/
|
|
2343
|
+
function table(options = {}) {
|
|
2344
|
+
const { table: tableOption, ...rest } = options;
|
|
2345
|
+
return TableKit.configure({
|
|
2346
|
+
table: tableOption === void 0 ? { resizable: true } : tableOption,
|
|
2347
|
+
...rest
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
//#endregion
|
|
2351
|
+
//#region src/video.ts
|
|
2352
|
+
function initialAttrs(id, options) {
|
|
2353
|
+
if (options && "file" in options) return {
|
|
2354
|
+
id,
|
|
2355
|
+
src: null,
|
|
2356
|
+
poster: options.poster ?? null,
|
|
2357
|
+
status: "uploading",
|
|
2358
|
+
error: null
|
|
2359
|
+
};
|
|
2360
|
+
return {
|
|
2361
|
+
id,
|
|
2362
|
+
src: options?.src ?? null,
|
|
2363
|
+
poster: options?.poster ?? null,
|
|
2364
|
+
status: "ready",
|
|
2365
|
+
error: null
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* A block-level video, same upload/retry shape as `Image`: `status`/`error`
|
|
2370
|
+
* live in doc attrs, the picked `File` lives in `storage.pending` keyed by
|
|
2371
|
+
* `id`. See `image.ts` for the full rationale.
|
|
2372
|
+
*/
|
|
2373
|
+
const Video = Node.create({
|
|
2374
|
+
name: "video",
|
|
2375
|
+
group: "block",
|
|
2376
|
+
atom: true,
|
|
2377
|
+
addOptions() {
|
|
2378
|
+
return { HTMLAttributes: {} };
|
|
2379
|
+
},
|
|
2380
|
+
addStorage() {
|
|
2381
|
+
return { pending: new PendingUploadRegistry() };
|
|
2382
|
+
},
|
|
2383
|
+
addAttributes() {
|
|
2384
|
+
return {
|
|
2385
|
+
src: { default: null },
|
|
2386
|
+
poster: { default: null },
|
|
2387
|
+
status: {
|
|
2388
|
+
default: "ready",
|
|
2389
|
+
parseHTML: (element) => element.getAttribute("data-status") ?? "ready",
|
|
2390
|
+
renderHTML: (attributes) => ({ "data-status": attributes.status })
|
|
2391
|
+
},
|
|
2392
|
+
error: {
|
|
2393
|
+
default: null,
|
|
2394
|
+
parseHTML: (element) => element.getAttribute("data-error"),
|
|
2395
|
+
renderHTML: (attributes) => attributes.error ? { "data-error": attributes.error } : {}
|
|
2396
|
+
}
|
|
2397
|
+
};
|
|
2398
|
+
},
|
|
2399
|
+
parseHTML() {
|
|
2400
|
+
return [{ tag: "video[src]" }];
|
|
2401
|
+
},
|
|
2402
|
+
renderHTML({ HTMLAttributes }) {
|
|
2403
|
+
return ["video", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
|
2404
|
+
"data-type": this.name,
|
|
2405
|
+
controls: ""
|
|
2406
|
+
})];
|
|
2407
|
+
},
|
|
2408
|
+
addCommands() {
|
|
2409
|
+
return {
|
|
2410
|
+
setVideo: (options) => ({ commands, dispatch }) => {
|
|
2411
|
+
const id = crypto.randomUUID();
|
|
2412
|
+
const inserted = commands.insertContent({
|
|
2413
|
+
type: this.name,
|
|
2414
|
+
attrs: initialAttrs(id, options)
|
|
2415
|
+
});
|
|
2416
|
+
if (inserted && dispatch && options && "file" in options) {
|
|
2417
|
+
const { file, adapter } = options;
|
|
2418
|
+
queueMicrotask(() => {
|
|
2419
|
+
runUpload({
|
|
2420
|
+
editor: this.editor,
|
|
2421
|
+
typeName: this.name,
|
|
2422
|
+
id,
|
|
2423
|
+
file,
|
|
2424
|
+
adapter,
|
|
2425
|
+
pending: this.storage.pending,
|
|
2426
|
+
toAttrs: (result) => ({ src: result.url })
|
|
2427
|
+
});
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
return inserted;
|
|
2431
|
+
},
|
|
2432
|
+
retryVideo: (id, override) => ({ dispatch }) => {
|
|
2433
|
+
const hasEntry = override !== void 0 || this.storage.pending.get(id) !== void 0;
|
|
2434
|
+
if (!dispatch || !hasEntry) return hasEntry;
|
|
2435
|
+
queueMicrotask(() => {
|
|
2436
|
+
retryUpload({
|
|
2437
|
+
editor: this.editor,
|
|
2438
|
+
typeName: this.name,
|
|
2439
|
+
id,
|
|
2440
|
+
pending: this.storage.pending,
|
|
2441
|
+
toAttrs: (result) => ({ src: result.url }),
|
|
2442
|
+
override
|
|
2443
|
+
});
|
|
2444
|
+
});
|
|
2445
|
+
return true;
|
|
2446
|
+
}
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
});
|
|
2450
|
+
/** Configures the video node. */
|
|
2451
|
+
function video(options = {}) {
|
|
2452
|
+
return Video.configure(options);
|
|
2453
|
+
}
|
|
2454
|
+
//#endregion
|
|
2455
|
+
//#region src/block-kit.ts
|
|
2456
|
+
const DEFAULT_HEADING_LEVELS = [
|
|
2457
|
+
1,
|
|
2458
|
+
2,
|
|
2459
|
+
3
|
|
2460
|
+
];
|
|
2461
|
+
/**
|
|
2462
|
+
* The baseline block schema: document, text, paragraph, headings, lists,
|
|
2463
|
+
* task lists, blockquote, callout, toggle (details), code block, horizontal
|
|
2464
|
+
* rule, hard break, and the inline marks.
|
|
2465
|
+
*
|
|
2466
|
+
* Emits no class names. UI layers style content through element selectors and
|
|
2467
|
+
* the `data-*` attributes rendered by slash-editor nodes.
|
|
2468
|
+
*/
|
|
2469
|
+
function createBlockKit(options = {}) {
|
|
2470
|
+
const { headingLevels = DEFAULT_HEADING_LEVELS, history = true, slash, blockId: blockIdOptions, drag, bubbleToolbar: bubbleToolbarOptions, image: imageOptions, file: fileOptions, video: videoOptions, embed: embedOptions, table: tableOptions, columns: columnsOptions, linkEditor: linkEditorOptions, mention: mentionOptions, ai: aiOptions, collaboration: collaborationOptions, comment: commentOptions, extend = [] } = options;
|
|
2471
|
+
const resolvedAi = aiOptions ? {
|
|
2472
|
+
actions: defaultAiSlashActions,
|
|
2473
|
+
node: true,
|
|
2474
|
+
...aiOptions
|
|
2475
|
+
} : void 0;
|
|
2476
|
+
const resolvedHistory = collaborationOptions ? false : history;
|
|
2477
|
+
return [
|
|
2478
|
+
StarterKit.configure({
|
|
2479
|
+
heading: { levels: headingLevels },
|
|
2480
|
+
undoRedo: resolvedHistory ? {} : false,
|
|
2481
|
+
link: {
|
|
2482
|
+
openOnClick: false,
|
|
2483
|
+
enableClickSelection: true
|
|
2484
|
+
}
|
|
2485
|
+
}),
|
|
2486
|
+
TaskList,
|
|
2487
|
+
TaskItem.configure({ nested: true }),
|
|
2488
|
+
Callout,
|
|
2489
|
+
Details.configure({ persist: true }),
|
|
2490
|
+
DetailsSummary,
|
|
2491
|
+
DetailsContent,
|
|
2492
|
+
...imageOptions === false ? [] : [image(imageOptions)],
|
|
2493
|
+
...fileOptions === false ? [] : [file(fileOptions)],
|
|
2494
|
+
...videoOptions === false ? [] : [video(videoOptions)],
|
|
2495
|
+
...embedOptions === false ? [] : [embed(embedOptions)],
|
|
2496
|
+
...tableOptions === false ? [] : [table(tableOptions)],
|
|
2497
|
+
...columnsOptions === false ? [] : [columns(columnsOptions), column()],
|
|
2498
|
+
...resolvedAi && resolvedAi.node !== false ? [aiBlock()] : [],
|
|
2499
|
+
...mentionOptions === false || !mentionOptions ? [] : [mention(mentionOptions)],
|
|
2500
|
+
...slash === false ? [] : [slashCommand({
|
|
2501
|
+
...slash,
|
|
2502
|
+
items: slash?.items ?? (() => [...defaultSlashItems, ...resolvedAi ? createAiSlashItems(resolvedAi) : []])()
|
|
2503
|
+
})],
|
|
2504
|
+
...blockIdOptions === false ? [] : [blockId(blockIdOptions)],
|
|
2505
|
+
...drag === false ? [] : [blockDrag(drag)],
|
|
2506
|
+
...bubbleToolbarOptions === false ? [] : [bubbleToolbar(bubbleToolbarOptions)],
|
|
2507
|
+
...linkEditorOptions === false ? [] : [linkEditor(linkEditorOptions)],
|
|
2508
|
+
...collaborationOptions === false || !collaborationOptions ? [] : [...collaboration(collaborationOptions)],
|
|
2509
|
+
...commentOptions === false ? [] : [comment(commentOptions)],
|
|
2510
|
+
...extend
|
|
2511
|
+
];
|
|
2512
|
+
}
|
|
2513
|
+
//#endregion
|
|
2514
|
+
export { AiBlock, BLOCK_ID_REMOTE_META, BlockDrag, BlockId, BubbleToolbar, Callout, Column, Columns, Comment, Embed, File, Image, LinkEditor, Mention, PendingAiRegistry, PendingUploadRegistry, SlashCommand, TableKit, Video, activeThreadIds, aiBlock, blockDrag, blockDragPluginKey, blockId, blockIdPluginKey, bubbleToolbar, callout, canAppendChild, canOpenLinkEditor, collaboration, column, columns, comment, createAiSlashItems, createBlockKit, defaultAiSlashActions, defaultBubbleToolbarItems, defaultSlashItems, embed, file, filterBubbleToolbarItems, filterSlashItems, findNodeById, image, linkEditor, mention, mentionPluginKey, resolveDropTarget, retryUpload, runUpload, slashCommand, slashCommandPluginKey, table, video };
|