@threadlabs/looma 0.29.1 → 0.29.3

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.
@@ -0,0 +1,39 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { type LoomaChipColor } from "./extensions/chip.js";
3
+ /** The chip editor's state: whether it is open, and the label and tone of the chip it edits. */
4
+ export interface LoomaChipEditingState {
5
+ readonly open: boolean;
6
+ readonly label: string;
7
+ readonly color: LoomaChipColor;
8
+ }
9
+ export declare const LOOMA_CHIP_EDITING_IDLE: LoomaChipEditingState;
10
+ /**
11
+ * The chip editor as a state machine: it opens on one chip and writes each label and tone change to
12
+ * that chip as the author makes it.
13
+ *
14
+ * @ownership The host owns presentation: the popover, its anchor, and focus. This owns which chip is
15
+ * edited and the node attributes it writes.
16
+ * @failure A change after the chip was deleted, or moved out of its node, closes the editor and writes
17
+ * nothing.
18
+ */
19
+ export interface LoomaChipEditing {
20
+ readonly state: LoomaChipEditingState;
21
+ /** The edited chip's element, for anchoring the editor, or null while it is closed. */
22
+ readonly element: HTMLElement | null;
23
+ /** Calls `listener` after every state change; returns the unsubscribe function. */
24
+ subscribe(listener: () => void): () => void;
25
+ /**
26
+ * Opens on the chip at `position`.
27
+ * @failure Returns false, and stays as it was, when the editor is read-only or no chip is there.
28
+ */
29
+ open(position: number): boolean;
30
+ setLabel(label: string): void;
31
+ setColor(color: LoomaChipColor): void;
32
+ close(): void;
33
+ }
34
+ export interface LoomaChipEditingOptions {
35
+ editor: Editor;
36
+ /** Read on each opening, so a host can change it while the editor runs. */
37
+ isEditable: () => boolean;
38
+ }
39
+ export declare function createChipEditing(options: LoomaChipEditingOptions): LoomaChipEditing;
@@ -0,0 +1,47 @@
1
+ import type { EditorState } from "@tiptap/pm/state";
2
+ /** Host-owned non-mutating commands for the selected passage. */
3
+ export interface LoomaEditorSelectionAction {
4
+ id: string;
5
+ label: string;
6
+ }
7
+ /** Host action available at a collapsed caret inside an ephemeral editor range. */
8
+ export interface LoomaEditorRangeAction extends LoomaEditorSelectionAction {
9
+ from: number;
10
+ to: number;
11
+ }
12
+ /** Matching ranges are grouped into one control for each action ID. */
13
+ export interface LoomaEditorRangeActionDetail {
14
+ id: string;
15
+ ranges: {
16
+ from: number;
17
+ to: number;
18
+ }[];
19
+ }
20
+ /** Exact editor range captured before focus leaves for a host action. */
21
+ export interface LoomaEditorSelectionActionDetail {
22
+ id: string;
23
+ from: number;
24
+ to: number;
25
+ text: string;
26
+ before: string;
27
+ after: string;
28
+ }
29
+ /**
30
+ * The range actions whose range holds the collapsed text caret.
31
+ * @contract Ranges that are not whole numbers, are empty, or fall outside the document are ignored.
32
+ */
33
+ export declare function rangeActionsAtCaret(state: EditorState, actions: readonly LoomaEditorRangeAction[]): LoomaEditorRangeAction[];
34
+ /** One control per action ID: its label and each distinct range it covers at the caret, in order. */
35
+ export declare function groupRangeActions(actions: readonly LoomaEditorRangeAction[]): Array<{
36
+ id: string;
37
+ label: string;
38
+ detail: LoomaEditorRangeActionDetail;
39
+ }>;
40
+ /** Whether the selection is a passage that selection actions apply to: nonempty text, or everything. */
41
+ export declare function selectsPassage(state: EditorState): boolean;
42
+ /**
43
+ * A selection action's detail for the current passage: its range, its text, and up to 48 characters
44
+ * of context on each side, captured before focus leaves the editor.
45
+ * @failure Returns null when the selection is empty.
46
+ */
47
+ export declare function selectionActionDetail(state: EditorState, id: string): LoomaEditorSelectionActionDetail | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.29.1",
3
+ "version": "0.29.3",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -6,6 +6,7 @@ import { type LoomaSlashCommand, type LoomaMentionItem, type LoomaCodeLanguages
6
6
  import { type LoomaImageActivationDetail, type LoomaImageAttributeResolver, type LoomaImageDescriptor, type LoomaImageSourceResolver, type LoomaImageRenditionErrorDetail } from "../../editor/image-delivery.js";
7
7
  import { type LoomaImageUploadDetail } from "../../editor/image-uploads.js";
8
8
  import { type LoomaResponder } from "../../editor/requests.js";
9
+ import { type LoomaEditorRangeAction, type LoomaEditorRangeActionDetail, type LoomaEditorSelectionAction, type LoomaEditorSelectionActionDetail } from "../../editor/host-actions.js";
9
10
  import { type LoomaLinkTarget } from "../../editor/link-editing.js";
10
11
  export type { LoomaImageUploadDetail } from "../../editor/image-uploads.js";
11
12
  export type { LoomaRequestInit, LoomaResponder } from "../../editor/requests.js";
@@ -17,33 +18,7 @@ export type { LoomaRequestInit, LoomaResponder } from "../../editor/requests.js"
17
18
  * set. Narrow viewports retain a dock; document commands/content are unchanged.
18
19
  */
19
20
  export type LoomaEditorToolbarMode = "bubble" | "sticky" | "popover" | "contextual";
20
- /** Host-owned non-mutating commands for the selected passage. */
21
- export interface LoomaEditorSelectionAction {
22
- id: string;
23
- label: string;
24
- }
25
- /** Host action available at a collapsed caret inside an ephemeral editor range. */
26
- export interface LoomaEditorRangeAction extends LoomaEditorSelectionAction {
27
- from: number;
28
- to: number;
29
- }
30
- /** Matching ranges are grouped into one control for each action ID. */
31
- export interface LoomaEditorRangeActionDetail {
32
- id: string;
33
- ranges: {
34
- from: number;
35
- to: number;
36
- }[];
37
- }
38
- /** Exact editor range captured before focus leaves for a host action. */
39
- export interface LoomaEditorSelectionActionDetail {
40
- id: string;
41
- from: number;
42
- to: number;
43
- text: string;
44
- before: string;
45
- after: string;
46
- }
21
+ export type { LoomaEditorRangeAction, LoomaEditorRangeActionDetail, LoomaEditorSelectionAction, LoomaEditorSelectionActionDetail, } from "../../editor/host-actions.js";
47
22
  export type { LoomaLinkTarget } from "../../editor/link-editing.js";
48
23
  /** The `slash-commands` request's detail: this editor's default commands. */
49
24
  export interface LoomaSlashCommandsDetail {
@@ -402,10 +377,10 @@ export declare const LoomaEditor: import("vue").DefineComponent<import("vue").Ex
402
377
  onImageActivate?: ((_detail: LoomaImageActivationDetail) => any) | undefined;
403
378
  onImageRenditionError?: ((_detail: LoomaImageRenditionErrorDetail) => any) | undefined;
404
379
  }>, {
405
- extensions: LoomaEditorExtensions | undefined;
406
- editable: boolean;
407
380
  label: string;
408
381
  placeholder: string;
382
+ extensions: LoomaEditorExtensions | undefined;
383
+ editable: boolean;
409
384
  linkBaseUrl: string;
410
385
  disableHighlight: boolean;
411
386
  codeLanguages: Readonly<Record<string, import("highlight.js").LanguageFn>> | null;
@@ -62,10 +62,10 @@ export declare const LoomaCompactEditor: import("vue").DefineComponent<import("v
62
62
  "onUpdate:modelValue"?: ((_value: JSONContent) => any) | undefined;
63
63
  onReady?: ((_editor: Editor) => any) | undefined;
64
64
  }>, {
65
+ id: string;
66
+ label: string;
65
67
  extensions: AnyExtension[];
66
68
  editable: boolean;
67
- label: string;
68
- id: string;
69
69
  modelValue: JSONContent;
70
70
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
71
71
  /** Renders basic compact JSON as escaped Vue nodes, including during SSR. */
@@ -1,5 +1,5 @@
1
1
  import { computed, createApp, defineComponent, h, nextTick, onBeforeUnmount, onMounted, onUpdated, reactive, ref, shallowRef, useId, watch } from "vue";
2
- import { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_ICONS, createLoomaMentionExtension, createLoomaSlashCommandExtension, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeLoomaChipColor, normalizeTableOfContentsConfig } from "@threadlabs/looma/editor";
2
+ import { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_ICONS, createLoomaMentionExtension, createLoomaSlashCommandExtension, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeTableOfContentsConfig } from "@threadlabs/looma/editor";
3
3
  import { Button, Callout, Card, Checkbox, Cluster, Disclosure, EditorBlockActions, EditorChipEditor, EditorCodeLanguage, EditorImageToolbar, EditorInsertTableGrid, EditorLinkCard, EditorLinkForm, EditorMentionMenu, EditorMentionMenuItem, EditorSlashMenu, EditorSlashMenuGroup, EditorSlashMenuItem, EditorSurface, EditorTableContextMenu, EditorTableOverlay, EditorTableToolbar, EditorToolbar, FormField, Icon, IconButton, Image, Input, List, Menu, MenuItem, Popover, Separator, Stack, StatusMessage, Text, Tooltip } from "@threadlabs/looma/vue";
4
4
  import { Editor, Extension, Node as Node$1, getText, getTextSerializersFromSchema, isNodeSelection, mergeAttributes, nodeInputRule, posToDOMRect } from "@tiptap/core";
5
5
  import { Editor as Editor$1 } from "@tiptap/vue-3";
@@ -1536,6 +1536,281 @@ function validLinkHref(value) {
1536
1536
  }
1537
1537
  }
1538
1538
  //#endregion
1539
+ //#region src/markdown/index.ts
1540
+ /**
1541
+ * Callout tones a document stores, in the order the editor offers them. The Callout component also
1542
+ * takes `neutral` and `success`, which Markdown and the editor store as `note`.
1543
+ */
1544
+ var LOOMA_CALLOUT_TONES = [
1545
+ "note",
1546
+ "tip",
1547
+ "info",
1548
+ "important",
1549
+ "warning",
1550
+ "danger"
1551
+ ];
1552
+ /** The hues chips and swatches take as their tone, written `:chip[Label]{tone=green}`. */
1553
+ var LOOMA_CHIP_TONES = [
1554
+ "neutral",
1555
+ "blue",
1556
+ "green",
1557
+ "yellow",
1558
+ "red",
1559
+ "purple"
1560
+ ];
1561
+ /**
1562
+ * Keeps a chip tone within the palette.
1563
+ * @failure Unknown values become `neutral`.
1564
+ */
1565
+ function normalizeLoomaChipTone(value) {
1566
+ return LOOMA_CHIP_TONES.includes(value) ? value : "neutral";
1567
+ }
1568
+ [...LOOMA_CALLOUT_TONES];
1569
+ /**
1570
+ * The anchor a heading's text derives: NFC, lowercase, keeping letters, numbers and combining
1571
+ * marks, with every other run of characters one hyphen.
1572
+ * @failure Text with no letters or numbers, such as only emoji, derives `heading`.
1573
+ */
1574
+ function loomaHeadingSlug(text) {
1575
+ return text.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}\p{M}]+/gu, "-").replace(/^-+|-+$/g, "") || "heading";
1576
+ }
1577
+ /**
1578
+ * Gives every heading of a document its anchor, in document order.
1579
+ * @contract A custom ID is kept unless an earlier heading already has it. Any other heading takes
1580
+ * its slug, or the slug's first free suffix (`-1`, `-2`…), never one a later custom ID reserves.
1581
+ */
1582
+ function assignLoomaHeadingIds(headings) {
1583
+ const reserved = new Set(headings.map(({ id }) => id).filter((id) => Boolean(id)));
1584
+ const used = /* @__PURE__ */ new Set();
1585
+ return headings.map(({ text, id: custom }) => {
1586
+ let id = custom || loomaHeadingSlug(text);
1587
+ if (custom ? used.has(id) : used.has(id) || reserved.has(id)) {
1588
+ const stem = id;
1589
+ let suffix = 1;
1590
+ while (used.has(`${stem}-${suffix}`) || reserved.has(`${stem}-${suffix}`)) suffix++;
1591
+ id = `${stem}-${suffix}`;
1592
+ }
1593
+ used.add(id);
1594
+ return id;
1595
+ });
1596
+ }
1597
+ //#endregion
1598
+ //#region src/editor/extensions/chip.ts
1599
+ var CHIP_PLACEHOLDER = "Set a label";
1600
+ /** Keep imported or programmatic colors within the supported palette. */
1601
+ function normalizeLoomaChipColor(value) {
1602
+ return normalizeLoomaChipTone(value);
1603
+ }
1604
+ Node$1.create({
1605
+ name: "loomaChip",
1606
+ group: "inline",
1607
+ inline: true,
1608
+ atom: true,
1609
+ selectable: true,
1610
+ addAttributes() {
1611
+ return {
1612
+ label: {
1613
+ default: "",
1614
+ parseHTML: (element) => element.getAttribute("data-label") ?? (element.hasAttribute("data-placeholder") ? "" : element.textContent ?? "")
1615
+ },
1616
+ color: {
1617
+ default: "neutral",
1618
+ parseHTML: (element) => normalizeLoomaChipColor(element.getAttribute("data-color"))
1619
+ }
1620
+ };
1621
+ },
1622
+ parseHTML() {
1623
+ return [{ tag: "span[data-looma-chip]" }];
1624
+ },
1625
+ renderHTML({ node }) {
1626
+ const label = String(node.attrs.label ?? "");
1627
+ return [
1628
+ "span",
1629
+ {
1630
+ "data-looma-chip": "",
1631
+ "data-label": label,
1632
+ "data-color": normalizeLoomaChipColor(node.attrs.color),
1633
+ ...label ? {} : { "data-placeholder": "" }
1634
+ },
1635
+ label || CHIP_PLACEHOLDER
1636
+ ];
1637
+ },
1638
+ renderText({ node }) {
1639
+ return String(node.attrs.label ?? "");
1640
+ },
1641
+ addNodeView() {
1642
+ return ({ node, editor }) => {
1643
+ const dom = document.createElement("span");
1644
+ dom.dataset.loomaChip = "";
1645
+ dom.contentEditable = "false";
1646
+ dom.setAttribute("role", "button");
1647
+ dom.setAttribute("aria-disabled", String(!editor.isEditable));
1648
+ dom.tabIndex = 0;
1649
+ const update = (next) => {
1650
+ if (next.type !== node.type) return false;
1651
+ const label = String(next.attrs.label ?? "");
1652
+ if (dom.dataset.label !== label) {
1653
+ dom.dataset.label = label;
1654
+ dom.textContent = label || CHIP_PLACEHOLDER;
1655
+ dom.setAttribute("aria-label", label ? `Edit chip: ${label}` : "Set chip label");
1656
+ if (label) delete dom.dataset.placeholder;
1657
+ else dom.dataset.placeholder = "";
1658
+ }
1659
+ const color = normalizeLoomaChipColor(next.attrs.color);
1660
+ if (dom.dataset.color !== color) dom.dataset.color = color;
1661
+ return true;
1662
+ };
1663
+ update(node);
1664
+ return {
1665
+ dom,
1666
+ update,
1667
+ stopEvent: (event) => event.type === "click" || event.type === "keydown"
1668
+ };
1669
+ };
1670
+ },
1671
+ addCommands() {
1672
+ return { insertLoomaChip: (attributes = {}) => ({ commands, state }) => {
1673
+ const selection = state.selection;
1674
+ const label = attributes.label ?? state.doc.textBetween(selection.from, selection.to);
1675
+ return commands.insertContent({
1676
+ type: this.name,
1677
+ attrs: {
1678
+ label,
1679
+ color: normalizeLoomaChipColor(attributes.color)
1680
+ }
1681
+ });
1682
+ } };
1683
+ }
1684
+ });
1685
+ //#endregion
1686
+ //#region src/editor/chip-editing.ts
1687
+ var LOOMA_CHIP_EDITING_IDLE = Object.freeze({
1688
+ open: false,
1689
+ label: "",
1690
+ color: "neutral"
1691
+ });
1692
+ function createChipEditing(options) {
1693
+ const { editor } = options;
1694
+ const listeners = /* @__PURE__ */ new Set();
1695
+ let state = LOOMA_CHIP_EDITING_IDLE;
1696
+ let element = null;
1697
+ const set = (next) => {
1698
+ state = next;
1699
+ listeners.forEach((listener) => listener());
1700
+ };
1701
+ const close = () => {
1702
+ element = null;
1703
+ if (state.open) set({
1704
+ ...state,
1705
+ open: false
1706
+ });
1707
+ };
1708
+ const change = (changes) => {
1709
+ if (!element || editor.isDestroyed || !element.isConnected) return close();
1710
+ const position = editor.view.posAtDOM(element, 0);
1711
+ const node = editor.state.doc.nodeAt(position);
1712
+ if (node?.type.name !== "loomaChip") return close();
1713
+ set({
1714
+ ...state,
1715
+ ...changes
1716
+ });
1717
+ editor.view.dispatch(editor.state.tr.setNodeMarkup(position, void 0, {
1718
+ ...node.attrs,
1719
+ ...changes
1720
+ }));
1721
+ };
1722
+ return {
1723
+ get state() {
1724
+ return state;
1725
+ },
1726
+ get element() {
1727
+ return element;
1728
+ },
1729
+ subscribe(listener) {
1730
+ listeners.add(listener);
1731
+ return () => {
1732
+ listeners.delete(listener);
1733
+ };
1734
+ },
1735
+ open(position) {
1736
+ if (!options.isEditable() || editor.isDestroyed) return false;
1737
+ const node = editor.state.doc.nodeAt(position);
1738
+ const dom = editor.view.nodeDOM(position);
1739
+ if (node?.type.name !== "loomaChip" || !(dom instanceof HTMLElement)) return false;
1740
+ element = dom;
1741
+ set({
1742
+ open: true,
1743
+ label: String(node.attrs.label ?? ""),
1744
+ color: normalizeLoomaChipColor(node.attrs.color)
1745
+ });
1746
+ return true;
1747
+ },
1748
+ setLabel: (label) => change({ label }),
1749
+ setColor: (color) => change({ color }),
1750
+ close
1751
+ };
1752
+ }
1753
+ //#endregion
1754
+ //#region src/editor/host-actions.ts
1755
+ /** Characters of context on each side of a selected passage. */
1756
+ var CONTEXT = 48;
1757
+ /**
1758
+ * The range actions whose range holds the collapsed text caret.
1759
+ * @contract Ranges that are not whole numbers, are empty, or fall outside the document are ignored.
1760
+ */
1761
+ function rangeActionsAtCaret(state, actions) {
1762
+ const selection = state.selection;
1763
+ if (!(selection instanceof TextSelection) || !selection.empty) return [];
1764
+ const size = state.doc.content.size;
1765
+ return actions.filter(({ from, to }) => Number.isSafeInteger(from) && Number.isSafeInteger(to) && from >= 0 && to > from && to <= size && from <= selection.from && selection.from <= to);
1766
+ }
1767
+ /** One control per action ID: its label and each distinct range it covers at the caret, in order. */
1768
+ function groupRangeActions(actions) {
1769
+ const groups = /* @__PURE__ */ new Map();
1770
+ for (const { id, label, from, to } of actions) {
1771
+ const group = groups.get(id) ?? {
1772
+ label,
1773
+ ranges: /* @__PURE__ */ new Map()
1774
+ };
1775
+ group.ranges.set(`${from}:${to}`, {
1776
+ from,
1777
+ to
1778
+ });
1779
+ groups.set(id, group);
1780
+ }
1781
+ return [...groups].map(([id, { label, ranges }]) => ({
1782
+ id,
1783
+ label,
1784
+ detail: {
1785
+ id,
1786
+ ranges: [...ranges.values()]
1787
+ }
1788
+ }));
1789
+ }
1790
+ /** Whether the selection is a passage that selection actions apply to: nonempty text, or everything. */
1791
+ function selectsPassage(state) {
1792
+ const selection = state.selection;
1793
+ return !selection.empty && (selection instanceof TextSelection || selection instanceof AllSelection);
1794
+ }
1795
+ /**
1796
+ * A selection action's detail for the current passage: its range, its text, and up to 48 characters
1797
+ * of context on each side, captured before focus leaves the editor.
1798
+ * @failure Returns null when the selection is empty.
1799
+ */
1800
+ function selectionActionDetail(state, id) {
1801
+ const { from, to } = state.selection;
1802
+ if (from === to) return null;
1803
+ const doc = state.doc;
1804
+ return {
1805
+ id,
1806
+ from,
1807
+ to,
1808
+ text: doc.textBetween(from, to, "\n"),
1809
+ before: doc.textBetween(Math.max(0, from - CONTEXT), from, "\n"),
1810
+ after: doc.textBetween(to, Math.min(doc.content.size, to + CONTEXT), "\n")
1811
+ };
1812
+ }
1813
+ //#endregion
1539
1814
  //#region src/editor/extensions/relative-links.ts
