@threadlabs/looma 0.23.21 → 0.23.23

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 (54) hide show
  1. package/components/shared/overlay.js +1 -1
  2. package/components/ui-button/ui-button.html +68 -2
  3. package/components/ui-image/ui-image.html +177 -0
  4. package/components/ui-image/ui-image.js +107 -0
  5. package/components/ui-menu/ui-menu.js +1 -1
  6. package/components/ui-popover/ui-popover.js +4 -1
  7. package/components/ui-separator/ui-separator.html +26 -2
  8. package/dist/index.js +24 -23
  9. package/editor/chunks/extensions.js +633 -20
  10. package/editor/extensions/block-selection.d.ts +6 -0
  11. package/editor/extensions/expand.d.ts +31 -0
  12. package/editor/extensions/image.d.ts +12 -0
  13. package/editor/extensions/index.d.ts +4 -1
  14. package/editor/extensions/index.js +2 -2
  15. package/editor/extensions/preset.d.ts +8 -0
  16. package/editor/extensions/slash-command.d.ts +31 -3
  17. package/editor/extensions/table-of-contents.d.ts +58 -0
  18. package/editor/index.js +2 -2
  19. package/package.json +1 -1
  20. package/styles/ui-button.css +39 -0
  21. package/styles/ui-image.css +153 -0
  22. package/styles/ui-separator.css +22 -2
  23. package/vanilla/UiButton.d.ts +1 -1
  24. package/vanilla/UiButton.js +126 -10
  25. package/vanilla/UiImage.d.ts +21 -0
  26. package/vanilla/UiImage.js +84 -0
  27. package/vanilla/UiSeparator.d.ts +3 -0
  28. package/vanilla/UiSeparator.js +14 -4
  29. package/vanilla/index.js +1 -0
  30. package/vue/UiButton.d.ts +18 -6
  31. package/vue/UiButton.vue +269 -8
  32. package/vue/UiImage.d.ts +100 -0
  33. package/vue/UiImage.js +2 -0
  34. package/vue/UiImage.vue +463 -0
  35. package/vue/UiListItem.d.ts +1 -1
  36. package/vue/UiSection.d.ts +1 -1
  37. package/vue/UiSeparator.d.ts +27 -0
  38. package/vue/UiSeparator.vue +94 -3
  39. package/vue/UiSidebar.d.ts +1 -1
  40. package/vue/chunks/UiButton.js +196 -12
  41. package/vue/chunks/UiImage.js +313 -0
  42. package/vue/chunks/UiSeparator.js +106 -22
  43. package/vue/components.css +252 -53
  44. package/vue/editor/LoomaEditor.d.ts +19 -2
  45. package/vue/editor/divider.d.ts +6 -0
  46. package/vue/editor/expand.d.ts +7 -0
  47. package/vue/editor/image-controls.d.ts +19 -0
  48. package/vue/editor/image-delivery.d.ts +2 -2
  49. package/vue/editor/image.d.ts +7 -0
  50. package/vue/editor/index.d.ts +1 -1
  51. package/vue/editor/index.js +1188 -52
  52. package/vue/editor/table-of-contents.d.ts +7 -0
  53. package/vue/index.d.ts +1 -0
  54. package/vue/index.js +2 -1
@@ -1,12 +1,13 @@
1
- import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, reactive, ref, useId, watch } from "vue";
2
- import { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_CHIP_COLORS, LOOMA_ICONS, createLoomaMentionExtension, createLoomaSlashCommandExtension, getActiveTableUiState, getDefaultEditorExtensions, getVisualViewportRect, handleTableAction, handleTableOverlayAction, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeLoomaChipColor, resolveTableCellAt, shouldShowTextFormattingToolbar, siteRelativeHref } from "@threadlabs/looma/editor";
3
- import { Button, Card, Checkbox, Cluster, Combobox, EditorInsertTableGrid, EditorMentionMenu, EditorMentionMenuItem, EditorSlashMenu, EditorSlashMenuGroup, EditorSlashMenuItem, EditorTableContextMenu, EditorTableOverlay, EditorTableToolbar, EditorToolbar, FormField, IconButton, Input, InputGroup, Menu, MenuItem, Popover, ScrollArea, SearchResultRow, Separator, Stack, Text, Tooltip } from "@threadlabs/looma/vue";
1
+ import { computed, createApp, defineComponent, h, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useId, watch } from "vue";
2
+ import { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_CHIP_COLORS, LOOMA_ICONS, createLoomaMentionExtension, createLoomaSlashCommandExtension, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeLoomaChipColor, normalizeTableOfContentsConfig, resolveTableCellAt, shouldShowTextFormattingToolbar, siteRelativeHref } from "@threadlabs/looma/editor";
3
+ import { Button, Card, Checkbox, Cluster, Combobox, Disclosure, EditorInsertTableGrid, EditorMentionMenu, EditorMentionMenuItem, EditorSlashMenu, EditorSlashMenuGroup, EditorSlashMenuItem, EditorTableContextMenu, EditorTableOverlay, EditorTableToolbar, EditorToolbar, EditorToolbar as EditorToolbar$1, FormField, Icon, IconButton, Image, Input, InputGroup, List, Menu, MenuItem, Popover, ScrollArea, SearchResultRow, Separator, Stack, Text, Tooltip } from "@threadlabs/looma/vue";
4
4
  import { BubbleMenu, EditorContent, useEditor } from "@tiptap/vue-3";
5
5
  import { closeHistory } from "@tiptap/pm/history";
6
6
  import { createLowlight } from "lowlight";
7
- import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
8
- import { Extension } from "@tiptap/core";
7
+ import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
8
+ import { Extension, Node as Node$1, getText, getTextSerializersFromSchema, mergeAttributes, nodeInputRule } from "@tiptap/core";
9
9
  import { Decoration, DecorationSet } from "@tiptap/pm/view";
10
+ import { DOMParser, DOMSerializer, Fragment as Fragment$1 } from "@tiptap/pm/model";
10
11
  export * from "@threadlabs/looma/editor";
11
12
  //#region src/vue/editor/primitives.ts
12
13
  /** Each Vue editor component's element tag, for tooling that enumerates them. */
@@ -28,13 +29,107 @@ function announceOverlayOpen(document, id) {
28
29
  document.dispatchEvent(new CustomEvent("ui-overlay-open", { detail: { id } }));
29
30
  }
30
31
  //#endregion
