@threadlabs/looma 0.23.21 → 0.23.22

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.
Files changed (45) hide show
  1. package/components/shared/overlay.js +1 -1
  2. package/components/ui-image/ui-image.html +177 -0
  3. package/components/ui-image/ui-image.js +107 -0
  4. package/components/ui-menu/ui-menu.js +1 -1
  5. package/components/ui-popover/ui-popover.js +4 -1
  6. package/components/ui-separator/ui-separator.html +18 -0
  7. package/dist/index.js +24 -23
  8. package/editor/chunks/extensions.js +633 -20
  9. package/editor/extensions/block-selection.d.ts +6 -0
  10. package/editor/extensions/expand.d.ts +31 -0
  11. package/editor/extensions/image.d.ts +12 -0
  12. package/editor/extensions/index.d.ts +4 -1
  13. package/editor/extensions/index.js +2 -2
  14. package/editor/extensions/preset.d.ts +8 -0
  15. package/editor/extensions/slash-command.d.ts +31 -3
  16. package/editor/extensions/table-of-contents.d.ts +58 -0
  17. package/editor/index.js +2 -2
  18. package/package.json +1 -1
  19. package/styles/ui-image.css +153 -0
  20. package/styles/ui-separator.css +16 -0
  21. package/vanilla/UiImage.d.ts +21 -0
  22. package/vanilla/UiImage.js +84 -0
  23. package/vanilla/UiSeparator.d.ts +2 -0
  24. package/vanilla/UiSeparator.js +10 -2
  25. package/vanilla/index.js +1 -0
  26. package/vue/UiImage.d.ts +100 -0
  27. package/vue/UiImage.js +2 -0
  28. package/vue/UiImage.vue +463 -0
  29. package/vue/UiSeparator.d.ts +18 -0
  30. package/vue/UiSeparator.vue +66 -1
  31. package/vue/UiSidebar.d.ts +1 -1
  32. package/vue/chunks/UiImage.js +313 -0
  33. package/vue/chunks/UiSeparator.js +79 -22
  34. package/vue/components.css +169 -9
  35. package/vue/editor/LoomaEditor.d.ts +19 -2
  36. package/vue/editor/divider.d.ts +6 -0
  37. package/vue/editor/expand.d.ts +7 -0
  38. package/vue/editor/image-controls.d.ts +19 -0
  39. package/vue/editor/image-delivery.d.ts +2 -2
  40. package/vue/editor/image.d.ts +7 -0
  41. package/vue/editor/index.d.ts +1 -1
  42. package/vue/editor/index.js +1188 -52
  43. package/vue/editor/table-of-contents.d.ts +7 -0
  44. package/vue/index.d.ts +1 -0
  45. package/vue/index.js +2 -1
@@ -1,12 +1,12 @@
1
1
  import { n as TABLE_CELL_BACKGROUND_PRESETS } from "./table-backgrounds.js";
2
- import { Extension, Mark, Node as Node$1, callOrReturn, canInsertNode, combineTransactionSteps, escapeForRegEx, findChildren, findChildrenInRange, findParentNodeClosestToPos, getAttributes, getChangedRanges, getExtensionField, getMarksBetween, isNodeEmpty, isNodeSelection, markInputRule, markPasteRule, mergeAttributes, nodeInputRule, textInputRule, textblockTypeInputRule, wrappingInputRule } from "@tiptap/core";
2
+ import { Extension, Mark, Node as Node$1, callOrReturn, canInsertNode, combineTransactionSteps, escapeForRegEx, findChildren, findChildrenInRange, findParentNodeClosestToPos, getAttributes, getChangedRanges, getExtensionField, getMarksBetween, getText, getTextSerializersFromSchema, isNodeEmpty, isNodeSelection, markInputRule, markPasteRule, mergeAttributes, nodeInputRule, textInputRule, textblockTypeInputRule, wrappingInputRule } from "@tiptap/core";
3
3
  import { NodeSelection, Plugin, PluginKey, Selection, TextSelection } from "@tiptap/pm/state";
4
4
  import { Decoration, DecorationSet } from "@tiptap/pm/view";
5
+ import { DOMParser, DOMSerializer, Fragment, Node as Node$2, Slice } from "@tiptap/pm/model";
5
6
  import { history, redo, undo } from "@tiptap/pm/history";
6
7
  import { dropCursor } from "@tiptap/pm/dropcursor";
7
8
  import { gapCursor } from "@tiptap/pm/gapcursor";
8
9
  import { createLowlight } from "lowlight";
9
- import { DOMParser, Node as Node$2, Slice } from "@tiptap/pm/model";
10
10
  import { Mapping } from "@tiptap/pm/transform";
11
11
  import MarkdownIt from "markdown-it";
12
12
  import { CellSelection, TableMap, addColumn, addColumnAfter, addColumnBefore, addRow, addRowAfter, addRowBefore, cellAround, columnResizing, deleteCellSelection, deleteColumn, deleteRow, deleteTable, fixTables, goToNextCell, mergeCells, moveTableColumn, moveTableRow, setCellAttr, splitCell, tableEditing, toggleHeader, toggleHeaderCell } from "@tiptap/pm/tables";
@@ -277,6 +277,391 @@ var LoomaChip = Node$1.create({
277
277
  }
278
278
  });
279
279
  //#endregion