1540
1815
  /** Keep other origins and non-web schemes intact; same-origin links retain path, query, and fragment. */
1541
1816
  function siteRelativeHref(href, baseUrl) {
@@ -2056,34 +2331,6 @@ function formatEditorShortcut(keys, apple) {
2056
2331
  ].filter(Boolean).join("+");
2057
2332
  }
2058
2333
  var isApplePlatform = () => typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent);
2059
- /**
2060
- * The anchor a heading's text derives: NFC, lowercase, keeping letters, numbers and combining
2061
- * marks, with every other run of characters one hyphen.
2062
- * @failure Text with no letters or numbers, such as only emoji, derives `heading`.
2063
- */
2064
- function loomaHeadingSlug(text) {
2065
- return text.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}\p{M}]+/gu, "-").replace(/^-+|-+$/g, "") || "heading";
2066
- }
2067
- /**
2068
- * Gives every heading of a document its anchor, in document order.
2069
- * @contract A custom ID is kept unless an earlier heading already has it. Any other heading takes
2070
- * its slug, or the slug's first free suffix (`-1`, `-2`…), never one a later custom ID reserves.
2071
- */
2072
- function assignLoomaHeadingIds(headings) {
2073
- const reserved = new Set(headings.map(({ id }) => id).filter((id) => Boolean(id)));
2074
- const used = /* @__PURE__ */ new Set();
2075
- return headings.map(({ text, id: custom }) => {
2076
- let id = custom || loomaHeadingSlug(text);
2077
- if (custom ? used.has(id) : used.has(id) || reserved.has(id)) {
2078
- const stem = id;
2079
- let suffix = 1;
2080
- while (used.has(`${stem}-${suffix}`) || reserved.has(`${stem}-${suffix}`)) suffix++;
2081
- id = `${stem}-${suffix}`;
2082
- }
2083
- used.add(id);
2084
- return id;
2085
- });
2086
- }
2087
2334
  //#endregion
