@threadlabs/looma 0.28.8 → 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.
@@ -1,12 +1,13 @@
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";
3
- import { Button, Callout, Card, Checkbox, Cluster, Combobox, Disclosure, EditorBlockActions, 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";
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
+ 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";
6
6
  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
@@ -25,7 +26,8 @@ var EDITOR_ADAPTER_COMPONENT_TAG_MAP = {
25
26
  EditorImageToolbar: "ui-editor-image-toolbar",
26
27
  EditorLinkCard: "ui-editor-link-card",
27
28
  EditorLinkForm: "ui-editor-link-form",
28
- EditorBlockActions: "ui-editor-block-actions"
29
+ EditorBlockActions: "ui-editor-block-actions",
30
+ EditorCodeLanguage: "ui-editor-code-language"
29
31
  };
30
32
  //#endregion
31
33
  //#region src/components/shared/overlay.js
@@ -1120,6 +1122,273 @@ function validLinkHref(value) {
1120
1122
  }
1121
1123
  }
1122
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
1123
1392
  //#region src/editor/selection-geometry.ts
1124
1393
  /** A plain copy of a client rectangle, the anchor shape the slash and mention menus accept as a prop. */
1125
1394
  function menuAnchorRect(rect) {
@@ -2492,28 +2761,13 @@ var LoomaEditor = defineComponent({
2492
2761
  const element = instance?.$el;
2493
2762
  linkFormRoot.value = element instanceof HTMLElement ? element : null;
2494
2763
  };
2495
- const linkOpen = ref(false);
2496
- const linkContextEditing = ref(false);
2497
- const linkHref = ref("");
2498
- const linkText = ref("");
2499
- const linkTextEditable = ref(false);
2500
- const linkNewTab = ref(true);
2501
- const linkError = ref("");
2502
- const linkMode = ref("url");
2503
- const linkQuery = ref("");
2504
- const linkResults = ref([]);
2505
- const linkSearching = ref(false);
2506
- const linkSelectedTarget = ref(null);
2507
- const linkContextTarget = ref(null);
2508
- const linkTargetCache = /* @__PURE__ */ new Map();
2509
- let linkSearchSequence = 0;
2510
- let linkResolveSequence = 0;
2764
+ let linkEditing;
2765
+ const linkState = shallowRef(LOOMA_LINK_EDITING_IDLE);
2511
2766
  const chipAnchorId = `looma-editor-chip-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
2512
2767
  const chipInput = ref(null);
2513
2768
  const chipOpen = ref(false);
2514
2769
  const chipLabel = ref("");
2515
2770
  const chipColor = ref("neutral");
2516
- let linkSelection = null;
2517
2771
  const toolbarScope = `looma-editor-tool-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
2518
2772
  const tooltipFor = ref("");
2519
2773
  const tooltipLabel = ref("");
@@ -2564,7 +2818,7 @@ var LoomaEditor = defineComponent({
2564
2818
  let heldPressTimer;
2565
2819
  let heldPress = null;
2566
2820
  const floatingFormattingMode = () => props.toolbarMode !== "sticky" && !(props.toolbarMode === "popover" && props.toolbarOpen) && !mobile.value;
2567
- const formattingPickerOpen = () => linkOpen.value || tablePickerOpen.value || formatOpen.value;
2821
+ const formattingPickerOpen = () => linkState.value.surface === "popover" || tablePickerOpen.value || formatOpen.value;
2568
2822
  const cancelHeldPress = () => {
2569
2823
  clearTimeout(heldPressTimer);
2570
2824
  heldPressTimer = void 0;
@@ -2753,7 +3007,7 @@ var LoomaEditor = defineComponent({
2753
3007
  if (view.isDestroyed) return;
2754
3008
  editorStateVersion.value += 1;
2755
3009
  const instance = editor.value;
2756
- 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") {
2757
3011
  linkPopup?.setAnchor(() => link.getBoundingClientRect());
2758
3012
  linkPopup?.show();
2759
3013
  }
@@ -2776,7 +3030,6 @@ var LoomaEditor = defineComponent({
2776
3030
  if (!heldFormattingSelection?.eq(selection)) heldFormattingSelection = null;
2777
3031
  if (!formattingSelectionActive(instance)) formattingPopup?.hide();
2778
3032
  }
2779
- if (linkContextEditing.value && instance.state.selection.from !== linkSelection?.from) linkContextEditing.value = false;
2780
3033
  },
2781
3034
  onUpdate: ({ editor: instance }) => {
2782
3035
  const value = instance.getJSON();
@@ -2788,6 +3041,16 @@ var LoomaEditor = defineComponent({
2788
3041
  editorStateVersion.value += 1;
2789
3042
  });
2790
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;
2791
3054
  });
2792
3055
  const attachDocument = (element) => {
2793
3056
  if (element instanceof HTMLElement) session?.attach(element);
@@ -2795,56 +3058,9 @@ var LoomaEditor = defineComponent({
2795
3058
  onBeforeUnmount(() => {
2796
3059
  const element = session?.editor.options.element;
2797
3060
  element?.parentNode?.replaceChild(element.cloneNode(true), element);
3061
+ linkEditing?.destroy();
2798
3062
  session?.destroy();
2799
3063
  });
2800
- watch(() => {
2801
- editorStateVersion.value;
2802
- return editor.value?.isActive("link") ? editor.value.getAttributes("link").href : null;
2803
- }, async (href) => {
2804
- const sequence = ++linkResolveSequence;
2805
- linkContextTarget.value = null;
2806
- if (!href || !props.linkResolve) return;
2807
- if (linkTargetCache.has(href)) {
2808
- linkContextTarget.value = linkTargetCache.get(href) ?? null;
2809
- return;
2810
- }
2811
- try {
2812
- const target = await props.linkResolve(href);
2813
- linkTargetCache.set(href, target);
2814
- if (sequence === linkResolveSequence) linkContextTarget.value = target;
2815
- } catch {}
2816
- });
2817
- watch([
2818
- linkQuery,
2819
- linkMode,
2820
- linkOpen,
2821
- linkContextEditing
2822
- ], async ([query, mode, open, contextEditing]) => {
2823
- const sequence = ++linkSearchSequence;
2824
- linkResults.value = [];
2825
- linkSearching.value = false;
2826
- if (!props.linkSearch || mode !== "target" || !open && !contextEditing || query.trim().length < 2 || linkSelectedTarget.value && query === linkSelectedTarget.value.label) return;
2827
- linkSearching.value = true;
2828
- try {
2829
- const results = await props.linkSearch(query.trim());
2830
- if (sequence === linkSearchSequence) linkResults.value = results.filter((target) => validLinkHref(target.href));
2831
- } catch {
2832
- if (sequence === linkSearchSequence) linkError.value = "Search is unavailable. Try again.";
2833
- } finally {
2834
- if (sequence === linkSearchSequence) linkSearching.value = false;
2835
- }
2836
- });
2837
- const selectLinkTarget = (target) => {
2838
- linkSelectedTarget.value = target;
2839
- linkTargetCache.set(target.href, target);
2840
- linkHref.value = target.href;
2841
- linkQuery.value = target.label;
2842
- linkResults.value = [];
2843
- linkNewTab.value = false;
2844
- linkError.value = "";
2845
- if (linkTextEditable.value && !linkText.value.trim()) linkText.value = target.label;
2846
- applyLink();
2847
- };
2848
3064
  const openLinkEditor = (context = false) => {
2849
3065
  const instance = editor.value;
2850
3066
  if (!instance || !props.editable) return;
@@ -2854,130 +3070,37 @@ var LoomaEditor = defineComponent({
2854
3070
  linkPressedSelection = null;
2855
3071
  const from = selection?.from ?? current.from;
2856
3072
  const to = selection?.to ?? current.to;
2857
- const empty = from === to;
2858
- const existing = instance.isActive("link");
2859
- linkSelection = {
2860
- from,
2861
- to,
2862
- existing
2863
- };
2864
- linkTextEditable.value = empty && !existing;
2865
- const attrs = existing ? instance.getAttributes("link") : {};
2866
- linkHref.value = typeof attrs.href === "string" ? attrs.href : "";
2867
- linkNewTab.value = attrs.target !== "_self";
2868
- linkText.value = empty && !existing ? "" : instance.state.doc.textBetween(from, to);
2869
- linkError.value = "";
2870
- linkSelectedTarget.value = linkTargetCache.get(linkHref.value) ?? null;
2871
- linkMode.value = props.linkSearch && (!existing || linkSelectedTarget.value) ? "target" : "url";
2872
- linkQuery.value = linkSelectedTarget.value?.label ?? linkHref.value;
2873
- linkContextEditing.value = context;
2874
- linkOpen.value = !context;
2875
- if (existing && props.linkResolve && !linkTargetCache.has(linkHref.value)) {
2876
- const href = linkHref.value;
2877
- props.linkResolve(href).then((target) => {
2878
- linkTargetCache.set(href, target);
2879
- if (linkHref.value === href && target) {
2880
- linkSelectedTarget.value = target;
2881
- linkMode.value = "target";
2882
- linkQuery.value = target.label;
2883
- }
2884
- }).catch(() => {});
2885
- }
3073
+ linkEditing?.open(from, to, context ? "context" : "popover");
2886
3074
  nextTick(() => {
2887
3075
  if (context) (props.toolbarMode === "contextual" && floatingFormattingMode() && formattingSelectionActive(instance) ? formattingPopup : linkPopup)?.setAnchor(() => posToDOMRect(instance.view, instance.state.selection.from, instance.state.selection.to));
2888
- (linkMode.value === "url" ? linkDestinationInput() : linkFormRoot.value?.querySelector("[data-link-search]"))?.focus();
3076
+ (linkState.value.mode === "url" ? linkDestinationInput() : linkFormRoot.value?.querySelector("[data-link-search]"))?.focus();
2889
3077
  });
2890
3078
  };
2891
- const applyLink = (reportError = false) => {
2892
- const instance = editor.value;
2893
- const selection = linkSelection;
2894
- if (!instance || !selection) return false;
2895
- const href = linkMode.value === "target" ? validLinkHref(linkSelectedTarget.value?.href ?? "") : validLinkHref(linkHref.value);
2896
- if (!href) {
2897
- if (reportError) linkError.value = linkMode.value === "target" ? "Choose a destination from the results." : "Enter an http, https, mailto, tel, or relative URL.";
2898
- return false;
2899
- }
2900
- if (linkMode.value === "url" && props.linkSearch && !selection.existing && !/^(https?:|mailto:|tel:)/i.test(href)) {
2901
- if (reportError) linkError.value = "Enter a full website, email, or phone URL, or choose a destination.";
2902
- return false;
2903
- }
2904
- const attrs = {
2905
- href: props.linkBaseUrl ? siteRelativeHref(href, props.linkBaseUrl) : href,
2906
- target: linkNewTab.value ? "_blank" : "_self",
2907
- rel: linkNewTab.value ? "noopener noreferrer" : null
2908
- };
2909
- const current = instance.state.selection;
2910
- const chain = instance.chain().setTextSelection({
2911
- from: selection.from,
2912
- to: selection.to
2913
- });
2914
- if (linkTextEditable.value) {
2915
- const text = linkText.value.trim();
2916
- if (!text) {
2917
- if (reportError) linkError.value = "Enter link text.";
2918
- return false;
2919
- }
2920
- linkSelection = {
2921
- from: selection.from,
2922
- to: selection.from + text.length,
2923
- existing: true
2924
- };
2925
- chain.insertContent({
2926
- type: "text",
2927
- text,
2928
- marks: [{
2929
- type: "link",
2930
- attrs
2931
- }]
2932
- }).setTextSelection(selection.from).run();
2933
- } else {
2934
- if (selection.existing && selection.from === selection.to) chain.extendMarkRange("link");
2935
- chain.setLink(attrs).setTextSelection({
2936
- from: current.from,
2937
- to: current.to
2938
- }).run();
2939
- selection.existing = true;
2940
- }
2941
- linkError.value = "";
2942
- return true;
2943
- };
2944
- const removeCurrentLink = () => {
2945
- const instance = editor.value;
2946
- if (!instance?.isActive("link")) return;
2947
- instance.chain().focus().extendMarkRange("link").unsetLink().run();
2948
- linkContextEditing.value = false;
2949
- };
2950
3079
  const dismissLink = (restoreFocus = true) => {
2951
- linkOpen.value = false;
2952
- linkContextEditing.value = false;
2953
- const selection = linkSelection;
2954
- 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();
2955
3083
  dismissedFormattingSelection = editor.value?.state.selection ?? null;
2956
3084
  heldFormattingSelection = null;
2957
3085
  linkPopup?.hide();
2958
3086
  formattingPopup?.hide();
2959
3087
  };
2960
- const linkStatus = () => {
2961
- if (linkSearching.value) return "Searching…";
2962
- if (linkSelectedTarget.value) return "";
2963
- if (linkQuery.value.trim().length < 2) return "Type at least two letters to search.";
2964
- return linkResults.value.length || linkError.value ? "" : "No results found.";
2965
- };
2966
3088
  const renderLinkForm = () => {
2967
- const shown = linkSelectedTarget.value ? [linkSelectedTarget.value] : linkResults.value;
3089
+ const state = linkState.value;
3090
+ const shown = state.selected ? [state.selected] : state.results;
2968
3091
  return h(EditorLinkForm, {
2969
3092
  ref: setLinkFormRoot,
2970
3093
  class: "looma-editor__link-form",
2971
- existing: Boolean(linkSelection?.existing),
3094
+ existing: Boolean(state.range?.existing),
2972
3095
  searchable: Boolean(props.linkSearch),
2973
- mode: linkMode.value,
2974
- value: props.linkSearch ? linkQuery.value : linkHref.value,
3096
+ mode: state.mode,
3097
+ value: props.linkSearch ? state.query : state.href,
2975
3098
  placeholder: props.linkSearch ? `Search ${props.linkTargetLabel.toLowerCase()}s or paste a URL…` : "https://example.com",
2976
- newTab: linkNewTab.value,
2977
- textEditable: linkTextEditable.value,
2978
- text: linkText.value,
2979
- error: linkError.value,
2980
- status: linkStatus(),
3099
+ newTab: state.newTab,
3100
+ textEditable: state.textEditable,
3101
+ text: state.text,
3102
+ error: state.error,
3103
+ status: linkFormStatus(state),
2981
3104
  results: shown.map(({ id, label, detail }) => detail ? {
2982
3105
  id,
2983
3106
  label,
@@ -2986,46 +3109,23 @@ var LoomaEditor = defineComponent({
2986
3109
  id,
2987
3110
  label
2988
3111
  }),
2989
- selected: linkSelectedTarget.value?.id ?? "",
2990
- onDestination: (event) => {
2991
- const { value } = event.detail;
2992
- if (props.linkSearch) {
2993
- linkQuery.value = value;
2994
- linkMode.value = /^(https?:\/\/|mailto:|tel:)/i.test(value.trim()) || linkSelection?.existing && /^(\/|\.\.?\/|#|\?)/.test(value.trim()) ? "url" : "target";
2995
- linkSelectedTarget.value = null;
2996
- }
2997
- linkHref.value = value;
2998
- linkError.value = "";
2999
- applyLink();
3000
- },
3001
- onClear: () => {
3002
- linkQuery.value = "";
3003
- linkHref.value = "";
3004
- linkMode.value = "target";
3005
- linkSelectedTarget.value = null;
3006
- linkError.value = "";
3007
- },
3112
+ selected: state.selected?.id ?? "",
3113
+ onDestination: (event) => linkEditing?.setDestination(event.detail.value),
3114
+ onClear: () => linkEditing?.clear(),
3008
3115
  onChoose: (event) => {
3009
3116
  const target = shown.find(({ id }) => id === event.detail.id);
3010
- if (target) selectLinkTarget(target);
3011
- },
3012
- onNewTab: (event) => {
3013
- linkNewTab.value = event.detail.newTab;
3014
- applyLink();
3015
- },
3016
- onText: (event) => {
3017
- linkText.value = event.detail.text;
3018
- linkError.value = "";
3019
- applyLink();
3117
+ if (target) linkEditing?.choose(target);
3020
3118
  },
3119
+ onNewTab: (event) => linkEditing?.setNewTab(event.detail.newTab),
3120
+ onText: (event) => linkEditing?.setText(event.detail.text),
3021
3121
  onSave: () => {
3022
- if (applyLink(true)) dismissLink();
3122
+ if (linkEditing?.apply(true)) dismissLink();
3023
3123
  },
3024
3124
  onDismiss: () => dismissLink()
3025
3125
  });
3026
3126
  };
3027
3127
  const renderLinkContextCard = (instance) => {
3028
- if (linkContextEditing.value) return h(Card, {
3128
+ if (linkState.value.surface === "context") return h(Card, {
3029
3129
  variant: "elevated",
3030
3130
  padding: "sm",
3031
3131
  size: "sm",
@@ -3033,7 +3133,7 @@ var LoomaEditor = defineComponent({
3033
3133
  }, () => [renderLinkForm()]);
3034
3134
  const rawHref = instance.getAttributes("link").href;
3035
3135
  const href = typeof rawHref === "string" ? rawHref : "";
3036
- const target = linkContextTarget.value;
3136
+ const target = linkState.value.current;
3037
3137
  return h(EditorLinkCard, {
3038
3138
  class: "looma-editor__link-context",
3039
3139
  href,
@@ -3042,7 +3142,7 @@ var LoomaEditor = defineComponent({
3042
3142
  detail: target?.detail ?? "",
3043
3143
  targetLabel: props.linkTargetLabel,
3044
3144
  onEdit: () => openLinkEditor(true),
3045
- onRemove: removeCurrentLink
3145
+ onRemove: () => linkEditing?.remove()
3046
3146
  });
3047
3147
  };
3048
3148
  const runBlockAction = (value) => {
@@ -3087,11 +3187,11 @@ var LoomaEditor = defineComponent({
3087
3187
  if (!editable) chipOpen.value = false;
3088
3188
  });
3089
3189
  watch([
3090
- linkOpen,
3190
+ () => linkState.value.surface === "popover",
3091
3191
  tablePickerOpen,
3092
3192
  formatOpen
3093
- ], ([link, table, format]) => {
3094
- 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;
3095
3195
  heldFormattingSelection = null;
3096
3196
  formattingPopup?.hide();
3097
3197
  });
@@ -3194,10 +3294,10 @@ var LoomaEditor = defineComponent({
3194
3294
  }))
3195
3295
  ];
3196
3296
  });
3197
- const chooseCodeLanguage = (detail) => {
3297
+ const chooseCodeLanguage = (value) => {
3198
3298
  const instance = editor.value;
3199
- if (!instance || detail.kind !== "selection" || !detail.value) return;
3200
- const language = detail.value === "auto" ? null : detail.value;
3299
+ if (!instance) return;
3300
+ const language = value === "auto" ? null : value;
3201
3301
  if (language !== null && !codeLanguageNames.includes(language)) return;
3202
3302
  const node = instance.state.doc.nodeAt(codeUi.pos);
3203
3303
  if (node?.type.name !== "codeBlock") return;
@@ -3336,7 +3436,7 @@ var LoomaEditor = defineComponent({
3336
3436
  const onDocumentPointerDown = (event) => {
3337
3437
  const path = typeof event.composedPath === "function" ? event.composedPath() : [];
3338
3438
  const inElement = (element) => Boolean(element && (path.includes(element) || event.target instanceof Node && element.contains(event.target)));
3339
- if ((linkOpen.value || linkContextEditing.value) && !inElement(linkFormRoot.value)) dismissLink(false);
3439
+ if (linkState.value.surface !== "none" && !inElement(linkFormRoot.value)) dismissLink(false);
3340
3440
  const inTableUi = inElement(tableToolbarShell.value) || inElement(tableOverlayShell.value) || inElement(tableMenuShell.value) || inElement(mobileToolbarShell.value);
3341
3441
  tableInteractionActive = inTableUi;
3342
3442
  if (inTableUi) setTimeout(() => {
@@ -3402,7 +3502,7 @@ var LoomaEditor = defineComponent({
3402
3502
  editor: instance,
3403
3503
  element,
3404
3504
  pluginKey: "looma-text-formatting-menu",
3405
- 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),
3406
3506
  boundary: () => props.toolbarMode === "contextual" ? root.value : null,
3407
3507
  related: editorRoot,
3408
3508
  onHide: () => instance.isEditable && floatingFormattingMode() && formattingPickerOpen() && formattingSelectionActive(instance) ? false : void 0,
@@ -3414,7 +3514,7 @@ var LoomaEditor = defineComponent({
3414
3514
  editor: instance,
3415
3515
  element,
3416
3516
  pluginKey: "looma-link-context-menu",
3417
- 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)),
3418
3518
  maxWidth: () => Math.min(480, window.innerWidth - 24),
3419
3519
  related: editorRoot,
3420
3520
  onDismiss: (reason) => reason === "escape" ? dismissFormatting() : linkPopup?.hide()
@@ -3832,7 +3932,7 @@ var LoomaEditor = defineComponent({
3832
3932
  label: "Link",
3833
3933
  size: "sm",
3834
3934
  variant: "ghost",
3835
- "aria-expanded": linkOpen.value ? "true" : "false",
3935
+ "aria-expanded": linkState.value.surface === "popover" ? "true" : "false",
3836
3936
  "aria-pressed": instance.isActive("link") ? "true" : "false",
3837
3937
  "data-active": instance.isActive("link") ? "true" : "false",
3838
3938
  onPointerenter: () => showTool(linkTriggerId, "Link", false),
@@ -4016,7 +4116,7 @@ var LoomaEditor = defineComponent({
4016
4116
  ref: formattingSurfaceRef,
4017
4117
  class: "looma-editor__floating-shell looma-editor__formatting-shell",
4018
4118
  hidden: true
4019
- }, [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,
4020
4120
  instance && props.editable ? h("div", { key: "looma-link-host" }, [h("div", {
4021
4121
  ref: linkSurfaceRef,
4022
4122
  class: "looma-editor__floating-shell looma-editor__link-shell",
@@ -4049,16 +4149,12 @@ var LoomaEditor = defineComponent({
4049
4149
  class: "looma-editor__code-language",
4050
4150
  style: codeUi.style,
4051
4151
  onFocusout: () => updateCodeUi()
4052
- }, [h(Combobox, {
4053
- label: "Code language",
4054
- labelVisibility: "sr-only",
4055
- size: "sm",
4056
- disclosure: true,
4057
- filter: "label",
4152
+ }, [h(EditorCodeLanguage, {
4058
4153
  value: codeUi.language,
4059
- onValueChange: (event) => chooseCodeLanguage(event.detail)
4060
- }, () => codeLanguageOptions.value.map((option) => h("option", { value: option.value }, option.label)))]) : null,
4061
- instance && (props.editable || props.selectionActions.length && !instance.state.selection.empty || activeRangeActions(instance).length > 0) && mobile.value && (editorFocused.value || linkOpen.value || !props.editable) ? h("div", {
4154
+ languages: codeLanguageOptions.value,
4155
+ onLanguage: (event) => chooseCodeLanguage(event.detail.value)
4156
+ })]) : null,
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", {
4062
4158
  key: "looma-mobile-toolbar-shell",
4063
4159
  ref: mobileToolbarRef,
4064
4160
  class: "looma-editor__mobile-toolbar-shell",
@@ -4153,17 +4249,18 @@ var LoomaEditor = defineComponent({
4153
4249
  h(Popover, {
4154
4250
  class: "looma-editor__link-popover",
4155
4251
  size: "sm",
4156
- open: linkOpen.value,
4252
+ open: linkState.value.surface === "popover",
4157
4253
  for: linkPopoverAnchorId.value,
4158
4254
  placement: mobile.value ? "top-start" : "bottom-start",
4159
4255
  onOpen: () => openLinkEditor(),
4160
4256
  onClose: () => {
4161
- linkOpen.value = false;
4257
+ if (linkState.value.surface === "popover") linkEditing?.close();
4162
4258
  linkPressedSelection = null;
4163
4259
  const current = editor.value?.state.selection;
4164
- 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);
4165
4262
  }
4166
- }, () => linkOpen.value ? renderLinkForm() : null),
4263
+ }, () => linkState.value.surface === "popover" ? renderLinkForm() : null),
4167
4264
  props.editable && props.compactToolbar && !mobile.value ? h(Popover, {
4168
4265
  class: "looma-editor__compact-formatting",
4169
4266
  for: formatAnchorId,
@@ -4310,4 +4407,4 @@ var LoomaEditor = defineComponent({
4310
4407
  }
4311
4408
  });
4312
4409
  //#endregion
4313
- export { EDITOR_ADAPTER_COMPONENT_TAG_MAP, EditorBlockActions, EditorImageToolbar, EditorInsertTableGrid, EditorLinkCard, EditorLinkForm, EditorMentionMenu, EditorMentionMenuItem, EditorSlashMenu, EditorSlashMenuGroup, EditorSlashMenuItem, EditorTableContextMenu, EditorTableOverlay, EditorTableToolbar, EditorToolbar, LoomaEditor };
4410
+ export { EDITOR_ADAPTER_COMPONENT_TAG_MAP, EditorBlockActions, EditorCodeLanguage, EditorImageToolbar, EditorInsertTableGrid, EditorLinkCard, EditorLinkForm, EditorMentionMenu, EditorMentionMenuItem, EditorSlashMenu, EditorSlashMenuGroup, EditorSlashMenuItem, EditorTableContextMenu, EditorTableOverlay, EditorTableToolbar, EditorToolbar, LoomaEditor };