280
+ //#region src/editor/extensions/expand.ts
281
+ /**
282
+ * Reveals collapsed ancestors before a host navigates to a document position.
283
+ * @contract Navigation changes presentation only, including in read-only mode.
284
+ */
285
+ async function revealLoomaExpandAt(editor, position) {
286
+ const { node } = editor.view.domAtPos(position);
287
+ const target = node instanceof Element ? node : node.parentElement;
288
+ const pending = [];
289
+ target?.dispatchEvent(new CustomEvent("looma-editor-reveal", {
290
+ bubbles: true,
291
+ detail: { pending }
292
+ }));
293
+ await Promise.all(pending);
294
+ }
295
+ /**
296
+ * A block container whose summary and initial expansion survive serialization.
297
+ * @ownership The document owns attributes/body; node views own reader toggles.
298
+ * @lifecycle Default views unregister their editor subscriptions on destroy.
299
+ */
300
+ var LoomaExpand = Node$1.create({
301
+ name: "loomaExpand",
302
+ group: "block",
303
+ content: "block+",
304
+ defining: true,
305
+ isolating: true,
306
+ addOptions: () => ({ nodeView: null }),
307
+ addAttributes() {
308
+ return {
309
+ summary: {
310
+ default: "Details",
311
+ parseHTML: (element) => element.querySelector(":scope > summary")?.textContent ?? "Details",
312
+ renderHTML: () => ({})
313
+ },
314
+ open: {
315
+ default: false,
316
+ parseHTML: (element) => element.hasAttribute("open"),
317
+ renderHTML: () => ({})
318
+ }
319
+ };
320
+ },
321
+ parseHTML() {
322
+ return [{
323
+ tag: "details",
324
+ getContent: (element, schema) => {
325
+ if (!(element instanceof Element)) return Fragment.from(schema.nodes.paragraph.create());
326
+ const body = element.ownerDocument.createElement("div");
327
+ const authoredBody = element.querySelector(":scope > [data-looma-expand-body]");
328
+ for (const child of (authoredBody ?? element).childNodes) {
329
+ if (child instanceof Element && child.localName === "summary") continue;
330
+ body.append(child.cloneNode(true));
331
+ }
332
+ const content = DOMParser.fromSchema(schema).parse(body).content;
333
+ return content.size ? content : Fragment.from(schema.nodes.paragraph.create());
334
+ }
335
+ }];
336
+ },
337
+ renderHTML({ node }) {
338
+ return [
339
+ "details",
340
+ {
341
+ "data-looma-expand": "",
342
+ ...node.attrs.open ? { open: "" } : {}
343
+ },
344
+ [
345
+ "summary",
346
+ {},
347
+ String(node.attrs.summary)
348
+ ],
349
+ [
350
+ "div",
351
+ { "data-looma-expand-body": "" },
352
+ 0
353
+ ]
354
+ ];
355
+ },
356
+ renderText({ node }) {
357
+ return `${node.attrs.summary}\n${getText(node, { textSerializers: getTextSerializersFromSchema(node.type.schema) })}`;
358
+ },
359
+ addCommands() {
360
+ return {
361
+ insertLoomaExpand: (attributes = {}) => ({ chain, state, dispatch }) => {
362
+ const from = state.selection.from;
363
+ const content = {
364
+ type: this.name,
365
+ attrs: {
366
+ summary: attributes.summary ?? "Details",
367
+ open: attributes.open === true
368
+ },
369
+ content: [{ type: "paragraph" }]
370
+ };
371
+ if (!dispatch) return chain().insertContent(content).run();
372
+ return chain().insertContent(content).command(({ tr, commands }) => {
373
+ let body = -1;
374
+ tr.doc.descendants((node, position) => {
375
+ if (node.type.name === this.name && position >= from - 1 && body < 0) body = position + 2;
376
+ });
377
+ return body >= 0 && commands.setTextSelection(body);
378
+ }).run();
379
+ },
380
+ exitLoomaExpand: () => ({ state, chain }) => {
381
+ const { $from } = state.selection;
382
+ for (let depth = $from.depth; depth > 0; depth--) {
383
+ if ($from.node(depth).type.name !== this.name) continue;
384
+ const after = $from.after(depth);
385
+ return chain().insertContentAt(after, { type: "paragraph" }).setTextSelection(after + 1).run();
386
+ }
387
+ return false;
388
+ }
389
+ };
390
+ },
391
+ addExtensions() {
392
+ return [Extension.create({
393
+ name: "loomaExpandExit",
394
+ priority: 110,
395
+ addKeyboardShortcuts() {
396
+ return { "Mod-Enter": () => this.editor.commands.exitLoomaExpand() };
397
+ }
398
+ })];
399
+ },
400
+ addNodeView() {
401
+ if (this.options.nodeView) return this.options.nodeView;
402
+ return ({ editor, node }) => {
403
+ const dom = document.createElement("details");
404
+ dom.dataset.loomaExpand = "";
405
+ const summary = document.createElement("summary");
406
+ summary.contentEditable = "false";
407
+ const contentDOM = document.createElement("div");
408
+ contentDOM.dataset.loomaExpandBody = "";
409
+ dom.append(summary, contentDOM);
410
+ let current = node;
411
+ let editable = editor.isEditable;
412
+ dom.open = editable || Boolean(node.attrs.open);
413
+ summary.textContent = node.attrs.summary;
414
+ const reveal = () => {
415
+ dom.open = true;
416
+ };
417
+ const refresh = () => {
418
+ if (editable !== editor.isEditable) {
419
+ editable = editor.isEditable;
420
+ dom.open = editable || Boolean(current.attrs.open);
421
+ }
422
+ };
423
+ dom.addEventListener("looma-editor-reveal", reveal);
424
+ editor.on("update", refresh);
425
+ return {
426
+ dom,
427
+ contentDOM,
428
+ update(next) {
429
+ if (next.type !== current.type) return false;
430
+ if (next.attrs.open !== current.attrs.open) dom.open = editor.isEditable || Boolean(next.attrs.open);
431
+ current = next;
432
+ summary.textContent = next.attrs.summary;
433
+ refresh();
434
+ return true;
435
+ },
436
+ stopEvent: (event) => summary.contains(event.target),
437
+ ignoreMutation: (mutation) => mutation.type !== "selection" && !contentDOM.contains(mutation.target),
438
+ destroy() {
439
+ dom.removeEventListener("looma-editor-reveal", reveal);
440
+ editor.off("update", refresh);
441
+ }
442
+ };
443
+ };
444
+ }
445
+ });
446
+ //#endregion
447
+ //#region src/editor/extensions/table-of-contents.ts
448
+ /** Normalize loaded configuration without persisting unsupported presentation values. */
449
+ function normalizeTableOfContentsConfig(attributes) {
450
+ return {
451
+ format: attributes.format === "bulleted" || attributes.format === "numbered" ? attributes.format : "plain",
452
+ depth: attributes.depth === 1 || attributes.depth === 2 ? attributes.depth : 3
453
+ };
454
+ }
455
+ /**
456
+ * Derives nonempty headings throughout the document, independent of TOC placement.
457
+ * @contract Depth is a maximum heading level. Relative nesting follows existing
458
+ * ancestors rather than inserting artificial entries for skipped levels.
459
+ */
460
+ function getTableOfContentsEntries(doc, depth = 3) {
461
+ const entries = [];
462
+ const ancestors = [];
463
+ doc.descendants((node, position) => {
464
+ if (node.type.name !== "heading" || node.attrs.level > depth || !node.textContent.trim()) return;
465
+ const level = Number(node.attrs.level);
466
+ while (ancestors.length && ancestors[ancestors.length - 1] >= level) ancestors.pop();
467
+ entries.push({
468
+ id: String(node.attrs.id ?? ""),
469
+ text: node.textContent.trim(),
470
+ level,
471
+ depth: ancestors.length,
472
+ position
473
+ });
474
+ ancestors.push(level);
475
+ });
476
+ return entries;
477
+ }
478
+ /** Shared nesting for semantic HTML and the adapter's component composition. */
479
+ function tableOfContentsTree(entries) {
480
+ const roots = [];
481
+ const parents = [];
482
+ for (const entry of entries) {
483
+ const branch = {
484
+ entry,
485
+ children: []
486
+ };
487
+ parents.length = entry.depth;
488
+ (parents[entry.depth - 1]?.children ?? roots).push(branch);
489
+ parents.push(branch);
490
+ }
491
+ return roots;
492
+ }
493
+ function anchorTransaction(state) {
494
+ let hasToc = false;
495
+ const headings = [];
496
+ state.doc.descendants((node, position) => {
497
+ if (node.type.name === "loomaTableOfContents") hasToc = true;
498
+ if (node.type.name === "heading") headings.push({
499
+ node,
500
+ position
501
+ });
502
+ });
503
+ if (!hasToc) return null;
504
+ const reserved = new Set(headings.map(({ node }) => String(node.attrs.id ?? "")).filter(Boolean));
505
+ const used = /* @__PURE__ */ new Set();
506
+ const transaction = state.tr;
507
+ let sequence = 1;
508
+ for (const { node, position } of headings) {
509
+ let id = typeof node.attrs.id === "string" ? node.attrs.id : "";
510
+ if (!id || /\s/.test(id) || used.has(id)) {
511
+ do
512
+ id = `looma-heading-${sequence++}`;
513
+ while (reserved.has(id) || used.has(id));
514
+ transaction.setNodeMarkup(position, void 0, {
515
+ ...node.attrs,
516
+ id
517
+ });
518
+ }
519
+ used.add(id);
520
+ }
521
+ return transaction.docChanged ? transaction : null;
522
+ }
523
+ var LoomaHeadingAnchors = Extension.create({
524
+ name: "loomaHeadingAnchors",
525
+ addGlobalAttributes() {
526
+ return [{
527
+ types: ["heading"],
528
+ attributes: { id: { default: null } }
529
+ }];
530
+ },
531
+ onCreate() {
532
+ const transaction = anchorTransaction(this.editor.state);
533
+ if (transaction) this.editor.view.dispatch(transaction.setMeta("addToHistory", false));
534
+ },
535
+ addProseMirrorPlugins() {
536
+ return [new Plugin({ appendTransaction: (transactions, _oldState, state) => transactions.some((transaction) => transaction.docChanged) ? anchorTransaction(state) : null })];
537
+ }
538
+ });
539
+ function contentsHtml(doc, config) {
540
+ const branches = tableOfContentsTree(getTableOfContentsEntries(doc, config.depth));
541
+ const list = (items) => [
542
+ config.format === "plain" ? "div" : config.format === "numbered" ? "ol" : "ul",
543
+ { role: "list" },
544
+ ...items.map(({ entry, children }) => [
545
+ config.format === "plain" ? "div" : "li",
546
+ { role: "listitem" },
547
+ [
548
+ "a",
549
+ { href: `#${entry.id}` },
550
+ entry.text
551
+ ],
552
+ ...children.length ? [list(children)] : []
553
+ ])
554
+ ];
555
+ return [
556
+ "nav",
557
+ {
558
+ "data-looma-toc": "",
559
+ "data-format": config.format,
560
+ "data-depth": config.depth,
561
+ "aria-label": "Table of contents"
562
+ },
563
+ [
564
+ "strong",
565
+ {},
566
+ "Table of contents"
567
+ ],
568
+ branches.length ? list(branches) : [
569
+ "p",
570
+ {},
571
+ "Add headings to build your table of contents."
572
+ ]
573
+ ];
574
+ }
575
+ /**
576
+ * Scrolls within this editor so multiple documents cannot cross-navigate by ID.
577
+ * @contract Reader navigation does not edit content or invoke link-editing UI.
578
+ */
579
+ async function navigateTableOfContents(editor, id) {
580
+ const target = [...editor.view.dom.querySelectorAll("h1[id], h2[id], h3[id]")].find((heading) => heading.id === id);
581
+ if (!target) return;
582
+ const entry = getTableOfContentsEntries(editor.state.doc).find((heading) => heading.id === id);
583
+ if (entry) await revealLoomaExpandAt(editor, entry.position + 1);
584
+ if (!target.isConnected || editor.isDestroyed) return;
585
+ target.scrollIntoView({
586
+ block: "start",
587
+ behavior: "auto"
588
+ });
589
+ target.tabIndex = -1;
590
+ target.focus({ preventScroll: true });
591
+ }
592
+ /**
593
+ * Automatic whole-document navigation with durable configuration only.
594
+ * @ownership The schema owns configuration/anchors; node views derive entries.
595
+ * @lifecycle Headless views subscribe to transactions and unsubscribe on destroy.
596
+ */
597
+ var LoomaTableOfContents = Node$1.create({
598
+ name: "loomaTableOfContents",
599
+ group: "block",
600
+ atom: true,
601
+ selectable: true,
602
+ isolating: true,
603
+ addOptions: () => ({ nodeView: null }),
604
+ addExtensions: () => [LoomaHeadingAnchors],
605
+ addAttributes() {
606
+ return {
607
+ format: {
608
+ default: "plain",
609
+ parseHTML: (element) => normalizeTableOfContentsConfig({ format: element.getAttribute("data-format") }).format,
610
+ renderHTML: () => ({})
611
+ },
612
+ depth: {
613
+ default: 3,
614
+ parseHTML: (element) => normalizeTableOfContentsConfig({ depth: Number(element.getAttribute("data-depth")) }).depth,
615
+ renderHTML: () => ({})
616
+ }
617
+ };
618
+ },
619
+ parseHTML: () => [{ tag: "nav[data-looma-toc]" }],
620
+ renderHTML({ node }) {
621
+ return contentsHtml(this.editor?.view?.state.doc ?? node, normalizeTableOfContentsConfig(node.attrs));
622
+ },
623
+ addCommands() {
624
+ return { insertLoomaTableOfContents: (config = {}) => ({ commands }) => commands.insertContent({
625
+ type: this.name,
626
+ attrs: normalizeTableOfContentsConfig(config)
627
+ }) };
628
+ },
629
+ addNodeView() {
630
+ if (this.options.nodeView) return this.options.nodeView;
631
+ return ({ editor, node }) => {
632
+ const dom = document.createElement("div");
633
+ dom.contentEditable = "false";
634
+ let current = node;
635
+ const render = () => {
636
+ const { dom: content } = DOMSerializer.renderSpec(document, contentsHtml(editor.state.doc, normalizeTableOfContentsConfig(current.attrs)));
637
+ dom.replaceChildren(content);
638
+ };
639
+ dom.addEventListener("click", (event) => {
640
+ const anchor = event.target.closest("a");
641
+ if (!anchor) return;
642
+ event.preventDefault();
643
+ navigateTableOfContents(editor, anchor.getAttribute("href").slice(1));
644
+ });
645
+ editor.on("transaction", render);
646
+ render();
647
+ return {
648
+ dom,
649
+ update(next) {
650
+ if (next.type !== current.type) return false;
651
+ current = next;
652
+ render();
653
+ return true;
654
+ },
655
+ stopEvent: () => true,
656
+ ignoreMutation: () => true,
657
+ destroy: () => {
658
+ editor.off("transaction", render);
659
+ }
660
+ };
661
+ };
662
+ }
663
+ });
664
+ //#endregion
280
665
  //#region ../../node_modules/.pnpm/@tiptap+extension-document@2.27.2_@tiptap+core@2.27.2_@tiptap+pm@2.27.2_/node_modules/@tiptap/extension-document/dist/index.js