32
+ //#region ../../node_modules/.pnpm/@tiptap+extension-image@2.27.2_@tiptap+core@2.27.2_@tiptap+pm@2.27.2_/node_modules/@tiptap/extension-image/dist/index.js
33
+ /**
34
+ * Matches an image to a ![image](src "title") on input.
35
+ */
36
+ var inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/;
37
+ /**
38
+ * This extension allows you to insert images.
39
+ * @see https://www.tiptap.dev/api/nodes/image
40
+ */
41
+ var Image$1 = Node$1.create({
42
+ name: "image",
43
+ addOptions() {
44
+ return {
45
+ inline: false,
46
+ allowBase64: false,
47
+ HTMLAttributes: {}
48
+ };
49
+ },
50
+ inline() {
51
+ return this.options.inline;
52
+ },
53
+ group() {
54
+ return this.options.inline ? "inline" : "block";
55
+ },
56
+ draggable: true,
57
+ addAttributes() {
58
+ return {
59
+ src: { default: null },
60
+ alt: { default: null },
61
+ title: { default: null }
62
+ };
63
+ },
64
+ parseHTML() {
65
+ return [{ tag: this.options.allowBase64 ? "img[src]" : "img[src]:not([src^=\"data:\"])" }];
66
+ },
67
+ renderHTML({ HTMLAttributes }) {
68
+ return ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
69
+ },
70
+ addCommands() {
71
+ return { setImage: (options) => ({ commands }) => {
72
+ return commands.insertContent({
73
+ type: this.name,
74
+ attrs: options
75
+ });
76
+ } };
77
+ },
78
+ addInputRules() {
79
+ return [nodeInputRule({
80
+ find: inputRegex,
81
+ type: this.type,
82
+ getAttributes: (match) => {
83
+ const [, , alt, src, title] = match;
84
+ return {
85
+ src,
86
+ alt,
87
+ title
88
+ };
89
+ }
90
+ })];
91
+ }
92
+ });
93
+ //#endregion
94
+ //#region src/editor/extensions/image.ts
95
+ /** Accepts only positive, integral CSS-pixel dimensions. */
96
+ function normalizeImageDimension(value) {
97
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
98
+ }
99
+ /** Unknown legacy values retain ordinary block placement. */
100
+ function normalizeImagePlacement(value) {
101
+ return value === "center" || value === "wrap-left" || value === "wrap-right" ? value : "block";
102
+ }
103
+ Image$1.extend({ addAttributes() {
104
+ return {
105
+ ...this.parent?.(),
106
+ width: {
107
+ default: null,
108
+ parseHTML: (element) => normalizeImageDimension(Number(element.getAttribute("width"))) ?? null,
109
+ renderHTML: (attrs) => normalizeImageDimension(attrs.width) ? { width: attrs.width } : {}
110
+ },
111
+ height: {
112
+ default: null,
113
+ parseHTML: (element) => normalizeImageDimension(Number(element.getAttribute("height"))) ?? null,
114
+ renderHTML: (attrs) => normalizeImageDimension(attrs.height) ? { height: attrs.height } : {}
115
+ },
116
+ responsive: {
117
+ default: null,
118
+ parseHTML: (element) => element.hasAttribute("data-looma-responsive") || null,
119
+ renderHTML: (attrs) => attrs.responsive === true ? { "data-looma-responsive": "" } : {}
120
+ },
121
+ placement: {
122
+ default: "block",
123
+ parseHTML: (element) => normalizeImagePlacement(element.getAttribute("data-placement")),
124
+ renderHTML: (attrs) => normalizeImagePlacement(attrs.placement) === "block" ? {} : { "data-placement": normalizeImagePlacement(attrs.placement) }
125
+ }
126
+ };
127
+ } });
128
+ //#endregion
31
129
  //#region src/vue/editor/image-delivery.ts
32
130
  function optionalString(value) {
33
131
  return typeof value === "string" && value.length > 0 ? value : void 0;
34
132
  }
35
- function normalizeImageDimension(value) {
36
- return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
37
- }
38
133
  function imageDescriptorFromAttrs(attrs) {
39
134
  const src = optionalString(attrs.src);
40
135
  if (!src) return null;
@@ -49,13 +144,15 @@ function imageDescriptorFromAttrs(attrs) {
49
144
  ...attrs.responsive === true ? { responsive: true } : {}
50
145
  };
51
146
  }
