@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.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 };