281
666
  /**
282
667
  * The default document node which represents the top level node of the editor.
@@ -3104,6 +3489,45 @@ var Image = Node$1.create({
3104
3489
  }
3105
3490
  });
3106
3491
  //#endregion
3492
+ //#region src/editor/extensions/image.ts
3493
+ /** Accepts only positive, integral CSS-pixel dimensions. */
3494
+ function normalizeImageDimension(value) {
3495
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
3496
+ }
3497
+ /** Unknown legacy values retain ordinary block placement. */
3498
+ function normalizeImagePlacement(value) {
3499
+ return value === "center" || value === "wrap-left" || value === "wrap-right" ? value : "block";
3500
+ }
3501
+ /** Portable image attributes shared by every editor adapter.
3502
+ * @contract Keeps Tiptap's existing `image` name and insertion command. Delivery
3503
+ * attributes stay transient; dimensions and placement survive HTML and JSON.
3504
+ */
3505
+ var LoomaImage = Image.extend({ addAttributes() {
3506
+ return {
3507
+ ...this.parent?.(),
3508
+ width: {
3509
+ default: null,
3510
+ parseHTML: (element) => normalizeImageDimension(Number(element.getAttribute("width"))) ?? null,
3511
+ renderHTML: (attrs) => normalizeImageDimension(attrs.width) ? { width: attrs.width } : {}
3512
+ },
3513
+ height: {
3514
+ default: null,
3515
+ parseHTML: (element) => normalizeImageDimension(Number(element.getAttribute("height"))) ?? null,
3516
+ renderHTML: (attrs) => normalizeImageDimension(attrs.height) ? { height: attrs.height } : {}
3517
+ },
3518
+ responsive: {
3519
+ default: null,
3520
+ parseHTML: (element) => element.hasAttribute("data-looma-responsive") || null,
3521
+ renderHTML: (attrs) => attrs.responsive === true ? { "data-looma-responsive": "" } : {}
3522
+ },
3523
+ placement: {
3524
+ default: "block",
3525
+ parseHTML: (element) => normalizeImagePlacement(element.getAttribute("data-placement")),
3526
+ renderHTML: (attrs) => normalizeImagePlacement(attrs.placement) === "block" ? {} : { "data-placement": normalizeImagePlacement(attrs.placement) }
3527
+ }
3528
+ };
3529
+ } });
3530
+ //#endregion
3107
3531
  //#region ../../node_modules/.pnpm/@tiptap+extension-highlight@2.27.2_@tiptap+core@2.27.2_@tiptap+pm@2.27.2_/node_modules/@tiptap/extension-highlight/dist/index.js
