@threadlabs/looma 0.28.9 → 0.28.11

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,9 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ /**
3
+ * Applies a block menu action to the top-level block holding the caret, then focuses the editor:
4
+ * insert a paragraph below, duplicate the block, or delete it. Deleting the only block leaves an
5
+ * empty paragraph.
6
+ *
7
+ * @failure Returns false, and changes nothing, for an unknown action or a caret outside any block.
8
+ */
9
+ export declare function runBlockAction(editor: Editor, action: string): boolean;
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { Editor, Range } from "@tiptap/core";
6
6
  import type { TableContextMenuActionEventDetail } from "../table-context-menu.js";
7
- import type { TableOverlayActionEventDetail } from "../table-overlay.js";
7
+ import { type TableOverlayActionEventDetail } from "../table-overlay.js";
8
8
  import { type TableCellAlignment, type TableCellBackground } from "./table-formatting.js";
9
9
  /**
10
10
  * Bounded initial shape for table insertion.
@@ -120,3 +120,9 @@ export declare function normalizeActiveTableColumnWidths(editor: Editor, tableEl
120
120
  * return false before dispatching a transaction.
121
121
  */
122
122
  export declare function handleTableOverlayAction(editor: Editor, detail: TableOverlayActionEventDetail): boolean;
123
+ /**
124
+ * Moves the caret to the cell an overlay action names when the action comes from a table other
125
+ * than the selected one, so table commands act on that table. A cell menu also selects its cell,
126
+ * unless that cell is already part of the selection.
127
+ */
128
+ export declare function focusTableOverlayTarget(editor: Editor, table: HTMLTableElement, detail: TableOverlayActionEventDetail): void;
@@ -0,0 +1,85 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ /** A destination supplied by the host application, such as a page or record. */
3
+ export interface LoomaLinkTarget {
4
+ id: string;
5
+ label: string;
6
+ detail?: string;
7
+ href: string;
8
+ }
9
+ /**
10
+ * Finds host records for a link without teaching the editor how they are stored.
11
+ * @ownership The host checks access and returns durable, openable href values.
12
+ * @failure A rejection leaves the existing document untouched and shows a retryable search error.
13
+ */
14
+ export type LoomaLinkSearch = (query: string) => Promise<LoomaLinkTarget[]>;
15
+ /**
16
+ * Describes a saved host link so a renamed record can show its current label.
17
+ * @ownership The host resolves identity and access; Looma keeps the saved href as is.
18
+ * @failure A rejection leaves the original href visible and editable.
19
+ */
20
+ export type LoomaLinkResolve = (href: string) => Promise<LoomaLinkTarget | null>;
21
+ /** The link form's state and the resolved target of the link at the caret. */
22
+ export interface LoomaLinkEditingState {
23
+ /** Where the form shows: nowhere, in the toolbar's popover, or in place of the link card. */
24
+ readonly surface: "none" | "popover" | "context";
25
+ /** The range the form links, and whether it was already a link when the form opened. */
26
+ readonly range: {
27
+ readonly from: number;
28
+ readonly to: number;
29
+ readonly existing: boolean;
30
+ } | null;
31
+ readonly mode: "target" | "url";
32
+ readonly query: string;
33
+ readonly href: string;
34
+ readonly text: string;
35
+ readonly textEditable: boolean;
36
+ readonly newTab: boolean;
37
+ readonly error: string;
38
+ readonly results: readonly LoomaLinkTarget[];
39
+ readonly searching: boolean;
40
+ readonly selected: LoomaLinkTarget | null;
41
+ /** What the link at the caret resolves to, for the link card. */
42
+ readonly current: LoomaLinkTarget | null;
43
+ }
44
+ export interface LoomaLinkEditingOptions {
45
+ editor: Editor;
46
+ /** Read on each use, so a host can change them while the editor runs. */
47
+ search: () => LoomaLinkSearch | null | undefined;
48
+ resolve: () => LoomaLinkResolve | null | undefined;
49
+ baseUrl: () => string;
50
+ }
51
+ /**
52
+ * The editor's link form as a state machine: what the form holds, how it applies to the
53
+ * document as the reader types, and the host's search and resolve calls.
54
+ *
55
+ * @ownership The host owns presentation: where the form shows, focus, and which selection it
56
+ * opens on. This owns the form's state and the link mark it writes.
57
+ * @lifecycle Listens to the editor's transactions until `destroy()`; results of a search or lookup
58
+ * that finish after a newer one, or after `destroy()`, are dropped.
59
+ */
60
+ export interface LoomaLinkEditing {
61
+ readonly state: LoomaLinkEditingState;
62
+ /** Calls `listener` after every state change; returns the unsubscribe function. */
63
+ subscribe(listener: () => void): () => void;
64
+ /** Opens the form on `from`–`to`, filled from the link there if there is one. */
65
+ open(from: number, to: number, surface: "popover" | "context"): void;
66
+ setDestination(value: string): void;
67
+ clear(): void;
68
+ choose(target: LoomaLinkTarget): void;
69
+ setNewTab(newTab: boolean): void;
70
+ setText(text: string): void;
71
+ /**
72
+ * Writes the form's link to its range without moving focus.
73
+ * @failure Returns false and leaves the document unchanged when the destination or text is
74
+ * missing or invalid; `reportError` also sets the form's error.
75
+ */
76
+ apply(reportError?: boolean): boolean;
77
+ close(): void;
78
+ /** Removes the link at the caret. */
79
+ remove(): void;
80
+ destroy(): void;
81
+ }
82
+ /** The status line above search results: progress, a hint, or an empty result. */
83
+ export declare function linkFormStatus(state: LoomaLinkEditingState): string;
84
+ export declare const LOOMA_LINK_EDITING_IDLE: LoomaLinkEditingState;
85
+ export declare function createLinkEditing(options: LoomaLinkEditingOptions): LoomaLinkEditing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.28.9",
3
+ "version": "0.28.11",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -4,6 +4,7 @@ import { type AnyExtension, type Editor, type JSONContent } from "@tiptap/core";
4
4
  import { type PropType, type VNode } from "vue";
5
5
  import { type LoomaSlashCommand, type LoomaMentionItem, type LoomaMentionProvider, type LoomaCodeLanguages } from "@threadlabs/looma/editor";
6
6
  import { type LoomaImageActivationDetail, type LoomaImageAttributeResolver, type LoomaImageDescriptor, type LoomaImageRenditionErrorDetail } from "../../editor/image-delivery.js";
7
+ import { type LoomaLinkResolve, type LoomaLinkSearch } from "../../editor/link-editing.js";
7
8
  /**
8
9
  * Host upload result normalized into the editor's durable image descriptor.
9
10
  * `url` becomes the stored `src`; optional dimensions preserve layout before
@@ -62,25 +63,7 @@ export interface LoomaEditorSelectionActionDetail {
62
63
  * fresh defaults and invokes this factory when the inventory prop changes.
63
64
  */
64
65
  export type LoomaEditorSlashCommands = LoomaSlashCommand[] | ((defaults: LoomaSlashCommand[]) => LoomaSlashCommand[]);
