@threadlabs/looma 0.28.9 → 0.28.10

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,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.10",
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, resolveTableCellAt } 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,7 @@ 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/transform";
10
11
  import { DOMParser, DOMSerializer, Fragment as Fragment$1 } from "@tiptap/pm/model";
11
12
  export * from "@threadlabs/looma/editor";
12
13
  //#region src/vue/editor/primitives.ts
@@ -1121,6 +1122,273 @@ function validLinkHref(value) {
1121
1122
  }
1122
1123
  }
1123
1124
  //#endregion
1125
+ //#region src/editor/extensions/relative-links.ts
1126
+ /** Keep other origins and non-web schemes intact; same-origin links retain path, query, and fragment. */
1127
+ function siteRelativeHref(href, baseUrl) {
1128
+ if (!/^(https?:\/\/|\/\/)/i.test(href.trim())) return href;
1129
+ try {
1130
+ const base = new URL(baseUrl);
1131
+ const url = new URL(href, base);
1132
+ if (!/^https?:$/.test(base.protocol) || url.origin !== base.origin || url.username || url.password) return href;
1133
+ return `${url.pathname}${url.search}${url.hash}`;
1134
+ } catch {
1135
+ return href;
1136
+ }
1137
+ }
1138
+ //#endregion
1139
+ //#region src/editor/link-editing.ts
1140
+ /** The status line above search results: progress, a hint, or an empty result. */
1141
+ function linkFormStatus(state) {
1142
+ if (state.searching) return "Searching…";
1143
+ if (state.selected) return "";
1144
+ if (state.query.trim().length < 2) return "Type at least two letters to search.";
1145
+ return state.results.length || state.error ? "" : "No results found.";
1146
+ }
1147
+ var LOOMA_LINK_EDITING_IDLE = Object.freeze({
1148
+ surface: "none",
1149
+ range: null,
1150
+ mode: "url",
1151
+ query: "",
1152
+ href: "",
1153
+ text: "",
1154
+ textEditable: false,
1155
+ newTab: true,
1156
+ error: "",
1157
+ results: [],
1158
+ searching: false,
1159
+ selected: null,
1160
+ current: null
1161
+ });
1162
+ function createLinkEditing(options) {
1163
+ const { editor } = options;
1164
+ const listeners = /* @__PURE__ */ new Set();
1165
+ const cache = /* @__PURE__ */ new Map();
1166
+ let state = LOOMA_LINK_EDITING_IDLE;
1167
+ let searchSequence = 0;
1168
+ let resolveSequence = 0;
1169
+ let currentHref = null;
1170
+ let destroyed = false;
1171
+ const set = (patch) => {
1172
+ const previous = state;
1173
+ state = {
1174
+ ...state,
1175
+ ...patch
1176
+ };
1177
+ if (state.query !== previous.query || state.mode !== previous.mode || state.surface !== previous.surface) search();
1178
+ listeners.forEach((listener) => listener());
1179
+ };
1180
+ const search = async () => {
1181
+ const sequence = ++searchSequence;
1182
+ const { query, mode, surface, selected } = state;
1183
+ state = {
1184
+ ...state,
1185
+ results: [],
1186
+ searching: false
1187
+ };
1188
+ const find = options.search();
1189
+ if (!find || mode !== "target" || surface === "none" || query.trim().length < 2 || selected && query === selected.label) return;
1190
+ state = {
1191
+ ...state,
1192
+ searching: true
1193
+ };
1194
+ try {
1195
+ const results = await find(query.trim());
1196
+ if (sequence === searchSequence && !destroyed) set({ results: results.filter((target) => validLinkHref(target.href)) });
1197
+ } catch {
1198
+ if (sequence === searchSequence && !destroyed) set({ error: "Search is unavailable. Try again." });
1199
+ } finally {
1200
+ if (sequence === searchSequence && !destroyed) set({ searching: false });
1201
+ }
1202
+ };
1203
+ const resolveCurrent = async (href) => {
1204
+ const sequence = ++resolveSequence;
1205
+ if (state.current) set({ current: null });
1206
+ const resolve = options.resolve();
1207
+ if (!href || !resolve) return;
1208
+ if (cache.has(href)) {
1209
+ set({ current: cache.get(href) ?? null });
1210
+ return;
1211
+ }
1212
+ try {
1213
+ const target = await resolve(href);
1214
+ cache.set(href, target);
1215
+ if (sequence === resolveSequence && !destroyed) set({ current: target });
1216
+ } catch {}
1217
+ };
1218
+ const onTransaction = () => {
1219
+ const href = editor.isActive("link") ? String(editor.getAttributes("link").href ?? "") : null;
1220
+ if (href !== currentHref) {
1221
+ currentHref = href;
1222
+ resolveCurrent(href);
1223
+ }
1224
+ if (state.surface === "context" && editor.state.selection.from !== state.range?.from) set({ surface: "none" });
1225
+ };
1226
+ editor.on("transaction", onTransaction);
1227
+ const apply = (reportError = false) => {
1228
+ const range = state.range;
1229
+ if (editor.isDestroyed || !range) return false;
1230
+ const fail = (error) => {
1231
+ if (reportError) set({ error });
1232
+ return false;
1233
+ };
1234
+ const href = state.mode === "target" ? validLinkHref(state.selected?.href ?? "") : validLinkHref(state.href);
1235
+ if (!href) return fail(state.mode === "target" ? "Choose a destination from the results." : "Enter an http, https, mailto, tel, or relative URL.");
1236
+ 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.");
1237
+ const baseUrl = options.baseUrl();
1238
+ const attrs = {
1239
+ href: baseUrl ? siteRelativeHref(href, baseUrl) : href,
1240
+ target: state.newTab ? "_blank" : "_self",
1241
+ rel: state.newTab ? "noopener noreferrer" : null
1242
+ };
1243
+ const current = editor.state.selection;
1244
+ const chain = editor.chain().setTextSelection({
1245
+ from: range.from,
1246
+ to: range.to
1247
+ });
1248
+ if (state.textEditable) {
1249
+ const text = state.text.trim();
1250
+ if (!text) return fail("Enter link text.");
1251
+ chain.insertContent({
1252
+ type: "text",
1253
+ text,
1254
+ marks: [{
1255
+ type: "link",
1256
+ attrs
1257
+ }]
1258
+ }).setTextSelection(range.from).run();
1259
+ set({
1260
+ range: {
1261
+ from: range.from,
1262
+ to: range.from + text.length,
1263
+ existing: true
1264
+ },
1265
+ error: ""
1266
+ });
1267
+ } else {
1268
+ if (range.existing && range.from === range.to) chain.extendMarkRange("link");
1269
+ chain.setLink(attrs).setTextSelection({
1270
+ from: current.from,
1271
+ to: current.to
1272
+ }).run();
1273
+ set({
1274
+ range: {
1275
+ ...range,
1276
+ existing: true
1277
+ },
1278
+ error: ""
1279
+ });
1280
+ }
1281
+ return true;
1282
+ };
1283
+ return {
1284
+ get state() {
1285
+ return state;
1286
+ },
1287
+ subscribe(listener) {
1288
+ listeners.add(listener);
1289
+ return () => {
1290
+ listeners.delete(listener);
1291
+ };
1292
+ },
1293
+ open(from, to, surface) {
1294
+ const empty = from === to;
1295
+ const existing = editor.isActive("link");
1296
+ const attrs = existing ? editor.getAttributes("link") : {};
1297
+ const href = typeof attrs.href === "string" ? attrs.href : "";
1298
+ const selected = cache.get(href) ?? null;
1299
+ set({
1300
+ surface,
1301
+ range: {
1302
+ from,
1303
+ to,
1304
+ existing
1305
+ },
1306
+ textEditable: empty && !existing,
1307
+ href,
1308
+ newTab: attrs.target !== "_self",
1309
+ text: empty && !existing ? "" : editor.state.doc.textBetween(from, to),
1310
+ error: "",
1311
+ selected,
1312
+ mode: options.search() && (!existing || selected) ? "target" : "url",
1313
+ query: selected?.label ?? href
1314
+ });
1315
+ const resolve = options.resolve();
1316
+ if (existing && resolve && !cache.has(href)) resolve(href).then((target) => {
1317
+ cache.set(href, target);
1318
+ if (!destroyed && state.href === href && target) set({
1319
+ selected: target,
1320
+ mode: "target",
1321
+ query: target.label
1322
+ });
1323
+ }).catch(() => {});
1324
+ },
1325
+ setDestination(value) {
1326
+ if (options.search()) {
1327
+ const trimmed = value.trim();
1328
+ const url = /^(https?:\/\/|mailto:|tel:)/i.test(trimmed) || Boolean(state.range?.existing && /^(\/|\.\.?\/|#|\?)/.test(trimmed));
1329
+ set({
1330
+ query: value,
1331
+ mode: url ? "url" : "target",
1332
+ selected: null,
1333
+ href: value,
1334
+ error: ""
1335
+ });
1336
+ } else set({
1337
+ href: value,
1338
+ error: ""
1339
+ });
1340
+ apply();
1341
+ },
1342
+ clear() {
1343
+ set({
1344
+ query: "",
1345
+ href: "",
1346
+ mode: "target",
1347
+ selected: null,
1348
+ error: ""
1349
+ });
1350
+ },
1351
+ choose(target) {
1352
+ cache.set(target.href, target);
1353
+ set({
1354
+ selected: target,
1355
+ href: target.href,
1356
+ query: target.label,
1357
+ results: [],
1358
+ newTab: false,
1359
+ error: "",
1360
+ ...state.textEditable && !state.text.trim() ? { text: target.label } : {}
1361
+ });
1362
+ apply();
1363
+ },
1364
+ setNewTab(newTab) {
1365
+ set({ newTab });
1366
+ apply();
1367
+ },
1368
+ setText(text) {
1369
+ set({
1370
+ text,
1371
+ error: ""
1372
+ });
1373
+ apply();
1374
+ },
1375
+ apply,
1376
+ close() {
1377
+ if (state.surface !== "none") set({ surface: "none" });
1378
+ },
1379
+ remove() {
1380
+ if (!editor.isActive("link")) return;
1381
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
1382
+ if (state.surface === "context") set({ surface: "none" });
1383
+ },
1384
+ destroy() {
1385
+ destroyed = true;
1386
+ listeners.clear();
1387
+ editor.off("transaction", onTransaction);
1388
+ }
1389
+ };
1390
+ }
1391
+ //#endregion
1124
1392
  //#region src/editor/selection-geometry.ts
1125
1393
  /** A plain copy of a client rectangle, the anchor shape the slash and mention menus accept as a prop. */
1126
1394
  function menuAnchorRect(rect) {
@@ -2493,28 +2761,13 @@ var LoomaEditor = defineComponent({
2493
2761
  const element = instance?.$el;
2494
2762
  linkFormRoot.value = element instanceof HTMLElement ? element : null;
2495
2763
  };
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;
2764
+ let linkEditing;
2765
+ const linkState = shallowRef(LOOMA_LINK_EDITING_IDLE);
2512
2766
  const chipAnchorId = `looma-editor-chip-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
2513
2767
  const chipInput = ref(null);
2514
2768
  const chipOpen = ref(false);
2515
2769
  const chipLabel = ref("");
2516
2770
  const chipColor = ref("neutral");
2517
- let linkSelection = null;
2518
2771
  const toolbarScope = `looma-editor-tool-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
2519
2772
  const tooltipFor = ref("");
2520
2773
  const tooltipLabel = ref("");
@@ -2565,7 +2818,7 @@ var LoomaEditor = defineComponent({
2565
2818
  let heldPressTimer;
2566
2819
  let heldPress = null;
2567
2820
  const floatingFormattingMode = () => props.toolbarMode !== "sticky" && !(props.toolbarMode === "popover" && props.toolbarOpen) && !mobile.value;
2568
- const formattingPickerOpen = () => linkOpen.value || tablePickerOpen.value || formatOpen.value;
2821
+ const formattingPickerOpen = () => linkState.value.surface === "popover" || tablePickerOpen.value || formatOpen.value;
2569
2822
  const cancelHeldPress = () => {
2570
2823
  clearTimeout(heldPressTimer);
2571
2824
  heldPressTimer = void 0;
@@ -2754,7 +3007,7 @@ var LoomaEditor = defineComponent({
2754
3007
  if (view.isDestroyed) return;
2755
3008
  editorStateVersion.value += 1;
2756
3009
  const instance = editor.value;
2757
- if (instance?.isFocused && instance.state.selection.empty && instance.isActive("link") && !linkOpen.value) {
3010
+ if (instance?.isFocused && instance.state.selection.empty && instance.isActive("link") && linkState.value.surface !== "popover") {
2758
3011
  linkPopup?.setAnchor(() => link.getBoundingClientRect());
2759
3012
  linkPopup?.show();
2760
3013
  }
@@ -2777,7 +3030,6 @@ var LoomaEditor = defineComponent({
2777
3030
  if (!heldFormattingSelection?.eq(selection)) heldFormattingSelection = null;
2778
3031
  if (!formattingSelectionActive(instance)) formattingPopup?.hide();
2779
3032
  }
2780
- if (linkContextEditing.value && instance.state.selection.from !== linkSelection?.from) linkContextEditing.value = false;
2781
3033
  },
2782
3034
  onUpdate: ({ editor: instance }) => {
2783
3035
  const value = instance.getJSON();
@@ -2789,6 +3041,16 @@ var LoomaEditor = defineComponent({
2789
3041
  editorStateVersion.value += 1;
2790
3042
  });
2791
3043
  editor.value = session.editor;
3044
+ const engine = createLinkEditing({
3045
+ editor: session.editor,
3046
+ search: () => props.linkSearch,
3047
+ resolve: () => props.linkResolve,
3048
+ baseUrl: () => props.linkBaseUrl
3049
+ });
3050
+ engine.subscribe(() => {
3051
+ linkState.value = engine.state;
3052
+ });
3053
+ linkEditing = engine;
2792
3054
  });
2793
3055
  const attachDocument = (element) => {
2794
3056
  if (element instanceof HTMLElement) session?.attach(element);
@@ -2796,56 +3058,9 @@ var LoomaEditor = defineComponent({
2796
3058
  onBeforeUnmount(() => {
2797
3059
  const element = session?.editor.options.element;
2798
3060
  element?.parentNode?.replaceChild(element.cloneNode(true), element);
3061
+ linkEditing?.destroy();
2799
3062
  session?.destroy();
2800
3063
  });
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
3064
  const openLinkEditor = (context = false) => {
2850
3065
  const instance = editor.value;
2851
3066
  if (!instance || !props.editable) return;
@@ -2855,130 +3070,37 @@ var LoomaEditor = defineComponent({
2855
3070
  linkPressedSelection = null;
2856
3071
  const from = selection?.from ?? current.from;
2857
3072
  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
- }
3073
+ linkEditing?.open(from, to, context ? "context" : "popover");
2887
3074
  nextTick(() => {
2888
3075
  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();
3076
+ (linkState.value.mode === "url" ? linkDestinationInput() : linkFormRoot.value?.querySelector("[data-link-search]"))?.focus();
2890
3077
  });
2891
3078
  };
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
2914
- });
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
- };
2951
3079
  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();
3080
+ linkEditing?.close();
3081
+ const range = linkState.value.range;
3082
+ if (restoreFocus && range) editor.value?.chain().focus().setTextSelection(range.to).run();
2956
3083
  dismissedFormattingSelection = editor.value?.state.selection ?? null;
2957
3084
  heldFormattingSelection = null;
2958
3085
  linkPopup?.hide();
2959
3086
  formattingPopup?.hide();
2960
3087
  };
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
3088
  const renderLinkForm = () => {
2968
- const shown = linkSelectedTarget.value ? [linkSelectedTarget.value] : linkResults.value;
3089
+ const state = linkState.value;
3090
+ const shown = state.selected ? [state.selected] : state.results;
2969
3091
  return h(EditorLinkForm, {
2970
3092
  ref: setLinkFormRoot,
2971
3093
  class: "looma-editor__link-form",
2972
- existing: Boolean(linkSelection?.existing),
3094
+ existing: Boolean(state.range?.existing),
2973
3095
  searchable: Boolean(props.linkSearch),
2974
- mode: linkMode.value,
2975
- value: props.linkSearch ? linkQuery.value : linkHref.value,
3096
+ mode: state.mode,
3097
+ value: props.linkSearch ? state.query : state.href,
2976
3098
  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(),
3099
+ newTab: state.newTab,
3100
+ textEditable: state.textEditable,
3101
+ text: state.text,
3102
+ error: state.error,
3103
+ status: linkFormStatus(state),
2982
3104
  results: shown.map(({ id, label, detail }) => detail ? {
2983
3105
  id,
2984
3106
  label,
@@ -2987,46 +3109,23 @@ var LoomaEditor = defineComponent({
2987
3109
  id,
2988
3110
  label
2989
3111
  }),
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
- },
3112
+ selected: state.selected?.id ?? "",
3113
+ onDestination: (event) => linkEditing?.setDestination(event.detail.value),
3114
+ onClear: () => linkEditing?.clear(),
3009
3115
  onChoose: (event) => {
3010
3116
  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();
3117
+ if (target) linkEditing?.choose(target);
3021
3118
  },
3119
+ onNewTab: (event) => linkEditing?.setNewTab(event.detail.newTab),
3120
+ onText: (event) => linkEditing?.setText(event.detail.text),
3022
3121
  onSave: () => {
3023
- if (applyLink(true)) dismissLink();
3122
+ if (linkEditing?.apply(true)) dismissLink();
3024
3123
  },
3025
3124
  onDismiss: () => dismissLink()
3026
3125
  });
3027
3126
  };
3028
3127
  const renderLinkContextCard = (instance) => {
3029
- if (linkContextEditing.value) return h(Card, {
3128
+ if (linkState.value.surface === "context") return h(Card, {
3030
3129
  variant: "elevated",
3031
3130
  padding: "sm",
3032
3131
  size: "sm",
@@ -3034,7 +3133,7 @@ var LoomaEditor = defineComponent({
3034
3133
  }, () => [renderLinkForm()]);
3035
3134
  const rawHref = instance.getAttributes("link").href;
3036
3135
  const href = typeof rawHref === "string" ? rawHref : "";
3037
- const target = linkContextTarget.value;
3136
+ const target = linkState.value.current;
3038
3137
  return h(EditorLinkCard, {
3039
3138
  class: "looma-editor__link-context",
3040
3139
  href,
@@ -3043,7 +3142,7 @@ var LoomaEditor = defineComponent({
3043
3142
  detail: target?.detail ?? "",
3044
3143
  targetLabel: props.linkTargetLabel,
3045
3144
  onEdit: () => openLinkEditor(true),
3046
- onRemove: removeCurrentLink
3145
+ onRemove: () => linkEditing?.remove()
3047
3146
  });
3048
3147
  };
3049
3148
  const runBlockAction = (value) => {
@@ -3088,11 +3187,11 @@ var LoomaEditor = defineComponent({
3088
3187
  if (!editable) chipOpen.value = false;
3089
3188
  });
3090
3189
  watch([
3091
- linkOpen,
3190
+ () => linkState.value.surface === "popover",
3092
3191
  tablePickerOpen,
3093
3192
  formatOpen
3094
- ], ([link, table, format]) => {
3095
- if (link || table || format || !floatingFormattingMode() || editor.value?.isFocused || formattingPopup?.element.contains(document.activeElement)) return;
3193
+ ], ([linking, table, format]) => {
3194
+ if (linking || table || format || !floatingFormattingMode() || editor.value?.isFocused || formattingPopup?.element.contains(document.activeElement)) return;
3096
3195
  heldFormattingSelection = null;
3097
3196
  formattingPopup?.hide();
3098
3197
  });
@@ -3337,7 +3436,7 @@ var LoomaEditor = defineComponent({
3337
3436
  const onDocumentPointerDown = (event) => {
3338
3437
  const path = typeof event.composedPath === "function" ? event.composedPath() : [];
3339
3438
  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);
3439
+ if (linkState.value.surface !== "none" && !inElement(linkFormRoot.value)) dismissLink(false);
3341
3440
  const inTableUi = inElement(tableToolbarShell.value) || inElement(tableOverlayShell.value) || inElement(tableMenuShell.value) || inElement(mobileToolbarShell.value);
3342
3441
  tableInteractionActive = inTableUi;
3343
3442
  if (inTableUi) setTimeout(() => {
@@ -3403,7 +3502,7 @@ var LoomaEditor = defineComponent({
3403
3502
  editor: instance,
3404
3503
  element,
3405
3504
  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),
3505
+ 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
3506
  boundary: () => props.toolbarMode === "contextual" ? root.value : null,
3408
3507
  related: editorRoot,
3409
3508
  onHide: () => instance.isEditable && floatingFormattingMode() && formattingPickerOpen() && formattingSelectionActive(instance) ? false : void 0,
@@ -3415,7 +3514,7 @@ var LoomaEditor = defineComponent({
3415
3514
  editor: instance,
3416
3515
  element,
3417
3516
  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)),
3517
+ 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
3518
  maxWidth: () => Math.min(480, window.innerWidth - 24),
3420
3519
  related: editorRoot,
3421
3520
  onDismiss: (reason) => reason === "escape" ? dismissFormatting() : linkPopup?.hide()
@@ -3833,7 +3932,7 @@ var LoomaEditor = defineComponent({
3833
3932
  label: "Link",
3834
3933
  size: "sm",
3835
3934
  variant: "ghost",
3836
- "aria-expanded": linkOpen.value ? "true" : "false",
3935
+ "aria-expanded": linkState.value.surface === "popover" ? "true" : "false",
3837
3936
  "aria-pressed": instance.isActive("link") ? "true" : "false",
3838
3937
  "data-active": instance.isActive("link") ? "true" : "false",
3839
3938
  onPointerenter: () => showTool(linkTriggerId, "Link", false),
@@ -4017,7 +4116,7 @@ var LoomaEditor = defineComponent({
4017
4116
  ref: formattingSurfaceRef,
4018
4117
  class: "looma-editor__floating-shell looma-editor__formatting-shell",
4019
4118
  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,
4119
+ }, [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
4120
  instance && props.editable ? h("div", { key: "looma-link-host" }, [h("div", {
4022
4121
  ref: linkSurfaceRef,
4023
4122
  class: "looma-editor__floating-shell looma-editor__link-shell",
@@ -4055,7 +4154,7 @@ var LoomaEditor = defineComponent({
4055
4154
  languages: codeLanguageOptions.value,
4056
4155
  onLanguage: (event) => chooseCodeLanguage(event.detail.value)
4057
4156
  })]) : 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", {
4157
+ 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
4158
  key: "looma-mobile-toolbar-shell",
4060
4159
  ref: mobileToolbarRef,
4061
4160
  class: "looma-editor__mobile-toolbar-shell",
@@ -4150,17 +4249,18 @@ var LoomaEditor = defineComponent({
4150
4249
  h(Popover, {
4151
4250
  class: "looma-editor__link-popover",
4152
4251
  size: "sm",
4153
- open: linkOpen.value,
4252
+ open: linkState.value.surface === "popover",
4154
4253
  for: linkPopoverAnchorId.value,
4155
4254
  placement: mobile.value ? "top-start" : "bottom-start",
4156
4255
  onOpen: () => openLinkEditor(),
4157
4256
  onClose: () => {
4158
- linkOpen.value = false;
4257
+ if (linkState.value.surface === "popover") linkEditing?.close();
4159
4258
  linkPressedSelection = null;
4160
4259
  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);
4260
+ const range = linkState.value.range;
4261
+ if (current && !current.empty && range && current.from === range.from && current.to === range.to) editor.value?.commands.setTextSelection(current.to);
4162
4262
  }
4163
- }, () => linkOpen.value ? renderLinkForm() : null),
4263
+ }, () => linkState.value.surface === "popover" ? renderLinkForm() : null),
4164
4264
  props.editable && props.compactToolbar && !mobile.value ? h(Popover, {
4165
4265
  class: "looma-editor__compact-formatting",
4166
4266
  for: formatAnchorId,