3108
3532
  /**
3109
3533
  * Matches a highlight to a ==highlight== on input.
@@ -5550,6 +5974,33 @@ var TableRow = Node$1.create({
5550
5974
  }
5551
5975
  });
5552
5976
  //#endregion
5977
+ //#region src/editor/extensions/block-selection.ts
5978
+ /** Selects atomic visual blocks by pointer and keeps text Tab input in the document.
5979
+ * @contract List and table keymaps run first; Shift-Tab outside a list remains
5980
+ * browser navigation so keyboard users can leave the editing surface.
5981
+ */
5982
+ var LoomaBlockSelection = Extension.create({
5983
+ name: "loomaBlockSelection",
5984
+ priority: 50,
5985
+ addKeyboardShortcuts() {
5986
+ return { Tab: () => {
5987
+ const editor = this.editor;
5988
+ if (!editor.isEditable || !(editor.state.selection instanceof TextSelection)) return false;
5989
+ if (editor.isActive("table")) return false;
5990
+ if (editor.isActive("listItem") || editor.isActive("taskItem")) return true;
5991
+ return editor.commands.insertContent(" ");
5992
+ } };
5993
+ },
5994
+ addProseMirrorPlugins() {
5995
+ return [new Plugin({ props: { handleClickOn: (view, _position, node, nodePosition, _event, direct) => {
5996
+ if (!view.editable || !direct || !["image", "horizontalRule"].includes(node.type.name)) return false;
5997
+ view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePosition)));
5998
+ view.focus();
5999
+ return true;
6000
+ } } })];
6001
+ }
6002
+ });
6003
+ //#endregion
5553
6004
  //#region src/editor/extensions/list-behavior.ts