52
- function transientAttributes(resolver, image) {
147
+ function transientAttributes(resolver, image, editable) {
53
148
  const attributes = {
54
- "aria-label": image.alt ? `Open image: ${image.alt}` : "Open image",
149
+ ...editable ? {} : {
150
+ "aria-label": image.alt ? `Open image: ${image.alt}` : "Open image",
151
+ role: "button"
152
+ },
55
153
  "data-looma-image": "",
56
- role: "button",
57
154
  src: image.src,
58
- tabindex: "0"
155
+ tabindex: editable ? "-1" : "0"
59
156
  };
60
157
  if (!image.responsive || !resolver) return attributes;
61
158
  let rendition;
@@ -85,7 +182,7 @@ function transientAttributes(resolver, image) {
85
182
  if (src && src !== image.src || srcset) attributes["data-looma-rendition"] = "true";
86
183
  return attributes;
87
184
  }
88
- function createDecorations(doc, resolver, failedRenditions) {
185
+ function createDecorations(doc, resolver, failedRenditions, editable) {
89
186
  const decorations = [];
90
187
  const currentSources = /* @__PURE__ */ new Set();
91
188
  doc.descendants((node, position) => {
@@ -93,7 +190,7 @@ function createDecorations(doc, resolver, failedRenditions) {
93
190
  const image = imageDescriptorFromAttrs(node.attrs);
94
191
  if (!image) return;
95
192
  currentSources.add(image.src);
96
- decorations.push(Decoration.node(position, position + node.nodeSize, transientAttributes(failedRenditions.has(image.src) ? void 0 : resolver, image)));
193
+ decorations.push(Decoration.node(position, position + node.nodeSize, {}, { loomaImageAttributes: transientAttributes(failedRenditions.has(image.src) ? void 0 : resolver, image, editable) }));
97
194
  });
98
195
  for (const src of failedRenditions) if (!currentSources.has(src)) failedRenditions.delete(src);
99
196
  return DecorationSet.create(doc, decorations);
@@ -127,36 +224,14 @@ function createLoomaImageDeliveryController(options) {
127
224
  return {
128
225
  extension: Extension.create({
129
226
  name: "loomaImageDelivery",
130
- addGlobalAttributes() {
131
- return [{
132
- types: ["image"],
133
- attributes: {
134
- width: {
135
- default: null,
136
- parseHTML: (element) => normalizeImageDimension(Number(element.getAttribute("width"))) ?? null,
137
- renderHTML: (attributes) => normalizeImageDimension(attributes.width) ? { width: normalizeImageDimension(attributes.width) } : {}
138
- },
139
- height: {
140
- default: null,
141
- parseHTML: (element) => normalizeImageDimension(Number(element.getAttribute("height"))) ?? null,
142
- renderHTML: (attributes) => normalizeImageDimension(attributes.height) ? { height: normalizeImageDimension(attributes.height) } : {}
143
- },
144
- responsive: {
145
- default: null,
146
- parseHTML: (element) => element.hasAttribute("data-looma-responsive") || null,
147
- renderHTML: (attributes) => attributes.responsive === true ? { "data-looma-responsive": "" } : {}
148
- }
149
- }
150
- }];
151
- },
152
227
  addProseMirrorPlugins() {
153
228
  return [new Plugin({
154
229
  key: pluginKey,
155
230
  state: {
156
- init: (_, state) => createDecorations(state.doc, options.resolveAttributes(), failedRenditions),
231
+ init: (_, state) => createDecorations(state.doc, options.resolveAttributes(), failedRenditions, options.isEditable()),
157
232
  apply: (transaction, previous) => {
158
233
  if (!transaction.getMeta(pluginKey) && !transactionChangesImages(transaction)) return previous.map(transaction.mapping, transaction.doc);
159
- return createDecorations(transaction.doc, options.resolveAttributes(), failedRenditions);
234
+ return createDecorations(transaction.doc, options.resolveAttributes(), failedRenditions, options.isEditable());
160
235
  }
161
236
  },
162
237
  props: { decorations: (state) => pluginKey.getState(state) }
@@ -407,6 +482,1010 @@ var autoCodeLanguageNames = [
407
482
  "yaml"
408
483
  ];
409
484
  //#endregion
485
+ //#region src/editor/extensions/expand.ts
486
+ /**
487
+ * Reveals collapsed ancestors before a host navigates to a document position.
488
+ * @contract Navigation changes presentation only, including in read-only mode.
489
+ */
490
+ async function revealLoomaExpandAt(editor, position) {
491
+ const { node } = editor.view.domAtPos(position);
492
+ const target = node instanceof Element ? node : node.parentElement;
493
+ const pending = [];
494
+ target?.dispatchEvent(new CustomEvent("looma-editor-reveal", {
495
+ bubbles: true,
496
+ detail: { pending }
497
+ }));
498
+ await Promise.all(pending);
499
+ }
500
+ Node$1.create({
501
+ name: "loomaExpand",
502
+ group: "block",
503
+ content: "block+",
504
+ defining: true,
505
+ isolating: true,
506
+ addOptions: () => ({ nodeView: null }),
507
+ addAttributes() {
508
+ return {
509
+ summary: {
510
+ default: "Details",
511
+ parseHTML: (element) => element.querySelector(":scope > summary")?.textContent ?? "Details",
512
+ renderHTML: () => ({})
513
+ },
514
+ open: {
515
+ default: false,
516
+ parseHTML: (element) => element.hasAttribute("open"),
517
+ renderHTML: () => ({})
518
+ }
519
+ };
520
+ },
521
+ parseHTML() {
522
+ return [{
523
+ tag: "details",
524
+ getContent: (element, schema) => {
525
+ if (!(element instanceof Element)) return Fragment$1.from(schema.nodes.paragraph.create());
526
+ const body = element.ownerDocument.createElement("div");
527
+ const authoredBody = element.querySelector(":scope > [data-looma-expand-body]");
528
+ for (const child of (authoredBody ?? element).childNodes) {
529
+ if (child instanceof Element && child.localName === "summary") continue;
530
+ body.append(child.cloneNode(true));
531
+ }
532
+ const content = DOMParser.fromSchema(schema).parse(body).content;
533
+ return content.size ? content : Fragment$1.from(schema.nodes.paragraph.create());
534
+ }
535
+ }];
536
+ },
537
+ renderHTML({ node }) {
538
+ return [
539
+ "details",
540
+ {
541
+ "data-looma-expand": "",
542
+ ...node.attrs.open ? { open: "" } : {}
543
+ },
544
+ [
545
+ "summary",
546
+ {},
547
+ String(node.attrs.summary)
548
+ ],
549
+ [
550
+ "div",
551
+ { "data-looma-expand-body": "" },
552
+ 0
553
+ ]
554
+ ];
555
+ },
556
+ renderText({ node }) {
557
+ return `${node.attrs.summary}\n${getText(node, { textSerializers: getTextSerializersFromSchema(node.type.schema) })}`;
558
+ },
559
+ addCommands() {
560
+ return {
561
+ insertLoomaExpand: (attributes = {}) => ({ chain, state, dispatch }) => {
562
+ const from = state.selection.from;
563
+ const content = {
564
+ type: this.name,
565
+ attrs: {
566
+ summary: attributes.summary ?? "Details",
567
+ open: attributes.open === true
568
+ },
569
+ content: [{ type: "paragraph" }]
570
+ };
571
+ if (!dispatch) return chain().insertContent(content).run();
572
+ return chain().insertContent(content).command(({ tr, commands }) => {
573
+ let body = -1;
574
+ tr.doc.descendants((node, position) => {
575
+ if (node.type.name === this.name && position >= from - 1 && body < 0) body = position + 2;
576
+ });
577
+ return body >= 0 && commands.setTextSelection(body);
578
+ }).run();
579
+ },
580
+ exitLoomaExpand: () => ({ state, chain }) => {
581
+ const { $from } = state.selection;
582
+ for (let depth = $from.depth; depth > 0; depth--) {
583
+ if ($from.node(depth).type.name !== this.name) continue;
584
+ const after = $from.after(depth);
585
+ return chain().insertContentAt(after, { type: "paragraph" }).setTextSelection(after + 1).run();
586
+ }
587
+ return false;
588
+ }
589
+ };
590
+ },
591
+ addExtensions() {
592
+ return [Extension.create({
593
+ name: "loomaExpandExit",
594
+ priority: 110,
595
+ addKeyboardShortcuts() {
596
+ return { "Mod-Enter": () => this.editor.commands.exitLoomaExpand() };
597
+ }
598
+ })];
599
+ },
600
+ addNodeView() {
601
+ if (this.options.nodeView) return this.options.nodeView;
602
+ return ({ editor, node }) => {
603
+ const dom = document.createElement("details");
604
+ dom.dataset.loomaExpand = "";
605
+ const summary = document.createElement("summary");
606
+ summary.contentEditable = "false";
607
+ const contentDOM = document.createElement("div");
608
+ contentDOM.dataset.loomaExpandBody = "";
609
+ dom.append(summary, contentDOM);
610
+ let current = node;
611
+ let editable = editor.isEditable;
612
+ dom.open = editable || Boolean(node.attrs.open);
613
+ summary.textContent = node.attrs.summary;
614
+ const reveal = () => {
615
+ dom.open = true;
616
+ };
617
+ const refresh = () => {
618
+ if (editable !== editor.isEditable) {
619
+ editable = editor.isEditable;
620
+ dom.open = editable || Boolean(current.attrs.open);
621
+ }
622
+ };
623
+ dom.addEventListener("looma-editor-reveal", reveal);
624
+ editor.on("update", refresh);
625
+ return {
626
+ dom,
627
+ contentDOM,
628
+ update(next) {
629
+ if (next.type !== current.type) return false;
630
+ if (next.attrs.open !== current.attrs.open) dom.open = editor.isEditable || Boolean(next.attrs.open);
631
+ current = next;
632
+ summary.textContent = next.attrs.summary;
633
+ refresh();
634
+ return true;
635
+ },
636
+ stopEvent: (event) => summary.contains(event.target),
637
+ ignoreMutation: (mutation) => mutation.type !== "selection" && !contentDOM.contains(mutation.target),
638
+ destroy() {
639
+ dom.removeEventListener("looma-editor-reveal", reveal);
640
+ editor.off("update", refresh);
641
+ }
642
+ };
643
+ };
644
+ }
645
+ });
646
+ //#endregion
647
+ //#region src/editor/extensions/table-of-contents.ts
648
+ /** Normalize loaded configuration without persisting unsupported presentation values. */
649
+ function normalizeTableOfContentsConfig$1(attributes) {
650
+ return {
651
+ format: attributes.format === "bulleted" || attributes.format === "numbered" ? attributes.format : "plain",
652
+ depth: attributes.depth === 1 || attributes.depth === 2 ? attributes.depth : 3
653
+ };
654
+ }
655
+ /**
656
+ * Derives nonempty headings throughout the document, independent of TOC placement.
657
+ * @contract Depth is a maximum heading level. Relative nesting follows existing
658
+ * ancestors rather than inserting artificial entries for skipped levels.
659
+ */
660
+ function getTableOfContentsEntries$1(doc, depth = 3) {
661
+ const entries = [];
662
+ const ancestors = [];
663
+ doc.descendants((node, position) => {
664
+ if (node.type.name !== "heading" || node.attrs.level > depth || !node.textContent.trim()) return;
665
+ const level = Number(node.attrs.level);
666
+ while (ancestors.length && ancestors[ancestors.length - 1] >= level) ancestors.pop();
667
+ entries.push({
668
+ id: String(node.attrs.id ?? ""),
669
+ text: node.textContent.trim(),
670
+ level,
671
+ depth: ancestors.length,
672
+ position
673
+ });
674
+ ancestors.push(level);
675
+ });
676
+ return entries;
677
+ }
678
+ /** Shared nesting for semantic HTML and the adapter's component composition. */
679
+ function tableOfContentsTree(entries) {
680
+ const roots = [];
681
+ const parents = [];
682
+ for (const entry of entries) {
683
+ const branch = {
684
+ entry,
685
+ children: []
686
+ };
687
+ parents.length = entry.depth;
688
+ (parents[entry.depth - 1]?.children ?? roots).push(branch);
689
+ parents.push(branch);
690
+ }
691
+ return roots;
692
+ }
693
+ function anchorTransaction(state) {
694
+ let hasToc = false;
695
+ const headings = [];
696
+ state.doc.descendants((node, position) => {
697
+ if (node.type.name === "loomaTableOfContents") hasToc = true;
698
+ if (node.type.name === "heading") headings.push({
699
+ node,
700
+ position
701
+ });
702
+ });
703
+ if (!hasToc) return null;
704
+ const reserved = new Set(headings.map(({ node }) => String(node.attrs.id ?? "")).filter(Boolean));
705
+ const used = /* @__PURE__ */ new Set();
706
+ const transaction = state.tr;
707
+ let sequence = 1;
708
+ for (const { node, position } of headings) {
709
+ let id = typeof node.attrs.id === "string" ? node.attrs.id : "";
710
+ if (!id || /\s/.test(id) || used.has(id)) {
711
+ do
712
+ id = `looma-heading-${sequence++}`;
713
+ while (reserved.has(id) || used.has(id));
714
+ transaction.setNodeMarkup(position, void 0, {
715
+ ...node.attrs,
716
+ id
717
+ });
718
+ }
719
+ used.add(id);
720
+ }
721
+ return transaction.docChanged ? transaction : null;
722
+ }
723
+ var LoomaHeadingAnchors = Extension.create({
724
+ name: "loomaHeadingAnchors",
725
+ addGlobalAttributes() {
726
+ return [{
727
+ types: ["heading"],
728
+ attributes: { id: { default: null } }
729
+ }];
730
+ },
731
+ onCreate() {
732
+ const transaction = anchorTransaction(this.editor.state);
733
+ if (transaction) this.editor.view.dispatch(transaction.setMeta("addToHistory", false));
734
+ },
735
+ addProseMirrorPlugins() {
736
+ return [new Plugin({ appendTransaction: (transactions, _oldState, state) => transactions.some((transaction) => transaction.docChanged) ? anchorTransaction(state) : null })];
737
+ }
738
+ });
739
+ function contentsHtml(doc, config) {
740
+ const branches = tableOfContentsTree(getTableOfContentsEntries$1(doc, config.depth));
741
+ const list = (items) => [
742
+ config.format === "plain" ? "div" : config.format === "numbered" ? "ol" : "ul",
743
+ { role: "list" },
744
+ ...items.map(({ entry, children }) => [
745
+ config.format === "plain" ? "div" : "li",
746
+ { role: "listitem" },
747
+ [
748
+ "a",
749
+ { href: `#${entry.id}` },
750
+ entry.text
751
+ ],
752
+ ...children.length ? [list(children)] : []
753
+ ])
754
+ ];
755
+ return [
756
+ "nav",
757
+ {
758
+ "data-looma-toc": "",
759
+ "data-format": config.format,
760
+ "data-depth": config.depth,
761
+ "aria-label": "Table of contents"
762
+ },
763
+ [
764
+ "strong",
765
+ {},
766
+ "Table of contents"
767
+ ],
768
+ branches.length ? list(branches) : [
769
+ "p",
770
+ {},
771
+ "Add headings to build your table of contents."
772
+ ]
773
+ ];
774
+ }
775
+ /**
776
+ * Scrolls within this editor so multiple documents cannot cross-navigate by ID.
777
+ * @contract Reader navigation does not edit content or invoke link-editing UI.
778
+ */
779
+ async function navigateTableOfContents(editor, id) {
780
+ const target = [...editor.view.dom.querySelectorAll("h1[id], h2[id], h3[id]")].find((heading) => heading.id === id);
781
+ if (!target) return;
782
+ const entry = getTableOfContentsEntries$1(editor.state.doc).find((heading) => heading.id === id);
783
+ if (entry) await revealLoomaExpandAt(editor, entry.position + 1);
784
+ if (!target.isConnected || editor.isDestroyed) return;
785
+ target.scrollIntoView({
786
+ block: "start",
787
+ behavior: "auto"
788
+ });
789
+ target.tabIndex = -1;
790
+ target.focus({ preventScroll: true });
791
+ }
792
+ Node$1.create({
793
+ name: "loomaTableOfContents",
794
+ group: "block",
795
+ atom: true,
796
+ selectable: true,
797
+ isolating: true,
798
+ addOptions: () => ({ nodeView: null }),
799
+ addExtensions: () => [LoomaHeadingAnchors],
800
+ addAttributes() {
801
+ return {
802
+ format: {
803
+ default: "plain",
804
+ parseHTML: (element) => normalizeTableOfContentsConfig$1({ format: element.getAttribute("data-format") }).format,
805
+ renderHTML: () => ({})
806
+ },
807
+ depth: {
808
+ default: 3,
809
+ parseHTML: (element) => normalizeTableOfContentsConfig$1({ depth: Number(element.getAttribute("data-depth")) }).depth,
810
+ renderHTML: () => ({})
811
+ }
812
+ };
813
+ },
814
+ parseHTML: () => [{ tag: "nav[data-looma-toc]" }],
815
+ renderHTML({ node }) {
816
+ return contentsHtml(this.editor?.view?.state.doc ?? node, normalizeTableOfContentsConfig$1(node.attrs));
817
+ },
818
+ addCommands() {
819
+ return { insertLoomaTableOfContents: (config = {}) => ({ commands }) => commands.insertContent({
820
+ type: this.name,
821
+ attrs: normalizeTableOfContentsConfig$1(config)
822
+ }) };
823
+ },
824
+ addNodeView() {
825
+ if (this.options.nodeView) return this.options.nodeView;
826
+ return ({ editor, node }) => {
827
+ const dom = document.createElement("div");
828
+ dom.contentEditable = "false";
829
+ let current = node;
830
+ const render = () => {
831
+ const { dom: content } = DOMSerializer.renderSpec(document, contentsHtml(editor.state.doc, normalizeTableOfContentsConfig$1(current.attrs)));
832
+ dom.replaceChildren(content);
833
+ };
834
+ dom.addEventListener("click", (event) => {
835
+ const anchor = event.target.closest("a");
836
+ if (!anchor) return;
837
+ event.preventDefault();
838
+ navigateTableOfContents(editor, anchor.getAttribute("href").slice(1));
839
+ });
840
+ editor.on("transaction", render);
841
+ render();
842
+ return {
843
+ dom,
844
+ update(next) {
845
+ if (next.type !== current.type) return false;
846
+ current = next;
847
+ render();
848
+ return true;
849
+ },
850
+ stopEvent: () => true,
851
+ ignoreMutation: () => true,
852
+ destroy: () => {
853
+ editor.off("transaction", render);
854
+ }
855
+ };
856
+ };
857
+ }
858
+ });
859
+ //#endregion
860
+ //#region src/vue/editor/table-of-contents.ts
861
+ var tocSequence = 0;
862
+ /**
863
+ * Composes TOC presentation and settings from the same controls as link editing.
864
+ * @lifecycle Each node owns a Vue app and document subscriptions, all released
865
+ * by its node-view destroy callback. Only configuration enters editor state.
866
+ */
867
+ var tableOfContentsNodeView = ({ editor, node, getPos }) => {
868
+ const dom = document.createElement("div");
869
+ dom.contentEditable = "false";
870
+ const current = shallowRef(node);
871
+ const doc = shallowRef(editor.state.doc);
872
+ const editable = ref(editor.isEditable);
873
+ const refresh = () => {
874
+ doc.value = editor.state.doc;
875
+ editable.value = editor.isEditable;
876
+ };
877
+ const app = createApp({ setup() {
878
+ const id = `looma-toc-${++tocSequence}`;
879
+ const open = ref(false);
880
+ const draft = ref(normalizeTableOfContentsConfig(current.value.attrs));
881
+ const config = computed(() => normalizeTableOfContentsConfig(current.value.attrs));
882
+ const branches = computed(() => tableOfContentsTree(getTableOfContentsEntries(doc.value, config.value.depth)));
883
+ const begin = () => {
884
+ draft.value = { ...config.value };
885
+ open.value = true;
886
+ };
887
+ const cancel = () => {
888
+ open.value = false;
889
+ };
890
+ const save = () => {
891
+ const position = getPos();
892
+ if (typeof position !== "number" || !editor.isEditable) return;
893
+ if (editor.state.doc.nodeAt(position)?.type.name !== "loomaTableOfContents") return;
894
+ editor.view.dispatch(editor.state.tr.setNodeMarkup(position, void 0, normalizeTableOfContentsConfig(draft.value)));
895
+ open.value = false;
896
+ editor.commands.focus();
897
+ };
898
+ const list = (items) => h(List, {
899
+ as: config.value.format === "numbered" ? "ol" : "ul",
900
+ markers: config.value.format === "bulleted" ? "disc" : "none",
901
+ density: "compact"
902
+ }, () => items.map(({ entry, children }) => h("li", { key: entry.id }, [h(Button, {
903
+ as: "a",
904
+ href: `#${entry.id}`,
905
+ variant: "link",
906
+ tone: "neutral",
907
+ size: "sm",
908
+ onClick: (event) => {
909
+ event.preventDefault();
910
+ navigateTableOfContents(editor, entry.id);
911
+ }
912
+ }, () => h(Text, {
913
+ as: "span",
914
+ wrap: "anywhere"
915
+ }, () => entry.text)), children.length ? list(children) : null])));
916
+ const formatLabels = {
917
+ plain: "Plain",
918
+ bulleted: "Bulleted",
919
+ numbered: "Numbered"
920
+ };
921
+ const dropdown = (field) => {
922
+ const label = field === "format" ? "Formatting" : "Heading depth";
923
+ const trigger = `${id}-${field}`;
924
+ const options = field === "format" ? Object.entries(formatLabels).map(([value, text]) => ({
925
+ value,
926
+ text
927
+ })) : [
928
+ 1,
929
+ 2,
930
+ 3
931
+ ].map((value) => ({
932
+ value: String(value),
933
+ text: `Through heading ${value}`
934
+ }));
935
+ return h(FormField, {}, {
936
+ label: () => h("label", { for: trigger }, label),
937
+ default: () => [h(Button, {
938
+ id: trigger,
939
+ variant: "outline",
940
+ tone: "neutral",
941
+ size: "sm",
942
+ stretch: true,
943
+ align: "start",
944
+ "aria-label": label
945
+ }, () => h(Cluster, {
946
+ justify: "between",
947
+ gap: "s"
948
+ }, () => [field === "format" ? formatLabels[draft.value.format] : `Through heading ${draft.value.depth}`, h(Icon, {
949
+ name: "chevron-down",
950
+ size: "sm"
951
+ })])), h(Menu, {
952
+ for: trigger,
953
+ size: "sm",
954
+ "aria-label": label,
955
+ onSelect: (event) => {
956
+ draft.value = normalizeTableOfContentsConfig({
957
+ ...draft.value,
958
+ [field]: field === "depth" ? Number(event.detail.value) : event.detail.value
959
+ });
960
+ }
961
+ }, () => options.map((option) => h(MenuItem, {
962
+ value: option.value,
963
+ type: "radio",
964
+ checked: String(draft.value[field]) === option.value
965
+ }, () => option.text)))]
966
+ });
967
+ };
968
+ return () => h("nav", {
969
+ "data-looma-toc": "",
970
+ "data-format": config.value.format,
971
+ "data-depth": config.value.depth,
972
+ "aria-label": "Table of contents"
973
+ }, [h(Stack, { gap: "s" }, () => [
974
+ h(Cluster, {
975
+ justify: "between",
976
+ gap: "s"
977
+ }, () => [h(Text, {
978
+ as: "strong",
979
+ size: "sm",
980
+ weight: "semibold"
981
+ }, () => "Table of contents"), editable.value ? h(IconButton, {
982
+ id,
983
+ label: "Table of contents settings",
984
+ variant: "ghost",
985
+ size: "sm"
986
+ }, () => h(Icon, {
987
+ name: "settings",
988
+ size: "sm"
989
+ })) : null]),
990
+ branches.value.length ? list(branches.value) : h(Text, {
991
+ size: "sm",
992
+ tone: "secondary"
993
+ }, () => "Add headings to build your table of contents."),
994
+ editable.value ? h(Popover, {
995
+ for: id,
996
+ open: open.value,
997
+ size: "sm",
998
+ "aria-label": "Table of contents settings",
999
+ onOpen: begin,
1000
+ onClose: cancel
1001
+ }, () => h(Stack, { gap: "s" }, () => [
1002
+ h(Text, {
1003
+ as: "strong",
1004
+ size: "sm",
1005
+ weight: "semibold"
1006
+ }, () => "Table of contents"),
1007
+ dropdown("format"),
1008
+ dropdown("depth"),
1009
+ h(Cluster, {
1010
+ justify: "end",
1011
+ gap: "xs"
1012
+ }, () => [h(Button, {
1013
+ variant: "ghost",
1014
+ tone: "neutral",
1015
+ size: "sm",
1016
+ onClick: cancel
1017
+ }, () => "Cancel"), h(Button, {
1018
+ variant: "solid",
1019
+ size: "sm",
1020
+ onClick: save
1021
+ }, () => "Save")])
1022
+ ])) : null,
1023
+ editable.value ? h(Tooltip, { for: id }, () => "Table of contents settings") : null
1024
+ ])]);
1025
+ } });
1026
+ editor.on("transaction", refresh);
1027
+ editor.on("update", refresh);
1028
+ app.mount(dom);
1029
+ return {
1030
+ dom,
1031
+ update(next) {
1032
+ if (next.type !== current.value.type) return false;
1033
+ current.value = next;
1034
+ refresh();
1035
+ return true;
1036
+ },
1037
+ stopEvent: () => true,
1038
+ ignoreMutation: () => true,
1039
+ destroy() {
1040
+ editor.off("transaction", refresh);
1041
+ editor.off("update", refresh);
1042
+ app.unmount();
1043
+ }
1044
+ };
1045
+ };
1046
+ //#endregion
1047
+ //#region src/vue/editor/expand.ts
1048
+ var expandSequence = 0;
1049
+ /**
1050
+ * Composes a section's transient disclosure and saved settings from shared controls.
1051
+ * @lifecycle The content DOM remains owned by ProseMirror; Vue owns surrounding
1052
+ * controls. Unmount removes the app, listeners, and editor subscription.
1053
+ */
1054
+ var expandNodeView = ({ editor, node, getPos }) => {
1055
+ const dom = document.createElement("div");
1056
+ dom.dataset.loomaExpand = "";
1057
+ const current = shallowRef(node);
1058
+ const editable = ref(editor.isEditable);
1059
+ const expanded = ref(editor.isEditable || Boolean(node.attrs.open));
1060
+ let contentDOM;
1061
+ const refresh = () => {
1062
+ if (editable.value !== editor.isEditable) expanded.value = editor.isEditable || Boolean(current.value.attrs.open);
1063
+ editable.value = editor.isEditable;
1064
+ };
1065
+ const reveal = (event) => {
1066
+ expanded.value = true;
1067
+ event.detail.pending.push(nextTick().then(async () => {
1068
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
1069
+ const panel = dom.querySelector("[data-component='ui-disclosure'] .panel");
1070
+ await Promise.all((panel?.getAnimations() ?? []).map((animation) => animation.finished.catch(() => {})));
1071
+ }));
1072
+ };
1073
+ const app = createApp({ setup() {
1074
+ const id = `looma-expand-${++expandSequence}`;
1075
+ const settings = ref(false);
1076
+ const title = ref(String(current.value.attrs.summary));
1077
+ const defaultOpen = ref(Boolean(current.value.attrs.open));
1078
+ const begin = () => {
1079
+ title.value = current.value.attrs.summary;
1080
+ defaultOpen.value = Boolean(current.value.attrs.open);
1081
+ settings.value = true;
1082
+ };
1083
+ const save = () => {
1084
+ const position = getPos();
1085
+ if (typeof position !== "number" || !editor.isEditable || editor.state.doc.nodeAt(position)?.type.name !== "loomaExpand") return;
1086
+ editor.view.dispatch(editor.state.tr.setNodeMarkup(position, void 0, {
1087
+ summary: title.value.trim() || "Details",
1088
+ open: defaultOpen.value
1089
+ }));
1090
+ settings.value = false;
1091
+ editor.commands.focus();
1092
+ };
1093
+ return () => h(Stack, { gap: "xs" }, () => [
1094
+ editable.value ? h(Cluster, {
1095
+ justify: "end",
1096
+ gap: "xs"
1097
+ }, () => h(IconButton, {
1098
+ id,
1099
+ label: "Section settings",
1100
+ variant: "ghost",
1101
+ size: "sm"
1102
+ }, () => h(Icon, {
1103
+ name: "settings",
1104
+ size: "sm"
1105
+ }))) : null,
1106
+ editable.value ? h(Tooltip, { for: id }, () => "Section settings") : null,
1107
+ h(Disclosure, {
1108
+ summary: current.value.attrs.summary,
1109
+ open: expanded.value,
1110
+ density: "compact",
1111
+ onOpen: () => {
1112
+ expanded.value = true;
1113
+ },
1114
+ onClose: () => {
1115
+ expanded.value = false;
1116
+ }
1117
+ }, () => h("div", {
1118
+ "data-looma-expand-body": "",
1119
+ ref: (element) => {
1120
+ if (element) contentDOM = element;
1121
+ }
1122
+ })),
1123
+ editable.value ? h(Popover, {
1124
+ for: id,
1125
+ open: settings.value,
1126
+ size: "sm",
1127
+ "aria-label": "Section settings",
1128
+ onOpen: begin,
1129
+ onClose: () => {
1130
+ settings.value = false;
1131
+ }
1132
+ }, () => h(Stack, { gap: "s" }, () => [
1133
+ h(Text, {
1134
+ as: "strong",
1135
+ size: "sm",
1136
+ weight: "semibold"
1137
+ }, () => "Section settings"),
1138
+ h(FormField, {}, {
1139
+ label: () => h("label", { for: `${id}-summary` }, "Summary"),
1140
+ default: () => h(Input, {
1141
+ id: `${id}-summary`,
1142
+ value: title.value,
1143
+ size: "sm",
1144
+ onInput: (event) => {
1145
+ title.value = event.target.value;
1146
+ }
1147
+ })
1148
+ }),
1149
+ h(Checkbox, {
1150
+ checked: defaultOpen.value,
1151
+ onChange: (event) => {
1152
+ defaultOpen.value = event.detail.checked;
1153
+ }
1154
+ }, () => "Initially expanded"),
1155
+ h(Cluster, {
1156
+ justify: "end",
1157
+ gap: "xs"
1158
+ }, () => [h(Button, {
1159
+ variant: "ghost",
1160
+ tone: "neutral",
1161
+ size: "sm",
1162
+ onClick: () => {
1163
+ settings.value = false;
1164
+ }
1165
+ }, () => "Cancel"), h(Button, {
1166
+ variant: "solid",
1167
+ size: "sm",
1168
+ onClick: save
1169
+ }, () => "Save")])
1170
+ ])) : null
1171
+ ]);
1172
+ } });
1173
+ dom.addEventListener("looma-editor-reveal", reveal);
1174
+ editor.on("update", refresh);
1175
+ app.mount(dom);
1176
+ return {
1177
+ dom,
1178
+ contentDOM,
1179
+ update(next) {
1180
+ if (next.type !== current.value.type) return false;
1181
+ if (next.attrs.open !== current.value.attrs.open) expanded.value = editor.isEditable || Boolean(next.attrs.open);
1182
+ current.value = next;
1183
+ refresh();
1184
+ return true;
1185
+ },
1186
+ stopEvent: (event) => !contentDOM.contains(event.target),
1187
+ ignoreMutation: (mutation) => mutation.type !== "selection" && !contentDOM.contains(mutation.target),
1188
+ destroy() {
1189
+ dom.removeEventListener("looma-editor-reveal", reveal);
1190
+ editor.off("update", refresh);
1191
+ app.unmount();
1192
+ }
1193
+ };
1194
+ };
1195
+ //#endregion
1196
+ //#region src/vue/editor/divider.ts
1197
+ /** A pointer-sized document divider using the shared separator's selected state.
1198
+ * @lifecycle The editor owns the node and deletion; destroying the view releases
1199
+ * its Vue app and editability subscription.
1200
+ */
1201
+ var dividerNodeView = ({ editor, getPos }) => {
1202
+ const mount = document.createElement("div");
1203
+ const selected = ref(false);
1204
+ const editable = ref(editor.isEditable);
1205
+ const refresh = () => {
1206
+ editable.value = editor.isEditable;
1207
+ };
1208
+ const app = createApp({ render: () => h(Separator, {
1209
+ selectable: editable.value,
1210
+ selected: editable.value && selected.value,
1211
+ contenteditable: "false",
1212
+ onClick: () => {
1213
+ const position = getPos();
1214
+ if (!editor.isEditable || typeof position !== "number") return;
1215
+ editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, position)));
1216
+ editor.view.focus();
1217
+ }
1218
+ }) });
1219
+ app.mount(mount);
1220
+ const dom = mount.firstElementChild;
1221
+ editor.on("update", refresh);
1222
+ return {
1223
+ dom,
1224
+ selectNode() {
1225
+ selected.value = true;
1226
+ },
1227
+ deselectNode() {
1228
+ selected.value = false;
1229
+ },
1230
+ stopEvent: () => false,
1231
+ ignoreMutation: (mutation) => mutation.type !== "selection",
1232
+ destroy() {
1233
+ editor.off("update", refresh);
1234
+ app.unmount();
1235
+ }
1236
+ };
1237
+ };
1238
+ //#endregion
1239
+ //#region src/vue/editor/image-controls.ts
1240
+ var placements = [
1241
+ {
1242
+ value: "block",
1243
+ label: "Image in line",
1244
+ icon: "panel-top"
1245
+ },
1246
+ {
1247
+ value: "center",
1248
+ label: "Center image",
1249
+ icon: "align-center"
1250
+ },
1251
+ {
1252
+ value: "wrap-left",
1253
+ label: "Wrap text to the right",
1254
+ icon: "panel-left"
1255
+ },
1256
+ {
1257
+ value: "wrap-right",
1258
+ label: "Wrap text to the left",
1259
+ icon: "panel-right"
1260
+ }
1261
+ ];
1262
+ /** One contextual editing bar; sizing belongs to the image's corner handles.
1263
+ * @contract Placement applies immediately. Alt text is a secondary draft with
1264
+ * Save/Cancel; editing another node cancels it rather than changing that node.
1265
+ */
1266
+ var ImageControls = defineComponent({
1267
+ props: { editor: {
1268
+ type: Object,
1269
+ required: true
1270
+ } },
1271
+ setup(props) {
1272
+ const id = `looma-image-${useId()}`;
1273
+ const open = ref(false);
1274
+ const alt = ref("");
1275
+ const placement = ref("block");
1276
+ let target = null;
1277
+ const selected = () => props.editor.state.selection instanceof NodeSelection && props.editor.state.selection.node.type.name === "image" ? props.editor.state.selection : null;
1278
+ const refresh = () => {
1279
+ const selection = selected();
1280
+ placement.value = normalizeImagePlacement(selection?.node.attrs.placement);
1281
+ if (open.value && selection?.node !== target) open.value = false;
1282
+ };
1283
+ props.editor.on("transaction", refresh);
1284
+ onBeforeUnmount(() => props.editor.off("transaction", refresh));
1285
+ const begin = () => {
1286
+ const selection = selected();
1287
+ if (!selection) return;
1288
+ target = selection.node;
1289
+ alt.value = selection.node.attrs.alt ?? "";
1290
+ open.value = true;
1291
+ };
1292
+ const update = (attrs) => {
1293
+ const selection = selected();
1294
+ if (!selection || !props.editor.isEditable) return;
1295
+ props.editor.view.dispatch(props.editor.state.tr.setNodeMarkup(selection.from, void 0, {
1296
+ ...selection.node.attrs,
1297
+ ...attrs
1298
+ }));
1299
+ props.editor.view.focus();
1300
+ };
1301
+ return () => h(Text, {
1302
+ as: "div",
1303
+ font: "sans"
1304
+ }, () => [
1305
+ h(EditorToolbar$1, {
1306
+ floating: true,
1307
+ "aria-label": "Image actions"
1308
+ }, () => [
1309
+ ...placements.map((option) => h(IconButton, {
1310
+ id: `${id}-${option.value}`,
1311
+ label: option.label,
1312
+ size: "sm",
1313
+ variant: placement.value === option.value ? "outline" : "ghost",
1314
+ "aria-pressed": placement.value === option.value,
1315
+ onClick: () => update({
1316
+ placement: option.value,
1317
+ ...option.value.startsWith("wrap-") ? {
1318
+ width: null,
1319
+ height: null
1320
+ } : {}
1321
+ })
1322
+ }, () => h(Icon, {
1323
+ name: option.icon,
1324
+ size: "sm"
1325
+ }))),
1326
+ h(IconButton, {
1327
+ id,
1328
+ label: "Image description",
1329
+ size: "sm",
1330
+ variant: "ghost"
1331
+ }, () => h(Icon, {
1332
+ name: "file-text",
1333
+ size: "sm"
1334
+ })),
1335
+ h(IconButton, {
1336
+ id: `${id}-delete`,
1337
+ label: "Delete image",
1338
+ size: "sm",
1339
+ variant: "ghost",
1340
+ onClick: () => {
1341
+ if (selected() && props.editor.isEditable) props.editor.chain().focus().deleteSelection().run();
1342
+ }
1343
+ }, () => h(Icon, {
1344
+ name: "trash",
1345
+ size: "sm"
1346
+ }))
1347
+ ]),
1348
+ ...placements.map((option) => h(Tooltip, { for: `${id}-${option.value}` }, () => option.label)),
1349
+ h(Tooltip, { for: id }, () => "Image description"),
1350
+ h(Tooltip, { for: `${id}-delete` }, () => "Delete image"),
1351
+ h(Popover, {
1352
+ for: id,
1353
+ open: open.value,
1354
+ size: "sm",
1355
+ "aria-label": "Image description",
1356
+ onOpen: begin,
1357
+ onClose: () => {
1358
+ open.value = false;
1359
+ }
1360
+ }, () => h("form", {
1361
+ "aria-label": "Image description",
1362
+ onSubmit: (event) => {
1363
+ event.preventDefault();
1364
+ if (selected()?.node !== target) return;
1365
+ open.value = false;
1366
+ update({ alt: alt.value });
1367
+ }
1368
+ }, [h(Stack, { gap: "s" }, () => [
1369
+ h(FormField, {}, {
1370
+ label: () => h("label", { for: `${id}-alt` }, "Alt text"),
1371
+ default: () => h(Input, {
1372
+ id: `${id}-alt`,
1373
+ size: "sm",
1374
+ value: alt.value,
1375
+ onInput: (event) => {
1376
+ alt.value = event.target.value;
1377
+ }
1378
+ })
1379
+ }),
1380
+ h(Text, {
1381
+ size: "xs",
1382
+ tone: "secondary"
1383
+ }, () => "Describe the image for people using a screen reader."),
1384
+ h(Cluster, {
1385
+ justify: "end",
1386
+ gap: "xs"
1387
+ }, () => [h(Button, {
1388
+ type: "button",
1389
+ variant: "ghost",
1390
+ tone: "neutral",
1391
+ size: "sm",
1392
+ onClick: () => {
1393
+ open.value = false;
1394
+ }
1395
+ }, () => "Cancel"), h(Button, {
1396
+ type: "submit",
1397
+ variant: "solid",
1398
+ size: "sm"
1399
+ }, () => "Save")])
1400
+ ])]))
1401
+ ]);
1402
+ }
1403
+ });
1404
+ //#endregion
1405
+ //#region src/vue/editor/image.ts
1406
+ /** Projects the shared image's resize intent into a single document transaction.
1407
+ * @ownership Delivery decorations belong to the inner image, never the figure;
1408
+ * persisted attributes remain independent of host renditions and drag previews.
1409
+ * @lifecycle Each atom owns a Vue app and an editability subscription.
1410
+ */
1411
+ var imageNodeView = ({ editor, node, getPos, decorations }) => {
1412
+ const mount = document.createElement("div");
1413
+ const current = shallowRef(node);
1414
+ const delivery = shallowRef(decorations);
1415
+ const selected = ref(false);
1416
+ const editable = ref(editor.isEditable);
1417
+ const refresh = () => {
1418
+ editable.value = editor.isEditable;
1419
+ };
1420
+ const app = createApp({ render: () => {
1421
+ const attrs = current.value.attrs;
1422
+ const transient = Object.assign({}, ...delivery.value.map((decoration) => decoration.spec.loomaImageAttributes ?? {}));
1423
+ return h(Image, {
1424
+ src: attrs.src,
1425
+ alt: attrs.alt ?? "",
1426
+ width: attrs.width,
1427
+ height: attrs.height,
1428
+ placement: normalizeImagePlacement(attrs.placement),
1429
+ selected: editable.value && selected.value,
1430
+ resizable: editable.value,
1431
+ contenteditable: "false",
1432
+ "data-looma-image-node": "",
1433
+ onClick: (event) => {
1434
+ if (event.target.closest("button")) return;
1435
+ const position = getPos();
1436
+ if (!editor.isEditable || typeof position !== "number") return;
1437
+ editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, position)));
1438
+ editor.view.focus();
1439
+ },
1440
+ onResize: (event) => {
1441
+ if (event.detail.phase !== "commit" || !editor.isEditable) return;
1442
+ const position = getPos();
1443
+ if (typeof position !== "number" || editor.state.doc.nodeAt(position) !== current.value) return;
1444
+ const { width, height } = event.detail;
1445
+ if (width === current.value.attrs.width && height === current.value.attrs.height) return;
1446
+ editor.view.dispatch(closeHistory(editor.state.tr).setNodeMarkup(position, void 0, {
1447
+ ...current.value.attrs,
1448
+ width,
1449
+ height
1450
+ }));
1451
+ editor.view.dispatch(closeHistory(editor.state.tr));
1452
+ }
1453
+ }, { media: () => h("img", {
1454
+ src: attrs.src,
1455
+ alt: attrs.alt ?? "",
1456
+ title: attrs.title,
1457
+ width: attrs.width,
1458
+ height: attrs.height,
1459
+ ...transient
1460
+ }) });
1461
+ } });
1462
+ app.mount(mount);
1463
+ const dom = mount.firstElementChild;
1464
+ editor.on("update", refresh);
1465
+ return {
1466
+ dom,
1467
+ update(next, nextDecorations) {
1468
+ if (next.type !== current.value.type) return false;
1469
+ current.value = next;
1470
+ delivery.value = nextDecorations;
1471
+ refresh();
1472
+ return true;
1473
+ },
1474
+ selectNode() {
1475
+ selected.value = true;
1476
+ },
1477
+ deselectNode() {
1478
+ selected.value = false;
1479
+ },
1480
+ stopEvent: (event) => event.target instanceof Element && Boolean(event.target.closest("[data-corner]")),
1481
+ ignoreMutation: (mutation) => mutation.type !== "selection",
1482
+ destroy() {
1483
+ editor.off("update", refresh);
1484
+ app.unmount();
1485
+ }
1486
+ };
1487
+ };
1488
+ //#endregion
410
1489
  //#region src/vue/editor/LoomaEditor.ts
411
1490
  /**
412
1491
  * Shows a key binding in ProseMirror notation ("Mod-Shift-8") the way the reader's platform writes
@@ -479,12 +1558,25 @@ function managedMenuAnchorRect(rect) {
479
1558
  height: rect.height
480
1559
  };
481
1560
  }
482
- function managedSlashMenuItems(items) {
483
- return items.map(({ title, description, icon }) => ({
484
- title,
485
- description,
486
- icon
487
- }));
1561
+ function slashMenuRows(items) {
1562
+ const groups = [];
1563
+ for (const [index, item] of items.entries()) {
1564
+ if (!groups.length || groups.at(-1).label !== item.group) groups.push({
1565
+ label: item.group,
1566
+ rows: []
1567
+ });
1568
+ groups.at(-1).rows.push(h(EditorSlashMenuItem, {
1569
+ key: item.id ?? `${item.title}-${index}`,
1570
+ value: item.id ?? item.title,
1571
+ description: item.description,
1572
+ icon: item.icon,
1573
+ keywords: [...item.keywords, item.id ?? ""].join(" ")
1574
+ }, () => item.title));
1575
+ }
1576
+ return groups.flatMap(({ label, rows }, index) => label ? [h(EditorSlashMenuGroup, {
1577
+ key: `${label}-${index}`,
1578
+ label
1579
+ }, () => rows)] : rows);
488
1580
  }
489
1581
  function selectedTableElement(editor) {
490
1582
  const { node } = editor.view.domAtPos(editor.state.selection.from);
@@ -547,6 +1639,11 @@ var LoomaEditor = defineComponent({
547
1639
  type: Array,
548
1640
  default: () => []
549
1641
  },
1642
+ /** One inventory for the existing slash extension; an empty array intentionally supplies no commands. */
1643
+ slashCommands: {
1644
+ type: [Array, Function],
1645
+ default: void 0
1646
+ },
550
1647
  /** Override the lazy built-in catalog with application-owned code grammars. An empty object disables highlighting. */
551
1648
  codeLanguages: {
552
1649
  type: Object,
@@ -774,7 +1871,12 @@ var LoomaEditor = defineComponent({
774
1871
  chipOpen.value = false;
775
1872
  if (focusChip) root.value?.querySelector(`#${chipAnchorId}`)?.focus();
776
1873
  };
1874
+ const resolveSlashCommands = () => {
1875
+ const defaults = getDefaultSlashCommands(props.uploadImage ? () => fileInput.value?.click() : void 0, openChipEditor, () => openLinkEditor());
1876
+ return typeof props.slashCommands === "function" ? props.slashCommands(defaults) : props.slashCommands ?? defaults;
1877
+ };
777
1878
  const slashExtension = createLoomaSlashCommandExtension({
1879
+ commands: resolveSlashCommands(),
778
1880
  onOpenImagePicker: () => fileInput.value?.click(),
779
1881
  onOpenChipEditor: openChipEditor,
780
1882
  onOpenLinkEditor: () => openLinkEditor(),
@@ -794,7 +1896,10 @@ var LoomaEditor = defineComponent({
794
1896
  Object.assign(mention, state);
795
1897
  }
796
1898
  });
797
- const imageDelivery = createLoomaImageDeliveryController({ resolveAttributes: () => props.resolveImageAttributes });
1899
+ const imageDelivery = createLoomaImageDeliveryController({
1900
+ resolveAttributes: () => props.resolveImageAttributes,
1901
+ isEditable: () => props.editable
1902
+ });
798
1903
  let lastFocusedSelection = null;
799
1904
  let linkPressedSelection = null;
800
1905
  const rememberSelection = (instance) => {
@@ -862,7 +1967,11 @@ var LoomaEditor = defineComponent({
862
1967
  mention: mentionExtension ?? false,
863
1968
  disableHighlight: props.disableHighlight,
864
1969
  codeLanguages: props.codeLanguages ?? void 0,
865
- codeLowlight: codeHighlightLowlight
1970
+ codeLowlight: codeHighlightLowlight,
1971
+ tableOfContentsNodeView,
1972
+ expandNodeView,
1973
+ dividerNodeView,
1974
+ imageNodeView
866
1975
  }),
867
1976
  imageDelivery.extension,
868
1977
  slashExtension,
@@ -1326,8 +2435,13 @@ var LoomaEditor = defineComponent({
1326
2435
  instance.view.dispatch(transaction);
1327
2436
  instance.commands.focus();
1328
2437
  };
2438
+ watch([() => props.slashCommands, () => props.uploadImage], () => {
2439
+ slashExtension.options.commands = resolveSlashCommands();
2440
+ editor.value?.commands.dismissLoomaSlashMenu();
2441
+ });
1329
2442
  watch(() => props.editable, (editable) => {
1330
2443
  editor.value?.setEditable(editable);
2444
+ if (editor.value) imageDelivery.reset(editor.value);
1331
2445
  root.value?.querySelectorAll("[data-looma-chip]").forEach((chip) => {
1332
2446
  chip.setAttribute("aria-disabled", String(!editable));
1333
2447
  });
@@ -1583,7 +2697,8 @@ var LoomaEditor = defineComponent({
1583
2697
  const instance = editor.value;
1584
2698
  if (!instance) return null;
1585
2699
  try {
1586
- const position = instance.view.posAtDOM(element, 0);
2700
+ const frame = element.closest("[data-looma-image-node]");
2701
+ const position = frame?.parentNode ? instance.view.posAtDOM(frame.parentNode, Array.from(frame.parentNode.childNodes).indexOf(frame)) : instance.view.posAtDOM(element, 0);
1587
2702
  const node = instance.state.doc.nodeAt(position);
1588
2703
  return node?.type.name === "image" ? imageDescriptorFromAttrs(node.attrs) : null;
1589
2704
  } catch {
@@ -1630,7 +2745,7 @@ var LoomaEditor = defineComponent({
1630
2745
  return;
1631
2746
  }
1632
2747
  const element = imageElementForEvent(event);
1633
- if (!element) return;
2748
+ if (!element || props.editable) return;
1634
2749
  event.preventDefault();
1635
2750
  activateImage(element, "keyboard");
1636
2751
  };
@@ -2045,7 +3160,22 @@ var LoomaEditor = defineComponent({
2045
3160
  role: "toolbar",
2046
3161
  "aria-label": "Editor tools"
2047
3162
  }, [renderToolbar(instance, false)]) : null,
2048
- instance ? h(EditorContent, { editor: instance }) : null,
3163
+ instance && props.editable ? h(BubbleMenu, {
3164
+ editor: instance,
3165
+ pluginKey: "looma-image-actions",
3166
+ shouldShow: ({ editor: menuEditor }) => menuEditor.isEditable && menuEditor.state.selection instanceof NodeSelection && menuEditor.state.selection.node.type.name === "image",
3167
+ tippyOptions: {
3168
+ appendTo: () => root.value?.closest("dialog[open], [popover]") ?? document.body,
3169
+ placement: "top",
3170
+ duration: 100,
3171
+ maxWidth: window.innerWidth - 24,
3172
+ getReferenceClientRect: () => {
3173
+ const dom = instance.view.nodeDOM(instance.state.selection.from);
3174
+ return (dom instanceof Element ? dom.querySelector("img") ?? dom : instance.view.dom).getBoundingClientRect();
3175
+ }
3176
+ }
3177
+ }, { default: () => h(ImageControls, { editor: instance }) }) : null,
3178
+ instance ? h(Stack, { gap: "none" }, () => h(EditorContent, { editor: instance })) : null,
2049
3179
  codeUi.open ? h("div", {
2050
3180
  ref: codeLanguageShell,
2051
3181
  class: "looma-editor__code-language",
@@ -2193,18 +3323,24 @@ var LoomaEditor = defineComponent({
2193
3323
  tablePickerOpen.value = false;
2194
3324
  }
2195
3325
  })]),
2196
- slash.active && slash.items.length > 0 ? h(EditorSlashMenu, {
3326
+ slash.active ? h(EditorSlashMenu, {
2197
3327
  open: true,
2198
3328
  query: slash.query,
2199
- items: managedSlashMenuItems(slash.items),
2200
3329
  selectedIndex: slash.selectedIndex,
2201
3330
  anchorRect: managedMenuAnchorRect(slash.rect),
2202
3331
  onHighlight: (event) => {
2203
- slash.selectedIndex = event.detail.index;
3332
+ slash.highlight?.(event.detail.index);
2204
3333
  },
2205
3334
  onSelect: (event) => {
2206
3335
  slash.select?.(event.detail.index);
2207
3336
  }
3337
+ }, {
3338
+ default: () => slashMenuRows(slash.items),
3339
+ empty: () => h(Text, {
3340
+ role: "status",
3341
+ size: "sm",
3342
+ tone: "muted"
3343
+ }, () => "No commands found.")
2208
3344
  }) : null,
2209
3345
  mention.active && (mention.loading || mention.items.length > 0) ? h(EditorMentionMenu, {
2210
3346
  id: mentionMenuId,