2088
2335
  //#region src/editor/extensions/expand.ts
2089
2336
  /**
@@ -3215,9 +3462,8 @@ var LoomaEditor = defineComponent({
3215
3462
  chipEditorRoot.value = element instanceof HTMLElement ? element : null;
3216
3463
  };
3217
3464
  const chipInput = () => chipEditorRoot.value?.querySelector("[data-chip-label]");
3218
- const chipOpen = ref(false);
3219
- const chipLabel = ref("");
3220
- const chipColor = ref("neutral");
3465
+ let chipEditing;
3466
+ const chipState = shallowRef(LOOMA_CHIP_EDITING_IDLE);
3221
3467
  const toolbarScope = `looma-editor-tool-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
3222
3468
  const tooltipFor = ref("");
3223
3469
  const tooltipLabel = ref("");
@@ -3270,11 +3516,7 @@ var LoomaEditor = defineComponent({
3270
3516
  let imagePopup = null;
3271
3517
  const floatingFormattingMode = () => props.toolbarMode !== "sticky" && !(props.toolbarMode === "popover" && props.toolbarOpen) && !mobile.value;
3272
3518
  const formattingPickerOpen = () => linkState.value.surface === "popover" || tablePickerOpen.value || formatOpen.value;
3273
- const activeRangeActions = (instance) => {
3274
- const selection = instance.state.selection;
3275
- if (!(selection instanceof TextSelection) || !selection.empty) return [];
3276
- return props.rangeActions.filter((action) => Number.isSafeInteger(action.from) && Number.isSafeInteger(action.to) && action.from >= 0 && action.to > action.from && action.to <= instance.state.doc.content.size && action.from <= selection.from && selection.from <= action.to);
3277
- };
3519
+ const activeRangeActions = (instance) => rangeActionsAtCaret(instance.state, props.rangeActions);
3278
3520
  const formattingActive = () => formatting?.active() ?? false;
3279
3521
  const editorStateVersion = ref(0);
3280
3522
  const mobileToolbarMode = ref("formatting");
@@ -3320,39 +3562,16 @@ var LoomaEditor = defineComponent({
3320
3562
  menuStyle: {},
3321
3563
  capabilities: { ...EMPTY_CAPABILITIES }
3322
3564
  });
3323
- const openChipEditor = (instance, position) => {
3324
- if (!props.editable) return;
3325
- const node = instance.state.doc.nodeAt(position);
3326
- const element = instance.view.nodeDOM(position);
3327
- if (node?.type.name !== "loomaChip" || !(element instanceof HTMLElement)) return;
3565
+ const openChipEditor = (_instance, position) => {
3566
+ if (!chipEditing?.open(position)) return;
3328
3567
  root.value?.querySelector(`#${chipAnchorId}`)?.removeAttribute("id");
3329
- element.id = chipAnchorId;
3330
- chipLabel.value = String(node.attrs.label ?? "");
3331
- chipColor.value = normalizeLoomaChipColor(node.attrs.color);
3332
- chipOpen.value = true;
3568
+ chipEditing.element.id = chipAnchorId;
3333
3569
  nextTick(() => chipInput()?.focus());
3334
3570
  };
3335
- const changeChip = (changes) => {
3336
- const instance = editor.value;
3337
- const element = root.value?.querySelector(`#${chipAnchorId}`);
3338
- if (!instance || !element) {
3339
- chipOpen.value = false;
3340
- return;
3341
- }
3342
- const position = instance.view.posAtDOM(element, 0);
3343
- const node = instance.state.doc.nodeAt(position);
3344
- if (node?.type.name !== "loomaChip") {
3345
- chipOpen.value = false;
3346
- return;
3347
- }
3348
- instance.view.dispatch(instance.state.tr.setNodeMarkup(position, void 0, {
3349
- ...node.attrs,
3350
- ...changes
3351
- }));
3352
- };
3353
3571
  const closeChip = (focusChip = false) => {
3354
- chipOpen.value = false;
3355
- if (focusChip) root.value?.querySelector(`#${chipAnchorId}`)?.focus();
3572
+ const element = chipEditing?.element;
3573
+ chipEditing?.close();
3574
+ if (focusChip) element?.focus();
3356
3575
  };
3357
3576
  const found = reactive(/* @__PURE__ */ new Set());