5554
6005
  /**
5555
6006
  * Looma list behavior extension:
@@ -9342,7 +9793,8 @@ function getLoomaTableExtensions() {
9342
9793
  * the code and explicit language stay in the document schema.
9343
9794
  */
9344
9795
  function getDefaultEditorExtensions(options = {}) {
9345
- const { placeholder = "Type “/” for commands, or start writing…", linkOpenOnClick = false, linkBaseUrl, imageInline = false, mention = createLoomaMentionExtension(), disableHighlight = false, codeLanguages, codeLowlight, codeBlockNodeView } = options;
9796
+ const { placeholder = "Type “/” for commands, or start writing…", linkOpenOnClick = false, linkBaseUrl, imageInline = false, imageNodeView, dividerNodeView, mention = createLoomaMentionExtension(), disableHighlight = false, codeLanguages, codeLowlight, codeBlockNodeView, tableOfContentsNodeView, expandNodeView } = options;
9797
+ const image = LoomaImage.configure({ inline: imageInline });
9346
9798
  const codeBlock = LoomaCodeBlock.configure({ lowlight: codeLowlight ?? createLowlight(codeLanguages) });
9347
9799
  return [
9348
9800
  Document,
@@ -9365,7 +9817,9 @@ function getDefaultEditorExtensions(options = {}) {
9365
9817
  Blockquote,
9366
9818
  LoomaCallout,
9367
9819
  LoomaChip,
9368
- HorizontalRule,
9820
+ LoomaExpand.configure({ nodeView: expandNodeView ?? null }),
9821
+ LoomaTableOfContents.configure({ nodeView: tableOfContentsNodeView ?? null }),
9822
+ dividerNodeView ? HorizontalRule.extend({ addNodeView: () => dividerNodeView }) : HorizontalRule,
9369
9823
  HardBreak,
9370
9824
  History,
9371
9825
  Dropcursor,
@@ -9378,7 +9832,7 @@ function getDefaultEditorExtensions(options = {}) {
9378
9832
  }
9379
9833
  }),
9380
9834
  ...linkBaseUrl ? [createSiteRelativeLinks(linkBaseUrl)] : [],
9381
- Image.configure({ inline: imageInline }),
9835
+ imageNodeView ? image.extend({ addNodeView: () => imageNodeView }) : image,
9382
9836
  (disableHighlight ? StoredOnlyHighlight : Highlight).configure({ multicolor: false }),
9383
9837
  Code,
9384
9838
  LoomaSmartPaste,
@@ -9391,7 +9845,8 @@ function getDefaultEditorExtensions(options = {}) {
9391
9845
  ...mention ? [mention] : [],
9392
9846
  LoomaTableKit,
9393
9847
  LoomaListBehavior,
9394
- LoomaActiveBlock
9848
+ LoomaActiveBlock,
9849
+ LoomaBlockSelection
9395
9850
  ];
9396
9851
  }