65
- /** A destination supplied by the host application, such as a page or record. */
66
- export interface LoomaLinkTarget {
67
- id: string;
68
- label: string;
69
- detail?: string;
70
- href: string;
71
- }
72
- /**
73
- * Finds host records for a link without teaching the editor how they are stored.
74
- * @ownership The host checks access and returns durable, openable href values.
75
- * @failure A rejection leaves the existing document untouched and shows a retryable search error.
76
- */
77
- export type LoomaLinkSearch = (query: string) => Promise<LoomaLinkTarget[]>;
78
- /**
79
- * Describes a saved host link so a renamed record can show its current label.
80
- * @ownership The host resolves identity and access; Looma keeps the saved href as is.
81
- * @failure A rejection leaves the original href visible and editable.
82
- */
83
- export type LoomaLinkResolve = (href: string) => Promise<LoomaLinkTarget | null>;
66
+ export type { LoomaLinkResolve, LoomaLinkSearch, LoomaLinkTarget } from "../../editor/link-editing.js";
84
67
  /**
85
68
  * Turnkey Vue editor that composes Looma's framework-neutral Tiptap extensions
86
69
  * with declarative menus, image delivery, and table interaction chrome.
@@ -1,5 +1,5 @@
1
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, siteRelativeHref } from "@threadlabs/looma/editor";
2
+ import { DEFAULT_MENTION_RESULT_LIMIT, LOOMA_CHIP_COLORS, LOOMA_ICONS, createLoomaMentionExtension, createLoomaSlashCommandExtension, getActiveTableUiState, getDefaultEditorExtensions, getDefaultSlashCommands, getTableOfContentsEntries, getVisualViewportRect, handleTableAction, handleTableOverlayAction, measureTableOverlayGeometry, normalizeActiveTableColumnWidths, normalizeLoomaChipColor, normalizeTableOfContentsConfig } from "@threadlabs/looma/editor";
3
3
  import { Button, Callout, Card, Checkbox, Cluster, Disclosure, EditorBlockActions, 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, 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";
@@ -7,6 +7,8 @@ import { closeHistory } from "@tiptap/pm/history";
7
7
  import { AllSelection, NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
8
8
  import { Decoration, DecorationSet } from "@tiptap/pm/view";
9
9
  import { createLowlight } from "lowlight";
10
+ import "@tiptap/pm/tables";
11
+ import "@tiptap/pm/transform";
10
12
  import { DOMParser, DOMSerializer, Fragment as Fragment$1 } from "@tiptap/pm/model";
11
13
  export * from "@threadlabs/looma/editor";
12
14
  //#region src/vue/editor/primitives.ts
@@ -1093,32 +1095,79 @@ function createCodeHighlighting(codeLanguages, onGrammarsLoaded) {
1093
1095
  };
1094
1096
  }
1095
1097
  //#endregion
1096
- //#region src/editor/content.ts
1097
- /** Whether a controlled document already matches the editor, so applying it would only reset the selection. */
1098
- function sameDocument(left, right) {
1099
- return JSON.stringify(left) === JSON.stringify(right);
1098
+ //#region src/editor/block-actions.ts
1099
+ /**
1100
+ * Applies a block menu action to the top-level block holding the caret, then focuses the editor:
1101
+ * insert a paragraph below, duplicate the block, or delete it. Deleting the only block leaves an
1102
+ * empty paragraph.
1103
+ *
1104
+ * @failure Returns false, and changes nothing, for an unknown action or a caret outside any block.
1105
+ */
1106
+ function runBlockAction(editor, action) {
1107
+ const head = editor.state.selection.$head;
1108
+ if (head.depth === 0) return false;
1109
+ const { state } = editor;
1110
+ const position = head.before(1);
1111
+ const block = state.doc.nodeAt(position);
1112
+ if (!block) return false;
1113
+ const end = position + block.nodeSize;
1114
+ const transaction = state.tr;
1115
+ let focusAt = end + 1;
1116
+ if (action === "insert-below") transaction.insert(end, state.schema.nodes.paragraph.create());
1117
+ else if (action === "duplicate") transaction.insert(end, block);
1118
+ else if (action === "delete") {
1119
+ if (state.doc.childCount === 1) {
1120
+ transaction.replaceWith(position, end, state.schema.nodes.paragraph.create());
1121
+ focusAt = 1;
1122
+ } else {
1123
+ transaction.delete(position, end);
1124
+ focusAt = Math.min(position + 1, transaction.doc.content.size);
1125
+ }
1126
+ } else return false;
1127
+ transaction.setSelection(TextSelection.near(transaction.doc.resolve(Math.min(focusAt, transaction.doc.content.size))));
1128
+ editor.view.dispatch(transaction);
1129
+ editor.commands.focus();
1130
+ return true;
1131
+ }
1132
+ //#endregion
1133
+ //#region src/editor/table-overlay.ts
1134
+ function mapTableCells(table) {
1135
+ const rows = Array.from(table.rows);
1136
+ const grid = [];
1137
+ const coordinates = /* @__PURE__ */ new Map();
1138
+ rows.forEach((row, rowIndex) => {
1139
+ grid[rowIndex] ??= [];
1140
+ let columnIndex = 0;
1141
+ for (const cell of Array.from(row.cells)) {
1142
+ while (grid[rowIndex]?.[columnIndex]) columnIndex += 1;
1143
+ coordinates.set(cell, {
1144
+ rowIndex,
1145
+ columnIndex
1146
+ });
1147
+ const rowSpan = cell.rowSpan === 0 ? rows.length - rowIndex : Math.max(1, cell.rowSpan);
1148
+ const columnSpan = Math.max(1, cell.colSpan);
1149
+ for (let rowOffset = 0; rowOffset < rowSpan; rowOffset += 1) {
1150
+ const gridRow = grid[rowIndex + rowOffset] ??= [];
1151
+ for (let columnOffset = 0; columnOffset < columnSpan; columnOffset += 1) gridRow[columnIndex + columnOffset] = cell;
1152
+ }
1153
+ columnIndex += columnSpan;
1154
+ }
1155
+ });
1156
+ return {
1157
+ grid,
1158
+ coordinates
1159
+ };
1100
1160
  }
1101
1161
  /**
1102
- * A link destination the editor may store: a site-relative path, fragment, or query, or an
1103
- * absolute http, https, mailto, or tel URL.
1162
+ * Resolves a logical grid coordinate to its owning DOM cell.
1163
+ * A spanning cell can therefore be returned for more than one coordinate; this
1164
+ * is intentional and prevents overlay actions from inventing nonexistent cells.
1104
1165
  *
1105
- * @failure Returns null for empty input, control characters, other schemes, and unparseable URLs.
1166
+ * @contract Coordinates outside the expanded table grid return null; coordinates
1167
+ * covered by a span resolve to the single DOM cell that owns them.
1106
1168
  */
1107
- function validLinkHref(value) {
1108
- const href = value.trim();
1109
- if (!href || /[\u0000-\u001f\u007f]/.test(href)) return null;
1110
- if (/^(\/|#|\?)/.test(href)) return href;
1111
- try {
1112
- const url = new URL(href);
1113
- return [
1114
- "http:",
1115
- "https:",
1116
- "mailto:",
1117
- "tel:"
1118
- ].includes(url.protocol) ? href : null;
1119
- } catch {
1120
- return null;
1121
- }
1169
+ function resolveTableCellAt(table, rowIndex, columnIndex) {
1170
+ return mapTableCells(table).grid[rowIndex]?.[columnIndex] ?? null;
1122
1171
  }
1123
1172
  //#endregion
1124
1173
  //#region src/editor/selection-geometry.ts
@@ -1149,6 +1198,317 @@ function selectedTableCellElement(editor) {
1149
1198
  return selectionElement(editor)?.closest("td, th");
1150
1199
  }
1151
1200
  //#endregion
1201
+ //#region src/editor/extensions/table-commands.ts
1202
+ /**
1203
+ * Moves the caret to the cell an overlay action names when the action comes from a table other
1204
+ * than the selected one, so table commands act on that table. A cell menu also selects its cell,
1205
+ * unless that cell is already part of the selection.
1206
+ */
1207
+ function focusTableOverlayTarget(editor, table, detail) {
1208
+ const target = "rowIndex" in detail ? resolveTableCellAt(table, detail.rowIndex, detail.columnIndex) : "fromIndex" in detail ? resolveTableCellAt(table, detail.action === "reorder-row" ? detail.fromIndex : 0, detail.action === "reorder-column" ? detail.fromIndex : 0) : resolveTableCellAt(table, detail.action.startsWith("add-row") ? Math.max(0, detail.boundaryIndex - 1) : 0, detail.action.startsWith("add-column") ? Math.max(0, detail.boundaryIndex - 1) : 0);
1209
+ if (target && selectedTableElement(editor) !== table) editor.chain().focus().setTextSelection(editor.view.posAtDOM(target, 0) + 1).run();
1210
+ if (detail.action !== "open-cell-menu") return;
1211
+ const selected = selectedTableCellElement(editor);
1212
+ const cell = resolveTableCellAt(table, detail.rowIndex, detail.columnIndex) ?? selected;
1213
+ if (cell && selected !== cell && !cell.classList.contains("selectedCell")) editor.chain().focus().setTextSelection(editor.view.posAtDOM(cell, 0) + 1).run();
1214
+ else editor.commands.focus();
1215
+ }
1216
+ //#endregion
1217
+ //#region src/editor/content.ts
1218
+ /** Whether a controlled document already matches the editor, so applying it would only reset the selection. */
1219
+ function sameDocument(left, right) {
1220
+ return JSON.stringify(left) === JSON.stringify(right);
1221
+ }
1222
+ /**
1223
+ * A link destination the editor may store: a site-relative path, fragment, or query, or an
1224
+ * absolute http, https, mailto, or tel URL.
1225
+ *
1226
+ * @failure Returns null for empty input, control characters, other schemes, and unparseable URLs.
1227
+ */
1228
+ function validLinkHref(value) {
1229
+ const href = value.trim();
1230
+ if (!href || /[\u0000-\u001f\u007f]/.test(href)) return null;
1231
+ if (/^(\/|#|\?)/.test(href)) return href;
1232
+ try {
1233
+ const url = new URL(href);
1234
+ return [
1235
+ "http:",
1236
+ "https:",
1237
+ "mailto:",
1238
+ "tel:"
1239
+ ].includes(url.protocol) ? href : null;
1240
+ } catch {
1241
+ return null;
1242
+ }
1243
+ }
1244
+ //#endregion
1245
+ //#region src/editor/extensions/relative-links.ts
1246
+ /** Keep other origins and non-web schemes intact; same-origin links retain path, query, and fragment. */
1247
+ function siteRelativeHref(href, baseUrl) {
1248
+ if (!/^(https?:\/\/|\/\/)/i.test(href.trim())) return href;
1249
+ try {
1250
+ const base = new URL(baseUrl);
1251
+ const url = new URL(href, base);
1252
+ if (!/^https?:$/.test(base.protocol) || url.origin !== base.origin || url.username || url.password) return href;
1253
+ return `${url.pathname}${url.search}${url.hash}`;
1254
+ } catch {
1255
+ return href;
1256
+ }
1257
+ }
1258
+ //#endregion
1259
+ //#region src/editor/link-editing.ts
1260
+ /** The status line above search results: progress, a hint, or an empty result. */
1261
+ function linkFormStatus(state) {
1262
+ if (state.searching) return "Searching…";
1263
+ if (state.selected) return "";
1264
+ if (state.query.trim().length < 2) return "Type at least two letters to search.";
1265
+ return state.results.length || state.error ? "" : "No results found.";
1266
+ }
1267
+ var LOOMA_LINK_EDITING_IDLE = Object.freeze({
1268
+ surface: "none",
1269
+ range: null,
1270
+ mode: "url",
1271
+ query: "",
1272
+ href: "",
1273
+ text: "",
1274
+ textEditable: false,
1275
+ newTab: true,
1276
+ error: "",
1277
+ results: [],
1278
+ searching: false,
1279
+ selected: null,
1280
+ current: null
1281
+ });
1282
+ function createLinkEditing(options) {
1283
+ const { editor } = options;
1284
+ const listeners = /* @__PURE__ */ new Set();
1285
+ const cache = /* @__PURE__ */ new Map();
1286
+ let state = LOOMA_LINK_EDITING_IDLE;
1287
+ let searchSequence = 0;
1288
+ let resolveSequence = 0;
1289
+ let currentHref = null;
1290
+ let destroyed = false;
1291
+ const set = (patch) => {
1292
+ const previous = state;
1293
+ state = {
1294
+ ...state,
1295
+ ...patch
1296
+ };
1297
+ if (state.query !== previous.query || state.mode !== previous.mode || state.surface !== previous.surface) search();
1298
+ listeners.forEach((listener) => listener());
1299
+ };
1300
+ const search = async () => {
1301
+ const sequence = ++searchSequence;
1302
+ const { query, mode, surface, selected } = state;
1303
+ state = {
1304
+ ...state,
1305
+ results: [],
1306
+ searching: false
1307
+ };
1308
+ const find = options.search();
1309
+ if (!find || mode !== "target" || surface === "none" || query.trim().length < 2 || selected && query === selected.label) return;
1310
+ state = {
1311
+ ...state,
1312
+ searching: true
1313
+ };
1314
+ try {
1315
+ const results = await find(query.trim());
1316
+ if (sequence === searchSequence && !destroyed) set({ results: results.filter((target) => validLinkHref(target.href)) });
1317
+ } catch {
1318
+ if (sequence === searchSequence && !destroyed) set({ error: "Search is unavailable. Try again." });
1319
+ } finally {
1320
+ if (sequence === searchSequence && !destroyed) set({ searching: false });
1321
+ }
1322
+ };
1323
+ const resolveCurrent = async (href) => {
1324
+ const sequence = ++resolveSequence;
1325
+ if (state.current) set({ current: null });
1326
+ const resolve = options.resolve();
1327
+ if (!href || !resolve) return;
1328
+ if (cache.has(href)) {
1329
+ set({ current: cache.get(href) ?? null });
1330
+ return;
1331
+ }
1332
+ try {
1333
+ const target = await resolve(href);
1334
+ cache.set(href, target);
1335
+ if (sequence === resolveSequence && !destroyed) set({ current: target });
1336
+ } catch {}
1337
+ };
1338
+ const onTransaction = () => {
1339
+ const href = editor.isActive("link") ? String(editor.getAttributes("link").href ?? "") : null;
1340
+ if (href !== currentHref) {
1341
+ currentHref = href;
1342
+ resolveCurrent(href);
1343
+ }
1344
+ if (state.surface === "context" && editor.state.selection.from !== state.range?.from) set({ surface: "none" });
1345
+ };
1346
+ editor.on("transaction", onTransaction);
1347
+ const apply = (reportError = false) => {
1348
+ const range = state.range;
1349
+ if (editor.isDestroyed || !range) return false;
1350
+ const fail = (error) => {
1351
+ if (reportError) set({ error });
1352
+ return false;
1353
+ };
1354
+ const href = state.mode === "target" ? validLinkHref(state.selected?.href ?? "") : validLinkHref(state.href);
1355
+ if (!href) return fail(state.mode === "target" ? "Choose a destination from the results." : "Enter an http, https, mailto, tel, or relative URL.");
1356
+ if (state.mode === "url" && options.search() && !range.existing && !/^(https?:|mailto:|tel:)/i.test(href)) return fail("Enter a full website, email, or phone URL, or choose a destination.");
1357
+ const baseUrl = options.baseUrl();
1358
+ const attrs = {
1359
+ href: baseUrl ? siteRelativeHref(href, baseUrl) : href,
1360
+ target: state.newTab ? "_blank" : "_self",
1361
+ rel: state.newTab ? "noopener noreferrer" : null
1362
+ };
1363
+ const current = editor.state.selection;
1364
+ const chain = editor.chain().setTextSelection({
1365
+ from: range.from,
1366
+ to: range.to
1367
+ });
1368
+ if (state.textEditable) {
1369
+ const text = state.text.trim();
1370
+ if (!text) return fail("Enter link text.");
1371
+ chain.insertContent({
1372
+ type: "text",
1373
+ text,
1374
+ marks: [{
1375
+ type: "link",
1376
+ attrs
1377
+ }]
1378
+ }).setTextSelection(range.from).run();
1379
+ set({
1380
+ range: {
1381
+ from: range.from,
1382
+ to: range.from + text.length,
1383
+ existing: true
1384
+ },
1385
+ error: ""
1386
+ });
1387
+ } else {
1388
+ if (range.existing && range.from === range.to) chain.extendMarkRange("link");
1389
+ chain.setLink(attrs).setTextSelection({
1390
+ from: current.from,
1391
+ to: current.to
1392
+ }).run();
1393
+ set({
1394
+ range: {
1395
+ ...range,
1396
+ existing: true
1397
+ },
1398
+ error: ""
1399
+ });
1400
+ }
1401
+ return true;
1402
+ };
1403
+ return {
1404
+ get state() {
1405
+ return state;
1406
+ },
1407
+ subscribe(listener) {
1408
+ listeners.add(listener);
1409
+ return () => {
1410
+ listeners.delete(listener);
1411
+ };
1412
+ },
1413
+ open(from, to, surface) {
1414
+ const empty = from === to;
1415
+ const existing = editor.isActive("link");
1416
+ const attrs = existing ? editor.getAttributes("link") : {};
1417
+ const href = typeof attrs.href === "string" ? attrs.href : "";
1418
+ const selected = cache.get(href) ?? null;
1419
+ set({
1420
+ surface,
1421
+ range: {
1422
+ from,
1423
+ to,
1424
+ existing
1425
+ },
1426
+ textEditable: empty && !existing,
1427
+ href,
1428
+ newTab: attrs.target !== "_self",
1429
+ text: empty && !existing ? "" : editor.state.doc.textBetween(from, to),
1430
+ error: "",
1431
+ selected,
1432
+ mode: options.search() && (!existing || selected) ? "target" : "url",
1433
+ query: selected?.label ?? href
1434
+ });
1435
+ const resolve = options.resolve();
1436
+ if (existing && resolve && !cache.has(href)) resolve(href).then((target) => {
1437
+ cache.set(href, target);
1438
+ if (!destroyed && state.href === href && target) set({
1439
+ selected: target,
1440
+ mode: "target",
1441
+ query: target.label
1442
+ });
1443
+ }).catch(() => {});
1444
+ },
1445
+ setDestination(value) {
1446
+ if (options.search()) {
1447
+ const trimmed = value.trim();
1448
+ const url = /^(https?:\/\/|mailto:|tel:)/i.test(trimmed) || Boolean(state.range?.existing && /^(\/|\.\.?\/|#|\?)/.test(trimmed));
1449
+ set({
1450
+ query: value,
1451
+ mode: url ? "url" : "target",
1452
+ selected: null,
1453
+ href: value,
1454
+ error: ""
1455
+ });
1456
+ } else set({
1457
+ href: value,
1458
+ error: ""
1459
+ });
1460
+ apply();
1461
+ },
1462
+ clear() {
1463
+ set({
1464
+ query: "",
1465
+ href: "",
1466
+ mode: "target",
1467
+ selected: null,
1468
+ error: ""
1469
+ });
1470
+ },
1471
+ choose(target) {
1472
+ cache.set(target.href, target);
1473
+ set({
1474
+ selected: target,
1475
+ href: target.href,
1476
+ query: target.label,
1477
+ results: [],
1478
+ newTab: false,
1479
+ error: "",
1480
+ ...state.textEditable && !state.text.trim() ? { text: target.label } : {}
1481
+ });
1482
+ apply();
1483
+ },
1484
+ setNewTab(newTab) {
1485
+ set({ newTab });
1486
+ apply();
1487
+ },
1488
+ setText(text) {
1489
+ set({
1490
+ text,
1491
+ error: ""
1492
+ });
1493
+ apply();
1494
+ },
1495
+ apply,
1496
+ close() {
1497
+ if (state.surface !== "none") set({ surface: "none" });
1498
+ },
1499
+ remove() {
1500
+ if (!editor.isActive("link")) return;
1501
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
1502
+ if (state.surface === "context") set({ surface: "none" });
1503
+ },
1504
+ destroy() {
1505
+ destroyed = true;
1506
+ listeners.clear();
1507
+ editor.off("transaction", onTransaction);
1508
+ }
1509
+ };
1510
+ }
1511
+ //#endregion
1152
1512
  //#region src/editor/selection-surface.ts
1153
1513
  var surfaceSequence = 0;
1154
1514
  /**
@@ -2493,28 +2853,13 @@ var LoomaEditor = defineComponent({
2493
2853
  const element = instance?.$el;
2494
2854
  linkFormRoot.value = element instanceof HTMLElement ? element : null;
2495
2855
  };
2496
- const linkOpen = ref(false);
2497
- const linkContextEditing = ref(false);
2498
- const linkHref = ref("");
2499
- const linkText = ref("");
2500
- const linkTextEditable = ref(false);
2501
- const linkNewTab = ref(true);
2502
- const linkError = ref("");
2503
- const linkMode = ref("url");
2504
- const linkQuery = ref("");
2505
- const linkResults = ref([]);
2506
- const linkSearching = ref(false);
2507
- const linkSelectedTarget = ref(null);
2508
- const linkContextTarget = ref(null);
2509
- const linkTargetCache = /* @__PURE__ */ new Map();
2510
- let linkSearchSequence = 0;
2511
- let linkResolveSequence = 0;
2856
+ let linkEditing;
2857
+ const linkState = shallowRef(LOOMA_LINK_EDITING_IDLE);
2512
2858
  const chipAnchorId = `looma-editor-chip-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
2513
2859
  const chipInput = ref(null);
2514
2860
  const chipOpen = ref(false);
2515
2861
  const chipLabel = ref("");
2516
2862
  const chipColor = ref("neutral");
2517
- let linkSelection = null;
2518
2863
  const toolbarScope = `looma-editor-tool-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
2519
2864
  const tooltipFor = ref("");
2520
2865
  const tooltipLabel = ref("");
@@ -2565,7 +2910,7 @@ var LoomaEditor = defineComponent({
2565
2910
  let heldPressTimer;
2566
2911
  let heldPress = null;
2567
2912
  const floatingFormattingMode = () => props.toolbarMode !== "sticky" && !(props.toolbarMode === "popover" && props.toolbarOpen) && !mobile.value;
2568
- const formattingPickerOpen = () => linkOpen.value || tablePickerOpen.value || formatOpen.value;
2913
+ const formattingPickerOpen = () => linkState.value.surface === "popover" || tablePickerOpen.value || formatOpen.value;
2569
2914
  const cancelHeldPress = () => {
2570
2915
  clearTimeout(heldPressTimer);
2571
2916
  heldPressTimer = void 0;
@@ -2754,7 +3099,7 @@ var LoomaEditor = defineComponent({
2754
3099
  if (view.isDestroyed) return;
2755
3100
  editorStateVersion.value += 1;
2756
3101
  const instance = editor.value;
2757
- if (instance?.isFocused && instance.state.selection.empty && instance.isActive("link") && !linkOpen.value) {
3102
+ if (instance?.isFocused && instance.state.selection.empty && instance.isActive("link") && linkState.value.surface !== "popover") {
2758
3103
  linkPopup?.setAnchor(() => link.getBoundingClientRect());
2759
3104
  linkPopup?.show();
2760
3105
  }
@@ -2777,7 +3122,6 @@ var LoomaEditor = defineComponent({
2777
3122
  if (!heldFormattingSelection?.eq(selection)) heldFormattingSelection = null;
2778
3123
  if (!formattingSelectionActive(instance)) formattingPopup?.hide();
2779
3124
  }
2780
- if (linkContextEditing.value && instance.state.selection.from !== linkSelection?.from) linkContextEditing.value = false;
2781
3125
  },
2782
3126
  onUpdate: ({ editor: instance }) => {
2783
3127
  const value = instance.getJSON();
@@ -2789,6 +3133,16 @@ var LoomaEditor = defineComponent({
2789
3133
  editorStateVersion.value += 1;
2790
3134
  });
2791
3135
  editor.value = session.editor;
3136
+ const engine = createLinkEditing({
3137
+ editor: session.editor,
3138
+ search: () => props.linkSearch,
3139
+ resolve: () => props.linkResolve,
3140
+ baseUrl: () => props.linkBaseUrl
3141
+ });
3142
+ engine.subscribe(() => {
3143
+ linkState.value = engine.state;
3144
+ });
3145
+ linkEditing = engine;
2792
3146
  });
2793
3147
  const attachDocument = (element) => {
2794
3148
  if (element instanceof HTMLElement) session?.attach(element);
@@ -2796,56 +3150,9 @@ var LoomaEditor = defineComponent({
2796
3150
  onBeforeUnmount(() => {
2797
3151
  const element = session?.editor.options.element;
2798
3152
  element?.parentNode?.replaceChild(element.cloneNode(true), element);
3153
+ linkEditing?.destroy();
2799
3154
  session?.destroy();
2800
3155
  });
2801
- watch(() => {
2802
- editorStateVersion.value;
2803
- return editor.value?.isActive("link") ? editor.value.getAttributes("link").href : null;
2804
- }, async (href) => {
2805
- const sequence = ++linkResolveSequence;
2806
- linkContextTarget.value = null;
2807
- if (!href || !props.linkResolve) return;
2808
- if (linkTargetCache.has(href)) {
2809
- linkContextTarget.value = linkTargetCache.get(href) ?? null;
2810
- return;
2811
- }
2812
- try {
2813
- const target = await props.linkResolve(href);
2814
- linkTargetCache.set(href, target);
2815
- if (sequence === linkResolveSequence) linkContextTarget.value = target;
2816
- } catch {}
2817
- });
2818
- watch([
2819
- linkQuery,
2820
- linkMode,
2821
- linkOpen,
2822
- linkContextEditing
2823
- ], async ([query, mode, open, contextEditing]) => {
2824
- const sequence = ++linkSearchSequence;
2825
- linkResults.value = [];
2826
- linkSearching.value = false;
2827
- if (!props.linkSearch || mode !== "target" || !open && !contextEditing || query.trim().length < 2 || linkSelectedTarget.value && query === linkSelectedTarget.value.label) return;
2828
- linkSearching.value = true;
2829
- try {
2830
- const results = await props.linkSearch(query.trim());
2831
- if (sequence === linkSearchSequence) linkResults.value = results.filter((target) => validLinkHref(target.href));
2832
- } catch {
2833
- if (sequence === linkSearchSequence) linkError.value = "Search is unavailable. Try again.";
2834
- } finally {
2835
- if (sequence === linkSearchSequence) linkSearching.value = false;
2836
- }
2837
- });
2838
- const selectLinkTarget = (target) => {
2839
- linkSelectedTarget.value = target;
2840
- linkTargetCache.set(target.href, target);
2841
- linkHref.value = target.href;
2842
- linkQuery.value = target.label;
2843
- linkResults.value = [];
2844
- linkNewTab.value = false;
2845
- linkError.value = "";
2846
- if (linkTextEditable.value && !linkText.value.trim()) linkText.value = target.label;
2847
- applyLink();
2848
- };
2849
3156
  const openLinkEditor = (context = false) => {
2850
3157
  const instance = editor.value;
2851
3158
  if (!instance || !props.editable) return;
@@ -2855,130 +3162,37 @@ var LoomaEditor = defineComponent({
2855
3162
  linkPressedSelection = null;
2856
3163
  const from = selection?.from ?? current.from;
2857
3164
  const to = selection?.to ?? current.to;
2858
- const empty = from === to;
2859
- const existing = instance.isActive("link");
2860
- linkSelection = {
2861
- from,
2862
- to,
2863
- existing
2864
- };
2865
- linkTextEditable.value = empty && !existing;
2866
- const attrs = existing ? instance.getAttributes("link") : {};
2867
- linkHref.value = typeof attrs.href === "string" ? attrs.href : "";
2868
- linkNewTab.value = attrs.target !== "_self";
2869
- linkText.value = empty && !existing ? "" : instance.state.doc.textBetween(from, to);
2870
- linkError.value = "";
2871
- linkSelectedTarget.value = linkTargetCache.get(linkHref.value) ?? null;
2872
- linkMode.value = props.linkSearch && (!existing || linkSelectedTarget.value) ? "target" : "url";
2873
- linkQuery.value = linkSelectedTarget.value?.label ?? linkHref.value;
2874
- linkContextEditing.value = context;
2875
- linkOpen.value = !context;
2876
- if (existing && props.linkResolve && !linkTargetCache.has(linkHref.value)) {
2877
- const href = linkHref.value;
2878
- props.linkResolve(href).then((target) => {
2879
- linkTargetCache.set(href, target);
2880
- if (linkHref.value === href && target) {
2881
- linkSelectedTarget.value = target;
2882
- linkMode.value = "target";
2883
- linkQuery.value = target.label;
2884
- }
2885
- }).catch(() => {});
2886
- }
3165
+ linkEditing?.open(from, to, context ? "context" : "popover");
2887
3166
  nextTick(() => {
2888
3167
  if (context) (props.toolbarMode === "contextual" && floatingFormattingMode() && formattingSelectionActive(instance) ? formattingPopup : linkPopup)?.setAnchor(() => posToDOMRect(instance.view, instance.state.selection.from, instance.state.selection.to));
2889
- (linkMode.value === "url" ? linkDestinationInput() : linkFormRoot.value?.querySelector("[data-link-search]"))?.focus();
2890
- });
2891
- };
2892
- const applyLink = (reportError = false) => {
2893
- const instance = editor.value;
2894
- const selection = linkSelection;
2895
- if (!instance || !selection) return false;
2896
- const href = linkMode.value === "target" ? validLinkHref(linkSelectedTarget.value?.href ?? "") : validLinkHref(linkHref.value);
2897
- if (!href) {
2898
- if (reportError) linkError.value = linkMode.value === "target" ? "Choose a destination from the results." : "Enter an http, https, mailto, tel, or relative URL.";
2899
- return false;
2900
- }
2901
- if (linkMode.value === "url" && props.linkSearch && !selection.existing && !/^(https?:|mailto:|tel:)/i.test(href)) {
2902
- if (reportError) linkError.value = "Enter a full website, email, or phone URL, or choose a destination.";
2903
- return false;
2904
- }
2905
- const attrs = {
2906
- href: props.linkBaseUrl ? siteRelativeHref(href, props.linkBaseUrl) : href,
2907
- target: linkNewTab.value ? "_blank" : "_self",
2908
- rel: linkNewTab.value ? "noopener noreferrer" : null
2909
- };
2910
- const current = instance.state.selection;
2911
- const chain = instance.chain().setTextSelection({
2912
- from: selection.from,
2913
- to: selection.to
3168
+ (linkState.value.mode === "url" ? linkDestinationInput() : linkFormRoot.value?.querySelector("[data-link-search]"))?.focus();
2914
3169
  });
2915
- if (linkTextEditable.value) {
2916
- const text = linkText.value.trim();
2917
- if (!text) {
2918
- if (reportError) linkError.value = "Enter link text.";
2919
- return false;
2920
- }
2921
- linkSelection = {
2922
- from: selection.from,
2923
- to: selection.from + text.length,
2924
- existing: true
2925
- };
2926
- chain.insertContent({
2927
- type: "text",
2928
- text,
2929
- marks: [{
2930
- type: "link",
2931
- attrs
2932
- }]
2933
- }).setTextSelection(selection.from).run();
2934
- } else {
2935
- if (selection.existing && selection.from === selection.to) chain.extendMarkRange("link");
2936
- chain.setLink(attrs).setTextSelection({
2937
- from: current.from,
2938
- to: current.to
2939
- }).run();
2940
- selection.existing = true;
2941
- }
2942
- linkError.value = "";
2943
- return true;
2944
- };
2945
- const removeCurrentLink = () => {
2946
- const instance = editor.value;
2947
- if (!instance?.isActive("link")) return;
2948
- instance.chain().focus().extendMarkRange("link").unsetLink().run();
2949
- linkContextEditing.value = false;
2950
3170
  };
2951
3171
  const dismissLink = (restoreFocus = true) => {
2952
- linkOpen.value = false;
2953
- linkContextEditing.value = false;
2954
- const selection = linkSelection;
2955
- if (restoreFocus && selection) editor.value?.chain().focus().setTextSelection(selection.to).run();
3172
+ linkEditing?.close();
3173
+ const range = linkState.value.range;
3174
+ if (restoreFocus && range) editor.value?.chain().focus().setTextSelection(range.to).run();
2956
3175
  dismissedFormattingSelection = editor.value?.state.selection ?? null;
2957
3176
  heldFormattingSelection = null;
2958
3177
  linkPopup?.hide();
2959
3178
  formattingPopup?.hide();
2960
3179
  };
2961
- const linkStatus = () => {
2962
- if (linkSearching.value) return "Searching…";
2963
- if (linkSelectedTarget.value) return "";
2964
- if (linkQuery.value.trim().length < 2) return "Type at least two letters to search.";
2965
- return linkResults.value.length || linkError.value ? "" : "No results found.";
2966
- };
2967
3180
  const renderLinkForm = () => {
2968
- const shown = linkSelectedTarget.value ? [linkSelectedTarget.value] : linkResults.value;
3181
+ const state = linkState.value;
3182
+ const shown = state.selected ? [state.selected] : state.results;
2969
3183
  return h(EditorLinkForm, {
2970
3184
  ref: setLinkFormRoot,
2971
3185
  class: "looma-editor__link-form",
2972
- existing: Boolean(linkSelection?.existing),
3186
+ existing: Boolean(state.range?.existing),
2973
3187
  searchable: Boolean(props.linkSearch),
2974
- mode: linkMode.value,
2975
- value: props.linkSearch ? linkQuery.value : linkHref.value,
3188
+ mode: state.mode,
3189
+ value: props.linkSearch ? state.query : state.href,
2976
3190
  placeholder: props.linkSearch ? `Search ${props.linkTargetLabel.toLowerCase()}s or paste a URL…` : "https://example.com",
2977
- newTab: linkNewTab.value,
2978
- textEditable: linkTextEditable.value,
2979
- text: linkText.value,
2980
- error: linkError.value,
2981
- status: linkStatus(),
3191
+ newTab: state.newTab,
3192
+ textEditable: state.textEditable,
3193
+ text: state.text,
3194
+ error: state.error,
3195
+ status: linkFormStatus(state),
2982
3196
  results: shown.map(({ id, label, detail }) => detail ? {
2983
3197
  id,
2984
3198
  label,
@@ -2987,46 +3201,23 @@ var LoomaEditor = defineComponent({
2987
3201
  id,
2988
3202
  label
2989
3203
  }),
2990
- selected: linkSelectedTarget.value?.id ?? "",
2991
- onDestination: (event) => {
2992
- const { value } = event.detail;
2993
- if (props.linkSearch) {
2994
- linkQuery.value = value;
2995
- linkMode.value = /^(https?:\/\/|mailto:|tel:)/i.test(value.trim()) || linkSelection?.existing && /^(\/|\.\.?\/|#|\?)/.test(value.trim()) ? "url" : "target";
2996
- linkSelectedTarget.value = null;
2997
- }
2998
- linkHref.value = value;
2999
- linkError.value = "";
3000
- applyLink();
3001
- },
3002
- onClear: () => {
3003
- linkQuery.value = "";
3004
- linkHref.value = "";
3005
- linkMode.value = "target";
3006
- linkSelectedTarget.value = null;
3007
- linkError.value = "";
3008
- },
3204
+ selected: state.selected?.id ?? "",
3205
+ onDestination: (event) => linkEditing?.setDestination(event.detail.value),
3206
+ onClear: () => linkEditing?.clear(),
3009
3207
  onChoose: (event) => {
3010
3208
  const target = shown.find(({ id }) => id === event.detail.id);
3011
- if (target) selectLinkTarget(target);
3012
- },
3013
- onNewTab: (event) => {
3014
- linkNewTab.value = event.detail.newTab;
3015
- applyLink();
3016
- },
3017
- onText: (event) => {
3018
- linkText.value = event.detail.text;
3019
- linkError.value = "";
3020
- applyLink();
3209
+ if (target) linkEditing?.choose(target);
3021
3210
  },
3211
+ onNewTab: (event) => linkEditing?.setNewTab(event.detail.newTab),
3212
+ onText: (event) => linkEditing?.setText(event.detail.text),
3022
3213
  onSave: () => {
3023
- if (applyLink(true)) dismissLink();
3214
+ if (linkEditing?.apply(true)) dismissLink();
3024
3215
  },
3025
3216
  onDismiss: () => dismissLink()
3026
3217
  });
3027
3218
  };
3028
3219
  const renderLinkContextCard = (instance) => {
3029
- if (linkContextEditing.value) return h(Card, {
3220
+ if (linkState.value.surface === "context") return h(Card, {
3030
3221
  variant: "elevated",
3031
3222
  padding: "sm",
3032
3223
  size: "sm",
@@ -3034,7 +3225,7 @@ var LoomaEditor = defineComponent({
3034
3225
  }, () => [renderLinkForm()]);
3035
3226
  const rawHref = instance.getAttributes("link").href;
3036
3227
  const href = typeof rawHref === "string" ? rawHref : "";
3037
- const target = linkContextTarget.value;
3228
+ const target = linkState.value.current;
3038
3229
  return h(EditorLinkCard, {
3039
3230
  class: "looma-editor__link-context",
3040
3231
  href,
@@ -3043,35 +3234,9 @@ var LoomaEditor = defineComponent({
3043
3234
  detail: target?.detail ?? "",
3044
3235
  targetLabel: props.linkTargetLabel,
3045
3236
  onEdit: () => openLinkEditor(true),
3046
- onRemove: removeCurrentLink
3237
+ onRemove: () => linkEditing?.remove()
3047
3238
  });
3048
3239
  };
3049
- const runBlockAction = (value) => {
3050
- const instance = editor.value;
3051
- const head = instance?.state.selection.$head;
3052
- if (!instance || !head || head.depth === 0 || !props.editable) return;
3053
- const { state } = instance;
3054
- const position = head.before(1);
3055
- const block = state.doc.nodeAt(position);
3056
- if (!block) return;
3057
- const end = position + block.nodeSize;
3058
- const transaction = state.tr;
3059
- let focusAt = end + 1;
3060
- if (value === "insert-below") transaction.insert(end, state.schema.nodes.paragraph.create());
3061
- else if (value === "duplicate") transaction.insert(end, block);
3062
- else if (value === "delete") {
3063
- if (state.doc.childCount === 1) {
3064
- transaction.replaceWith(position, end, state.schema.nodes.paragraph.create());
3065
- focusAt = 1;
3066
- } else {
3067
- transaction.delete(position, end);
3068
- focusAt = Math.min(position + 1, transaction.doc.content.size);
3069
- }
3070
- } else return;
3071
- transaction.setSelection(TextSelection.near(transaction.doc.resolve(Math.min(focusAt, transaction.doc.content.size))));
3072
- instance.view.dispatch(transaction);
3073
- instance.commands.focus();
3074
- };
3075
3240
  watch([() => props.slashCommands, () => props.uploadImage], () => {
3076
3241
  slashExtension.options.commands = resolveSlashCommands();
3077
3242
  editor.value?.commands.dismissLoomaSlashMenu();
@@ -3088,11 +3253,11 @@ var LoomaEditor = defineComponent({
3088
3253
  if (!editable) chipOpen.value = false;
3089
3254
  });
3090
3255
  watch([
3091
- linkOpen,
3256
+ () => linkState.value.surface === "popover",
3092
3257
  tablePickerOpen,
3093
3258
  formatOpen
3094
- ], ([link, table, format]) => {
3095
- if (link || table || format || !floatingFormattingMode() || editor.value?.isFocused || formattingPopup?.element.contains(document.activeElement)) return;
3259
+ ], ([linking, table, format]) => {
3260
+ if (linking || table || format || !floatingFormattingMode() || editor.value?.isFocused || formattingPopup?.element.contains(document.activeElement)) return;
3096
3261
  heldFormattingSelection = null;
3097
3262
  formattingPopup?.hide();
3098
3263
  });
@@ -3337,7 +3502,7 @@ var LoomaEditor = defineComponent({
3337
3502
  const onDocumentPointerDown = (event) => {
3338
3503
  const path = typeof event.composedPath === "function" ? event.composedPath() : [];
3339
3504
  const inElement = (element) => Boolean(element && (path.includes(element) || event.target instanceof Node && element.contains(event.target)));
3340
- if ((linkOpen.value || linkContextEditing.value) && !inElement(linkFormRoot.value)) dismissLink(false);
3505
+ if (linkState.value.surface !== "none" && !inElement(linkFormRoot.value)) dismissLink(false);
3341
3506
  const inTableUi = inElement(tableToolbarShell.value) || inElement(tableOverlayShell.value) || inElement(tableMenuShell.value) || inElement(mobileToolbarShell.value);
3342
3507
  tableInteractionActive = inTableUi;
3343
3508
  if (inTableUi) setTimeout(() => {
@@ -3403,7 +3568,7 @@ var LoomaEditor = defineComponent({
3403
3568
  editor: instance,
3404
3569
  element,
3405
3570
  pluginKey: "looma-text-formatting-menu",
3406
- shouldShow: ({ editor: menuEditor }) => (props.toolbarMode !== "popover" || !props.toolbarOpen) && !slash.active && !mention.active && (!props.editable && (props.selectionActions.length > 0 || props.rangeActions.length > 0) || menuEditor.isFocused || formattingPopup?.element.contains(document.activeElement) || linkContextEditing.value || formattingPickerOpen()) && formattingSelectionActive(menuEditor),
3571
+ shouldShow: ({ editor: menuEditor }) => (props.toolbarMode !== "popover" || !props.toolbarOpen) && !slash.active && !mention.active && (!props.editable && (props.selectionActions.length > 0 || props.rangeActions.length > 0) || menuEditor.isFocused || formattingPopup?.element.contains(document.activeElement) || linkState.value.surface === "context" || formattingPickerOpen()) && formattingSelectionActive(menuEditor),
3407
3572
  boundary: () => props.toolbarMode === "contextual" ? root.value : null,
3408
3573
  related: editorRoot,
3409
3574
  onHide: () => instance.isEditable && floatingFormattingMode() && formattingPickerOpen() && formattingSelectionActive(instance) ? false : void 0,
@@ -3415,7 +3580,7 @@ var LoomaEditor = defineComponent({
3415
3580
  editor: instance,
3416
3581
  element,
3417
3582
  pluginKey: "looma-link-context-menu",
3418
- shouldShow: ({ editor: menuEditor, from, to }) => from === to && menuEditor.isActive("link") && !linkOpen.value && !dismissedFormattingSelection?.eq(menuEditor.state.selection) && Boolean(menuEditor.isFocused || linkContextEditing.value || linkPopup?.element.contains(document.activeElement)) && !(props.toolbarMode === "contextual" && floatingFormattingMode() && formattingSelectionActive(menuEditor)),
3583
+ shouldShow: ({ editor: menuEditor, from, to }) => from === to && menuEditor.isActive("link") && linkState.value.surface !== "popover" && !dismissedFormattingSelection?.eq(menuEditor.state.selection) && Boolean(menuEditor.isFocused || linkState.value.surface === "context" || linkPopup?.element.contains(document.activeElement)) && !(props.toolbarMode === "contextual" && floatingFormattingMode() && formattingSelectionActive(menuEditor)),
3419
3584
  maxWidth: () => Math.min(480, window.innerWidth - 24),
3420
3585
  related: editorRoot,
3421
3586
  onDismiss: (reason) => reason === "escape" ? dismissFormatting() : linkPopup?.hide()
@@ -3661,13 +3826,8 @@ var LoomaEditor = defineComponent({
3661
3826
  if (!instance) return;
3662
3827
  const table = overlayTableElement ?? selectedTableElement(instance);
3663
3828
  if (!table) return;
3664
- const targetCell = "rowIndex" in detail ? resolveTableCellAt(table, detail.rowIndex, detail.columnIndex) : "fromIndex" in detail ? resolveTableCellAt(table, detail.action === "reorder-row" ? detail.fromIndex : 0, detail.action === "reorder-column" ? detail.fromIndex : 0) : resolveTableCellAt(table, detail.action.startsWith("add-row") ? Math.max(0, detail.boundaryIndex - 1) : 0, detail.action.startsWith("add-column") ? Math.max(0, detail.boundaryIndex - 1) : 0);
3665
- if (targetCell && selectedTableElement(instance) !== table) instance.chain().focus().setTextSelection(instance.view.posAtDOM(targetCell, 0) + 1).run();
3829
+ focusTableOverlayTarget(instance, table, detail);
3666
3830
  if (detail.action === "open-cell-menu") {
3667
- const selectedCell = selectedTableCellElement(instance);
3668
- const cell = table ? resolveTableCellAt(table, detail.rowIndex, detail.columnIndex) ?? selectedCell : selectedCell;
3669
- if (cell && selectedCell !== cell && !cell.classList.contains("selectedCell")) instance.chain().focus().setTextSelection(instance.view.posAtDOM(cell, 0) + 1).run();
3670
- else instance.commands.focus();
3671
3831
  updateTableUi();
3672
3832
  tableUi.menuScope = "cell";
3673
3833
  tableUi.menuStyle = {
@@ -3833,7 +3993,7 @@ var LoomaEditor = defineComponent({
3833
3993
  label: "Link",
3834
3994
  size: "sm",
3835
3995
  variant: "ghost",
3836
- "aria-expanded": linkOpen.value ? "true" : "false",
3996
+ "aria-expanded": linkState.value.surface === "popover" ? "true" : "false",
3837
3997
  "aria-pressed": instance.isActive("link") ? "true" : "false",
3838
3998
  "data-active": instance.isActive("link") ? "true" : "false",
3839
3999
  onPointerenter: () => showTool(linkTriggerId, "Link", false),
@@ -4017,7 +4177,7 @@ var LoomaEditor = defineComponent({
4017
4177
  ref: formattingSurfaceRef,
4018
4178
  class: "looma-editor__floating-shell looma-editor__formatting-shell",
4019
4179
  hidden: true
4020
- }, [props.toolbarMode === "contextual" ? h(Stack, { gap: "xs" }, () => [renderToolbar(instance, true), (linkContextEditing.value || instance.state.selection.empty && instance.isActive("link")) && !linkOpen.value && formattingSelectionActive(instance) ? renderLinkContextCard(instance) : null]) : renderToolbar(instance, true, props.toolbarMode === "popover")])]) : null,
4180
+ }, [props.toolbarMode === "contextual" ? h(Stack, { gap: "xs" }, () => [renderToolbar(instance, true), (linkState.value.surface === "context" || instance.state.selection.empty && instance.isActive("link")) && linkState.value.surface !== "popover" && formattingSelectionActive(instance) ? renderLinkContextCard(instance) : null]) : renderToolbar(instance, true, props.toolbarMode === "popover")])]) : null,
4021
4181
  instance && props.editable ? h("div", { key: "looma-link-host" }, [h("div", {
4022
4182
  ref: linkSurfaceRef,
4023
4183
  class: "looma-editor__floating-shell looma-editor__link-shell",
@@ -4055,7 +4215,7 @@ var LoomaEditor = defineComponent({
4055
4215
  languages: codeLanguageOptions.value,
4056
4216
  onLanguage: (event) => chooseCodeLanguage(event.detail.value)
4057
4217
  })]) : null,
4058
- instance && (props.editable || props.selectionActions.length && !instance.state.selection.empty || activeRangeActions(instance).length > 0) && mobile.value && (editorFocused.value || linkOpen.value || !props.editable) ? h("div", {
4218
+ instance && (props.editable || props.selectionActions.length && !instance.state.selection.empty || activeRangeActions(instance).length > 0) && mobile.value && (editorFocused.value || linkState.value.surface === "popover" || !props.editable) ? h("div", {
4059
4219
  key: "looma-mobile-toolbar-shell",
4060
4220
  ref: mobileToolbarRef,
4061
4221
  class: "looma-editor__mobile-toolbar-shell",
@@ -4150,17 +4310,18 @@ var LoomaEditor = defineComponent({
4150
4310
  h(Popover, {
4151
4311
  class: "looma-editor__link-popover",
4152
4312
  size: "sm",
4153
- open: linkOpen.value,
4313
+ open: linkState.value.surface === "popover",
4154
4314
  for: linkPopoverAnchorId.value,
4155
4315
  placement: mobile.value ? "top-start" : "bottom-start",
4156
4316
  onOpen: () => openLinkEditor(),
4157
4317
  onClose: () => {
4158
- linkOpen.value = false;
4318
+ if (linkState.value.surface === "popover") linkEditing?.close();
4159
4319
  linkPressedSelection = null;
4160
4320
  const current = editor.value?.state.selection;
4161
- if (current && !current.empty && linkSelection && current.from === linkSelection.from && current.to === linkSelection.to) editor.value?.commands.setTextSelection(current.to);
4321
+ const range = linkState.value.range;
4322
+ if (current && !current.empty && range && current.from === range.from && current.to === range.to) editor.value?.commands.setTextSelection(current.to);
4162
4323
  }
4163
- }, () => linkOpen.value ? renderLinkForm() : null),
4324
+ }, () => linkState.value.surface === "popover" ? renderLinkForm() : null),
4164
4325
  props.editable && props.compactToolbar && !mobile.value ? h(Popover, {
4165
4326
  class: "looma-editor__compact-formatting",
4166
4327
  for: formatAnchorId,
@@ -4176,7 +4337,9 @@ var LoomaEditor = defineComponent({
4176
4337
  h(EditorBlockActions, {
4177
4338
  for: blockActionAnchorId,
4178
4339
  placement: mobile.value ? "top-start" : "bottom-start",
4179
- onAction: (event) => runBlockAction(event.detail.action)
4340
+ onAction: (event) => {
4341
+ if (props.editable && editor.value) runBlockAction(editor.value, event.detail.action);
4342
+ }
4180
4343
  }),
4181
4344
  h(Popover, {
4182
4345
  class: "looma-editor__table-picker-popover",