3358
3577
  const answers = (name) => {
@@ -3565,6 +3784,14 @@ var LoomaEditor = defineComponent({
3565
3784
  linkState.value = engine.state;
3566
3785
  });
3567
3786
  linkEditing = engine;
3787
+ const chips = createChipEditing({
3788
+ editor: session.editor,
3789
+ isEditable: () => props.editable
3790
+ });
3791
+ chips.subscribe(() => {
3792
+ chipState.value = chips.state;
3793
+ });
3794
+ chipEditing = chips;
3568
3795
  const imageUploads = createImageUploads({
3569
3796
  editor: session.editor,
3570
3797
  upload: () => async (file, init) => {
@@ -3597,6 +3824,7 @@ var LoomaEditor = defineComponent({
3597
3824
  const previous = session;
3598
3825
  const content = edited ? previous.editor.getJSON() : props.modelValue;
3599
3826
  linkEditing?.destroy();
3827
+ chipEditing?.close();
3600
3828
  formatting?.destroy();
3601
3829
  uploads?.cancel();
3602
3830
  previous.destroy();
@@ -3721,7 +3949,7 @@ var LoomaEditor = defineComponent({
3721
3949
  root.value?.querySelectorAll("[data-looma-chip]").forEach((chip) => {
3722
3950
  chip.setAttribute("aria-disabled", String(!editable));
3723
3951
  });
3724
- if (!editable) chipOpen.value = false;
3952
+ if (!editable) chipEditing?.close();
3725
3953
  });
3726
3954
  watch([
3727
3955
  () => linkState.value.surface === "popover",
@@ -4342,40 +4570,22 @@ var LoomaEditor = defineComponent({
4342
4570
  onClick: run
4343
4571
  }, () => loomaIcon(icon));
4344
4572
  };
4345
- const renderRangeActions = (instance) => {
4346
- const groups = /* @__PURE__ */ new Map();
4347
- for (const action of activeRangeActions(instance)) {
4348
- const group = groups.get(action.id) ?? {
4349
- label: action.label,
4350
- ranges: /* @__PURE__ */ new Map()
4351
- };
4352
- group.ranges.set(`${action.from}:${action.to}`, {
4353
- from: action.from,
4354
- to: action.to
4355
- });
4356
- groups.set(action.id, group);
4573
+ const renderRangeActions = (instance) => groupRangeActions(activeRangeActions(instance)).map(({ id, label, detail }) => h(Button, {
4574
+ key: id,
4575
+ "aria-label": label,
4576
+ type: "button",
4577
+ variant: "ghost",
4578
+ tone: "neutral",
4579
+ size: "sm",
4580
+ onPointerdown: (event) => event.preventDefault(),
4581
+ onClick: () => {
4582
+ emit("rangeAction", detail);
4583
+ formattingPopup?.hide();
4357
4584
  }
4358
- return [...groups].map(([id, group]) => h(Button, {
4359
- key: id,
4360
- "aria-label": group.label,
4361
- type: "button",
4362
- variant: "ghost",
4363
- tone: "neutral",
4364
- size: "sm",
4365
- onPointerdown: (event) => event.preventDefault(),
4366
- onClick: () => {
4367
- emit("rangeAction", {
4368
- id,
4369
- ranges: [...group.ranges.values()]
4370
- });
4371
- formattingPopup?.hide();
4372
- }
4373
- }, () => group.label));
4374
- };
4585
+ }, () => label));
4375
4586
  const renderSelectionActions = (instance) => {
4376
- const selection = instance.state.selection;
4377
- if (selection.empty) return renderRangeActions(instance);
4378
- if (!(selection instanceof TextSelection || selection instanceof AllSelection)) return [];
4587
+ if (instance.state.selection.empty) return renderRangeActions(instance);
4588
+ if (!selectsPassage(instance.state)) return [];
4379
4589
  return props.selectionActions.map((action) => h(Button, {
4380
4590
  key: action.id,
4381
4591
  "aria-label": action.label,
@@ -4385,17 +4595,9 @@ var LoomaEditor = defineComponent({
4385
4595
  size: "sm",
4386
4596
  onPointerdown: (event) => event.preventDefault(),
4387
4597
  onClick: () => {
4388
- const { from, to } = instance.state.selection;
4389
- if (from === to) return;
4390
- const doc = instance.state.doc;
4391
- emit("selectionAction", {
4392
- id: action.id,
4393
- from,
4394
- to,
4395
- text: doc.textBetween(from, to, "\n"),
4396
- before: doc.textBetween(Math.max(0, from - 48), from, "\n"),
4397
- after: doc.textBetween(to, Math.min(doc.content.size, to + 48), "\n")
4398
- });
4598
+ const detail = selectionActionDetail(instance.state, action.id);
4599
+ if (!detail) return;
4600
+ emit("selectionAction", detail);
4399
4601
  formattingPopup?.hide();
4400
4602
  }
4401
4603
  }, () => action.label));
@@ -4691,24 +4893,16 @@ var LoomaEditor = defineComponent({
4691
4893
  }, [loomaIcon("image"), h("span", "Drop image to upload")]) : null,
4692
4894
  h(Popover, {
4693
4895
  class: "looma-editor__chip-popover",
4694
- open: chipOpen.value,
4896
+ open: chipState.value.open,
4695
4897
  for: chipAnchorId,
4696
4898
  placement: "bottom-start",
4697
- onClose: () => {
4698
- chipOpen.value = false;
4699
- }
4899
+ onClose: () => chipEditing?.close()
4700
4900
  }, () => h(EditorChipEditor, {
4701
4901
  ref: setChipEditorRoot,
4702
- label: chipLabel.value,
4703
- color: chipColor.value,
4704
- onLabel: (event) => {
4705
- chipLabel.value = event.detail.label;
4706
- changeChip({ label: chipLabel.value });
4707
- },
4708
- onColor: (event) => {
4709
- chipColor.value = event.detail.color;
4710
- changeChip({ color: event.detail.color });
4711
- },
4902
+ label: chipState.value.label,
4903
+ color: chipState.value.color,
4904
+ onLabel: (event) => chipEditing?.setLabel(event.detail.label),
4905
+ onColor: (event) => chipEditing?.setColor(event.detail.color),
4712
4906
  onDone: () => closeChip(true)
4713
4907
  })),
4714
4908
  h(Popover, {