9397
9852
  //#endregion
@@ -9833,7 +10288,8 @@ var CALLOUT_COMMANDS = [
9833
10288
  * Builds Looma's default command policy as fresh objects for one editor.
9834
10289
  * Image and link commands delegate their picker because Looma does not own
9835
10290
  * upload, persistence, or destination search. Link appears only when a picker
9836
- * callback is supplied, so a headless default inventory has no dead action.
10291
+ * callback is supplied. Image follows the same rule, so a headless inventory
10292
+ * has no dead picker actions.
9837
10293
  *
9838
10294
  * @ownership Returned command objects belong to the caller and are recreated
9839
10295
  * per call so one editor cannot mutate another editor's command inventory.
@@ -9943,7 +10399,8 @@ function getDefaultSlashCommands(onOpenImagePicker, onOpenChipEditor, onOpenLink
9943
10399
  "chip",
9944
10400
  "label",
9945
10401
  "badge",
9946
- "tag"
10402
+ "tag",
10403
+ "status"
9947
10404
  ],
9948
10405
  command: ({ editor, range }) => {
9949
10406
  if (editor.chain().focus().deleteRange(range).setTextSelection(range.from).insertLoomaChip({ label: "" }).run()) onOpenChipEditor?.(editor, range.from);
@@ -9990,6 +10447,34 @@ function getDefaultSlashCommands(onOpenImagePicker, onOpenChipEditor, onOpenLink
9990
10447
  insertTableAtRange(editor, range);
9991
10448
  }
9992
10449
  },
10450
+ {
10451
+ title: "Table of contents",
10452
+ description: "Automatic links to document headings",
10453
+ icon: "list",
10454
+ keywords: [
10455
+ "toc",
10456
+ "contents",
10457
+ "tableofcontents",
10458
+ "outline"
10459
+ ],
10460
+ command: ({ editor, range }) => {
10461
+ editor.chain().focus().deleteRange(range).insertLoomaTableOfContents().run();
10462
+ }
10463
+ },
10464
+ {
10465
+ title: "Expand",
10466
+ description: "Collapsible section with a summary",
10467
+ icon: "chevron-down",
10468
+ keywords: [
10469
+ "expand",
10470
+ "toggle",
10471
+ "details",
10472
+ "collapse"
10473
+ ],
10474
+ command: ({ editor, range }) => {
10475
+ editor.chain().focus().deleteRange(range).insertLoomaExpand().run();
10476
+ }
10477
+ },
9993
10478
  {
9994
10479
  title: "Divider",
9995
10480
  description: "Horizontal rule",
@@ -10018,7 +10503,7 @@ function getDefaultSlashCommands(onOpenImagePicker, onOpenChipEditor, onOpenLink
10018
10503
  onOpenLinkEditor();
10019
10504
  }
10020
10505
  }] : [],
10021
- {
10506
+ ...onOpenImagePicker ? [{
10022
10507
  title: "Image",
10023
10508
  description: "Upload an image",
10024
10509
  icon: "image",
@@ -10032,8 +10517,87 @@ function getDefaultSlashCommands(onOpenImagePicker, onOpenChipEditor, onOpenLink
10032
10517
  editor.chain().focus().deleteRange(range).run();
10033
10518
  onOpenImagePicker?.();
10034
10519
  }
10520
+ }] : []
10521
+ ].map((command) => ({
10522
+ ...command,
10523
+ id: command.title.toLowerCase().replaceAll(" ", "-"),
10524
+ group: CALLOUT_COMMANDS.some((callout) => callout.title === command.title) ? "Callouts" : [
10525
+ "Chip",
10526
+ "Inline code",
10527
+ "Link",
10528
+ "Image",
10529
+ "Table",
10530
+ "Table of contents",
10531
+ "Expand"
10532
+ ].includes(command.title) ? "Insertions" : "Basic blocks",
10533
+ isAvailable: (context) => canRunDefaultCommand(command.title, context)
10534
+ })).sort((a, b) => [
10535
+ "Basic blocks",
10536
+ "Callouts",
10537
+ "Insertions"
10538
+ ].indexOf(a.group) - [
10539
+ "Basic blocks",
10540
+ "Callouts",
10541
+ "Insertions"
10542
+ ].indexOf(b.group));
10543
+ }
10544
+ function canRunDefaultCommand(title, { editor, range }) {
10545
+ const chain = editor.can().chain().deleteRange(range);
10546
+ switch (title) {
10547
+ case "Text": return editor.isActive("paragraph") || chain.setParagraph().run();
10548
+ case "Heading 1": return editor.isActive("heading", { level: 1 }) || chain.setHeading({ level: 1 }).run();
10549
+ case "Heading 2": return editor.isActive("heading", { level: 2 }) || chain.setHeading({ level: 2 }).run();
10550
+ case "Heading 3": return editor.isActive("heading", { level: 3 }) || chain.setHeading({ level: 3 }).run();
10551
+ case "Bullet list": return chain.toggleBulletList().run();
10552
+ case "Numbered list": return chain.toggleOrderedList().run();
10553
+ case "Checklist": return chain.toggleTaskList().run();
10554
+ case "Blockquote": return chain.toggleBlockquote().run();
10555
+ case "Info": return chain.setLoomaCallout("info").run();
10556
+ case "Note": return chain.setLoomaCallout("note").run();
10557
+ case "Warning": return chain.setLoomaCallout("warning").run();
10558
+ case "Chip": return chain.insertLoomaChip({ label: "" }).run();
10559
+ case "Inline code": return chain.toggleCode().run();
10560
+ case "Code block": return chain.toggleCodeBlock().run();
10561
+ case "Table": return chain.insertTable({
10562
+ rows: 3,
10563
+ cols: 3,
10564
+ withHeaderRow: true
10565
+ }).run();
10566
+ case "Table of contents": return chain.insertLoomaTableOfContents().run();
10567
+ case "Expand": return chain.insertLoomaExpand().run();
10568
+ case "Divider": return chain.setHorizontalRule().run();
10569
+ default: return chain.run();
10570
+ }
10571
+ }
10572
+ /**
10573
+ * Ranks exact names/aliases before partial matches, then matching categories.
10574
+ * Other ties retain inventory order.
10575
+ * @contract Capability checks run against the current range; a failing host
10576
+ * predicate omits that command rather than exposing an action that may destroy text.
10577
+ */
10578
+ function filterLoomaSlashCommands(commands, query, context) {
10579
+ const term = query.toLowerCase().trim();
10580
+ return commands.map((command, index) => {
10581
+ const names = [
10582
+ command.title,
10583
+ ...command.keywords,
10584
+ command.id ?? ""
10585
+ ].map((name) => name.toLowerCase());
10586
+ const rank = !term || names.includes(term) ? 0 : names.some((name) => name.startsWith(term)) ? 1 : names.some((name) => name.includes(term)) ? 2 : 3;
10587
+ let available = true;
10588
+ if (context && command.isAvailable) try {
10589
+ available = command.isAvailable(context);
10590
+ } catch {
10591
+ available = false;
10035
10592
  }
10036
- ];
10593
+ return {
10594
+ command,
10595
+ index,
10596
+ rank,
10597
+ groupMatch: Boolean(term && command.group?.toLowerCase().startsWith(term)),
10598
+ available
10599
+ };
10600
+ }).filter((item) => item.rank < 3 && item.available).sort((a, b) => a.rank - b.rank || Number(b.groupMatch) - Number(a.groupMatch) || a.index - b.index).map((item) => item.command);
10037
10601
  }
10038
10602
  var EMPTY_STATE = {
10039
10603
  active: false,
@@ -10041,7 +10605,8 @@ var EMPTY_STATE = {
10041
10605
  selectedIndex: 0,
10042
10606
  query: "",
10043
10607
  rect: null,
10044
- select: null
10608
+ select: null,
10609
+ highlight: null
10045
10610
  };
10046
10611
  /**
10047
10612
  * Framework-neutral slash-command extension used by the turnkey editor.
@@ -10058,10 +10623,25 @@ var LoomaSlashCommand = Extension.create({
10058
10623
  addOptions() {
10059
10624
  return { commands: getDefaultSlashCommands() };
10060
10625
  },
10626
+ addStorage: () => ({ dismiss: () => {} }),
10627
+ addCommands() {
10628
+ return { dismissLoomaSlashMenu: () => ({ dispatch }) => {
10629
+ if (dispatch) this.storage.dismiss();
10630
+ return true;
10631
+ } };
10632
+ },
10061
10633
  addProseMirrorPlugins() {
10062
10634
  let selectedIndex = 0;
10063
10635
  let currentProps = null;
10636
+ let generation = 0;
10637
+ let dismissed = null;
10638
+ const isCurrent = (props) => {
10639
+ const state = SuggestionPluginKey.getState(this.editor.state);
10640
+ return state?.active && state.query === props.query && state.range.from === props.range.from && state.range.to === props.range.to;
10641
+ };
10064
10642
  const publish = (props) => {
10643
+ const revision = ++generation;
10644
+ const document = this.editor.state.doc;
10065
10645
  if (!props) {
10066
10646
  this.options.onStateChange?.({ ...EMPTY_STATE });
10067
10647
  return;
@@ -10073,23 +10653,55 @@ var LoomaSlashCommand = Extension.create({
10073
10653
  query: props.query,
10074
10654
  rect: props.clientRect?.() ?? null,
10075
10655
  select: (index) => {
10656
+ if (revision !== generation || props !== currentProps || document !== this.editor.state.doc || !this.editor.isEditable || !isCurrent(props)) return;
10076
10657
  const item = props.items[index];
10077
- if (item) props.command(item);
10658
+ if (item && filterLoomaSlashCommands([item], "", {
10659
+ editor: this.editor,
10660
+ range: props.range
10661
+ }).length) props.command(item);
10662
+ },
10663
+ highlight: (index) => {
10664
+ if (revision !== generation || props !== currentProps || index < 0 || index >= props.items.length) return;
10665
+ selectedIndex = index;
10666
+ publish(props);
10078
10667
  }
10079
10668
  });
10080
10669
  };
10670
+ this.storage.dismiss = () => {
10671
+ const range = SuggestionPluginKey.getState(this.editor.state)?.range;
10672
+ if (range) dismissed = {
10673
+ ...range,
10674
+ document: this.editor.state.doc
10675
+ };
10676
+ currentProps = null;
10677
+ publish(null);
10678
+ };
10081
10679
  return [Suggestion({
10082
10680
  editor: this.editor,
10083
10681
  char: "/",
10084
10682
  allowSpaces: false,
10085
10683
  startOfLine: false,
10086
- items: ({ query }) => {
10087
- const normalized = query.toLowerCase().trim();
10088
- const commands = this.options.commands.length > 0 ? this.options.commands : getDefaultSlashCommands(this.options.onOpenImagePicker, this.options.onOpenChipEditor);
10089
- if (!normalized) return commands;
10090
- return commands.filter((command) => command.title.toLowerCase().includes(normalized) || command.keywords.some((keyword) => keyword.includes(normalized)));
10684
+ allow: ({ state, range }) => {
10685
+ if (dismissed?.document === state.doc && dismissed.from === range.from && dismissed.to === range.to) return false;
10686
+ const { $from } = state.selection;
10687
+ for (let depth = $from.depth; depth >= 0; depth--) if ($from.node(depth).type.spec.code) return false;
10688
+ return !state.schema.marks.code?.isInSet(state.storedMarks ?? $from.marks());
10689
+ },
10690
+ items: ({ query, editor }) => {
10691
+ const range = SuggestionPluginKey.getState(editor.state)?.range ?? {
10692
+ from: editor.state.selection.from,
10693
+ to: editor.state.selection.to
10694
+ };
10695
+ return filterLoomaSlashCommands(this.options.commands, query, {
10696
+ editor,
10697
+ range
10698
+ });
10091
10699
  },
10092
10700
  command: ({ editor, range, props }) => {
10701
+ if (!editor.isEditable || !currentProps || !isCurrent(currentProps) || !this.options.commands.includes(props) || !filterLoomaSlashCommands([props], "", {
10702
+ editor,
10703
+ range
10704
+ }).length) return;
10093
10705
  props.command({
10094
10706
  editor,
10095
10707
  range
@@ -10097,11 +10709,13 @@ var LoomaSlashCommand = Extension.create({
10097
10709
  },
10098
10710
  render: () => ({
10099
10711
  onStart: (props) => {
10712
+ if (!isCurrent(props)) return;
10100
10713
  currentProps = props;
10101
10714
  selectedIndex = 0;
10102
10715
  publish(props);
10103
10716
  },
10104
10717
  onUpdate: (props) => {
10718
+ if (!isCurrent(props)) return;
10105
10719
  currentProps = props;
10106
10720
  if (selectedIndex >= props.items.length) selectedIndex = 0;
10107
10721
  publish(props);
@@ -10124,8 +10738,7 @@ var LoomaSlashCommand = Extension.create({
10124
10738
  return true;
10125
10739
  }
10126
10740
  if (event.key === "Escape") {
10127
- currentProps = null;
10128
- publish(null);
10741
+ this.editor.commands.dismissLoomaSlashMenu();
10129
10742
  return true;
10130
10743
  }
10131
10744
  return false;
@@ -10156,4 +10769,4 @@ function createLoomaSlashCommandExtension(options = {}) {
10156
10769
  });
10157
10770
  }
10158
10771
  //#endregion
10159
- export { LoomaChip as A, LoomaMentionSuggestionPluginKey as C, filterLoomaMentionItems as D, MAX_MENTION_RESULT_LIMIT as E, LOOMA_ACTIVE_BLOCK_CLASS as F, LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS as I, LoomaActiveBlock as L, LOOMA_CALLOUT_TONES as M, LoomaCallout as N, normalizeMentionResultLimit as O, LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS as P, LoomaActiveBlockPluginKey as R, siteRelativeHref as S, DEFAULT_MENTION_RESULT_LIMIT as T, getActiveTableCellBackground as _, handleTableAction as a, LoomaSmartPaste as b, normalizeActiveTableColumnWidths as c, getDefaultEditorExtensions as d, getLoomaTableExtensions as f, getActiveTableCellAlignment as g, LoomaTableHeader as h, getActiveTableUiState as i, normalizeLoomaChipColor as j, LOOMA_CHIP_COLORS as k, shouldShowTextFormattingToolbar as l, LoomaTableCell as m, createLoomaSlashCommandExtension as n, handleTableOverlayAction as o, LoomaTable as p, getDefaultSlashCommands as r, insertTableAtRange as s, LoomaSlashCommand as t, LoomaTableKit as u, setActiveTableCellAlignment as v, createLoomaMentionExtension as w, createSiteRelativeLinks as x, setActiveTableCellBackground as y };
10772
+ export { LoomaImage as A, normalizeLoomaChipColor as B, siteRelativeHref as C, MAX_MENTION_RESULT_LIMIT as D, DEFAULT_MENTION_RESULT_LIMIT as E, normalizeTableOfContentsConfig as F, LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS as G, LoomaCallout as H, LoomaExpand as I, LoomaActiveBlock as K, revealLoomaExpandAt as L, normalizeImagePlacement as M, LoomaTableOfContents as N, filterLoomaMentionItems as O, getTableOfContentsEntries as P, LOOMA_CHIP_COLORS as R, createSiteRelativeLinks as S, createLoomaMentionExtension as T, LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS as U, LOOMA_CALLOUT_TONES as V, LOOMA_ACTIVE_BLOCK_CLASS as W, getActiveTableCellAlignment as _, getActiveTableUiState as a, setActiveTableCellBackground as b, insertTableAtRange as c, LoomaTableKit as d, getDefaultEditorExtensions as f, LoomaTableHeader as g, LoomaTableCell as h, getDefaultSlashCommands as i, normalizeImageDimension as j, normalizeMentionResultLimit as k, normalizeActiveTableColumnWidths as l, LoomaTable as m, createLoomaSlashCommandExtension as n, handleTableAction as o, getLoomaTableExtensions as p, LoomaActiveBlockPluginKey as q, filterLoomaSlashCommands as r, handleTableOverlayAction as s, LoomaSlashCommand as t, shouldShowTextFormattingToolbar as u, getActiveTableCellBackground as v, LoomaMentionSuggestionPluginKey as w, LoomaSmartPaste as x, setActiveTableCellAlignment as y, LoomaChip as z };