@threadlabs/looma 0.22.10 → 0.22.12

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.
@@ -26,6 +26,25 @@ export type LoomaImageUploader = (file: File) => Promise<string | LoomaImageUplo
26
26
  * bubble for selections); it does not alter document commands or stored content.
27
27
  */
28
28
  export type LoomaEditorToolbarMode = "bubble" | "sticky" | "popover";
29
+ /** A destination supplied by the host application, such as a page or record. */
30
+ export interface LoomaLinkTarget {
31
+ id: string;
32
+ label: string;
33
+ detail?: string;
34
+ href: string;
35
+ }
36
+ /**
37
+ * Finds host records for a link without teaching the editor how they are stored.
38
+ * @ownership The host checks access and returns durable, openable href values.
39
+ * @failure A rejection leaves the existing document untouched and shows a retryable search error.
40
+ */
41
+ export type LoomaLinkSearch = (query: string) => Promise<LoomaLinkTarget[]>;
42
+ /**
43
+ * Describes a saved host link so a renamed record can show its current label.
44
+ * @ownership The host resolves identity and access; Looma keeps the saved href as is.
45
+ * @failure A rejection leaves the original href visible and editable.
46
+ */
47
+ export type LoomaLinkResolve = (href: string) => Promise<LoomaLinkTarget | null>;
29
48
  /**
30
49
  * Turnkey Vue editor that composes Looma's framework-neutral Tiptap extensions
31
50
  * with declarative menus, image delivery, and table interaction chrome.
@@ -88,6 +107,19 @@ export declare const LoomaEditor: import("vue").DefineComponent<import("vue").Ex
88
107
  type: NumberConstructor;
89
108
  default: number;
90
109
  };
110
+ /** Host-owned search and identity lookup for links to application records. */
111
+ linkSearch: {
112
+ type: PropType<LoomaLinkSearch | undefined>;
113
+ default: undefined;
114
+ };
115
+ linkResolve: {
116
+ type: PropType<LoomaLinkResolve | undefined>;
117
+ default: undefined;
118
+ };
119
+ linkTargetLabel: {
120
+ type: StringConstructor;
121
+ default: string;
122
+ };
91
123
  uploadImage: {
92
124
  type: PropType<LoomaImageUploader | undefined>;
93
125
  default: undefined;
@@ -181,6 +213,19 @@ export declare const LoomaEditor: import("vue").DefineComponent<import("vue").Ex
181
213
  type: NumberConstructor;
182
214
  default: number;
183
215
  };
216
+ /** Host-owned search and identity lookup for links to application records. */
217
+ linkSearch: {
218
+ type: PropType<LoomaLinkSearch | undefined>;
219
+ default: undefined;
220
+ };
221
+ linkResolve: {
222
+ type: PropType<LoomaLinkResolve | undefined>;
223
+ default: undefined;
224
+ };
225
+ linkTargetLabel: {
226
+ type: StringConstructor;
227
+ default: string;
228
+ };
184
229
  uploadImage: {
185
230
  type: PropType<LoomaImageUploader | undefined>;
186
231
  default: undefined;
@@ -233,6 +278,9 @@ export declare const LoomaEditor: import("vue").DefineComponent<import("vue").Ex
233
278
  mentionItems: LoomaMentionItem[];
234
279
  mentionProvider: LoomaMentionProvider | undefined;
235
280
  mentionLimit: number;
281
+ linkSearch: LoomaLinkSearch | undefined;
282
+ linkResolve: LoomaLinkResolve | undefined;
283
+ linkTargetLabel: string;
236
284
  uploadImage: LoomaImageUploader | undefined;
237
285
  resolveImageAttributes: LoomaImageAttributeResolver | undefined;
238
286
  toolbarMode: LoomaEditorToolbarMode;
@@ -1,4 +1,4 @@
1
1
  export * from "@threadlabs/looma/editor";
2
2
  export * from "./primitives.js";
3
- export { LoomaEditor, type LoomaEditorToolbarMode, type LoomaImageUploader, type LoomaImageUploadResult, } from "./LoomaEditor.js";
3
+ export { LoomaEditor, type LoomaEditorToolbarMode, type LoomaLinkTarget, type LoomaLinkSearch, type LoomaLinkResolve, type LoomaImageUploader, type LoomaImageUploadResult, } from "./LoomaEditor.js";
4
4
  export type { LoomaImageActivationDetail, LoomaImageActivationTrigger, LoomaImageAttributeResolver, LoomaImageDescriptor, LoomaImageRenditionErrorDetail, LoomaImageRenditionAttributes, } from "./image-delivery.js";
@@ -23,6 +23,11 @@ var EDITOR_ADAPTER_COMPONENT_TAG_MAP = {
23
23
  EditorTableOverlay: "ui-editor-table-overlay"
24
24
  };
25
25
  //#endregion
26
+ //#region src/components/shared/overlay.js
27
+ function announceOverlayOpen(document, id) {
28
+ document.dispatchEvent(new CustomEvent("ui-overlay-open", { detail: { id } }));
29
+ }
30
+ //#endregion
26
31
  //#region src/vue/editor/image-delivery.ts
27
32
  function optionalString(value) {
28
33
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -557,6 +562,19 @@ var LoomaEditor = defineComponent({
557
562
  type: Number,
558
563
  default: DEFAULT_MENTION_RESULT_LIMIT
559
564
  },
565
+ /** Host-owned search and identity lookup for links to application records. */
566
+ linkSearch: {
567
+ type: Function,
568
+ default: void 0
569
+ },
570
+ linkResolve: {
571
+ type: Function,
572
+ default: void 0
573
+ },
574
+ linkTargetLabel: {
575
+ type: String,
576
+ default: "Item"
577
+ },
560
578
  uploadImage: {
561
579
  type: Function,
562
580
  default: void 0
@@ -608,12 +626,22 @@ var LoomaEditor = defineComponent({
608
626
  let blockActionTarget = null;
609
627
  const linkAnchorId = `looma-editor-link-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
610
628
  const linkUrlInput = ref(null);
629
+ const linkFormRoot = ref(null);
611
630
  const linkOpen = ref(false);
612
631
  const linkContextEditing = ref(false);
613
632
  const linkHref = ref("");
614
633
  const linkText = ref("");
615
634
  const linkNewTab = ref(true);
616
635
  const linkError = ref("");
636
+ const linkMode = ref("url");
637
+ const linkQuery = ref("");
638
+ const linkResults = ref([]);
639
+ const linkSearching = ref(false);
640
+ const linkSelectedTarget = ref(null);
641
+ const linkContextTarget = ref(null);
642
+ const linkTargetCache = /* @__PURE__ */ new Map();
643
+ let linkSearchSequence = 0;
644
+ let linkResolveSequence = 0;
617
645
  const chipAnchorId = `looma-editor-chip-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
618
646
  const chipInput = ref(null);
619
647
  const chipOpen = ref(false);
@@ -857,6 +885,63 @@ var LoomaEditor = defineComponent({
857
885
  emit("update", value);
858
886
  }
859
887
  });
888
+ watch(() => {
889
+ editorStateVersion.value;
890
+ return editor.value?.isActive("link") ? editor.value.getAttributes("link").href : null;
891
+ }, async (href) => {
892
+ const sequence = ++linkResolveSequence;
893
+ linkContextTarget.value = null;
894
+ if (!href || !props.linkResolve) return;
895
+ if (linkTargetCache.has(href)) {
896
+ linkContextTarget.value = linkTargetCache.get(href) ?? null;
897
+ return;
898
+ }
899
+ try {
900
+ const target = await props.linkResolve(href);
901
+ linkTargetCache.set(href, target);
902
+ if (sequence === linkResolveSequence) linkContextTarget.value = target;
903
+ } catch {}
904
+ });
905
+ watch([
906
+ linkQuery,
907
+ linkMode,
908
+ linkOpen,
909
+ linkContextEditing
910
+ ], async ([query, mode, open, contextEditing]) => {
911
+ const sequence = ++linkSearchSequence;
912
+ linkResults.value = [];
913
+ linkSearching.value = false;
914
+ if (!props.linkSearch || mode !== "target" || !open && !contextEditing || query.trim().length < 2 || linkSelectedTarget.value && query === linkSelectedTarget.value.label) return;
915
+ linkSearching.value = true;
916
+ try {
917
+ const results = await props.linkSearch(query.trim());
918
+ if (sequence === linkSearchSequence) linkResults.value = results.filter((target) => validLinkHref(target.href));
919
+ } catch {
920
+ if (sequence === linkSearchSequence) linkError.value = "Search is unavailable. Try again.";
921
+ } finally {
922
+ if (sequence === linkSearchSequence) linkSearching.value = false;
923
+ }
924
+ });
925
+ const selectLinkTarget = (target) => {
926
+ linkSelectedTarget.value = target;
927
+ linkTargetCache.set(target.href, target);
928
+ linkHref.value = target.href;
929
+ linkQuery.value = target.label;
930
+ linkResults.value = [];
931
+ linkNewTab.value = false;
932
+ linkError.value = "";
933
+ };
934
+ const moveLinkResultFocus = (event, offset) => {
935
+ const buttons = [...linkFormRoot.value?.querySelectorAll("[data-link-result]") ?? []];
936
+ const next = buttons[buttons.indexOf(event.target) + offset];
937
+ if (next) {
938
+ event.preventDefault();
939
+ next.focus();
940
+ } else if (offset < 0) {
941
+ event.preventDefault();
942
+ linkFormRoot.value?.querySelector("[data-link-search]")?.focus();
943
+ }
944
+ };
860
945
  const openLinkEditor = (context = false) => {
861
946
  const instance = editor.value;
862
947
  if (!instance || !props.editable) return;
@@ -878,17 +963,36 @@ var LoomaEditor = defineComponent({
878
963
  linkNewTab.value = attrs.target !== "_self";
879
964
  linkText.value = empty && !existing ? "" : instance.state.doc.textBetween(from, to);
880
965
  linkError.value = "";
966
+ linkSelectedTarget.value = linkTargetCache.get(linkHref.value) ?? null;
967
+ linkMode.value = props.linkSearch && (!existing || linkSelectedTarget.value) ? "target" : "url";
968
+ linkQuery.value = linkSelectedTarget.value?.label ?? "";
881
969
  linkContextEditing.value = context;
882
970
  linkOpen.value = !context;
883
- nextTick(() => linkUrlInput.value?.focus());
971
+ if (existing && props.linkResolve && !linkTargetCache.has(linkHref.value)) {
972
+ const href = linkHref.value;
973
+ props.linkResolve(href).then((target) => {
974
+ linkTargetCache.set(href, target);
975
+ if (linkHref.value === href && target) {
976
+ linkSelectedTarget.value = target;
977
+ linkMode.value = "target";
978
+ linkQuery.value = target.label;
979
+ }
980
+ }).catch(() => {});
981
+ }
982
+ nextTick(() => (linkMode.value === "url" ? linkUrlInput.value : linkFormRoot.value?.querySelector("[data-link-search]"))?.focus());
884
983
  };
885
984
  const saveLink = () => {
886
985
  const instance = editor.value;
887
986
  const selection = linkSelection;
888
987
  if (!instance || !selection) return;
889
- const href = validLinkHref(linkHref.value);
988
+ const href = linkMode.value === "target" ? validLinkHref(linkSelectedTarget.value?.href ?? "") : validLinkHref(linkHref.value);
890
989
  if (!href) {
891
- linkError.value = "Enter an http, https, mailto, tel, or relative URL.";
990
+ linkError.value = linkMode.value === "target" ? "Choose a destination from the results." : "Enter an http, https, mailto, tel, or relative URL.";
991
+ (linkMode.value === "url" ? linkUrlInput.value : linkFormRoot.value?.querySelector("[data-link-search]"))?.focus();
992
+ return;
993
+ }
994
+ if (linkMode.value === "url" && props.linkSearch && !selection.existing && !/^(https?:|mailto:|tel:)/i.test(href)) {
995
+ linkError.value = "Enter a full website, email, or phone URL, or choose a destination.";
892
996
  linkUrlInput.value?.focus();
893
997
  return;
894
998
  }
@@ -940,6 +1044,7 @@ var LoomaEditor = defineComponent({
940
1044
  linkContextEditing.value = false;
941
1045
  };
942
1046
  const renderLinkForm = () => h("form", {
1047
+ ref: linkFormRoot,
943
1048
  class: "looma-editor__link-form",
944
1049
  "aria-label": "Edit link",
945
1050
  onSubmit: (event) => {
@@ -954,7 +1059,47 @@ var LoomaEditor = defineComponent({
954
1059
  linkError.value = "";
955
1060
  }
956
1061
  })]) : null,
957
- h("label", [h("span", "URL"), h("input", {
1062
+ props.linkSearch ? h("div", {
1063
+ class: "looma-editor__link-modes",
1064
+ role: "group",
1065
+ "aria-label": "Link destination"
1066
+ }, [h("button", {
1067
+ type: "button",
1068
+ "aria-pressed": linkMode.value === "target",
1069
+ onClick: () => {
1070
+ linkMode.value = "target";
1071
+ linkHref.value = "";
1072
+ linkSelectedTarget.value = null;
1073
+ linkQuery.value = "";
1074
+ linkError.value = "";
1075
+ nextTick(() => linkFormRoot.value?.querySelector("[data-link-search]")?.focus());
1076
+ }
1077
+ }, props.linkTargetLabel), h("button", {
1078
+ type: "button",
1079
+ "aria-pressed": linkMode.value === "url",
1080
+ onClick: () => {
1081
+ linkMode.value = "url";
1082
+ linkHref.value = "";
1083
+ linkSelectedTarget.value = null;
1084
+ linkError.value = "";
1085
+ nextTick(() => linkUrlInput.value?.focus());
1086
+ }
1087
+ }, "URL")]) : null,
1088
+ linkMode.value === "target" && props.linkSearch ? h("label", [h("span", `Find a ${props.linkTargetLabel.toLowerCase()}`), h("input", {
1089
+ "data-link-search": "",
1090
+ type: "search",
1091
+ value: linkQuery.value,
1092
+ autocomplete: "off",
1093
+ "aria-invalid": linkError.value ? "true" : void 0,
1094
+ onInput: (event) => {
1095
+ linkQuery.value = event.target.value;
1096
+ linkSelectedTarget.value = null;
1097
+ linkError.value = "";
1098
+ },
1099
+ onKeydown: (event) => {
1100
+ if (event.key === "ArrowDown") moveLinkResultFocus(event, 1);
1101
+ }
1102
+ })]) : h("label", [h("span", "URL"), h("input", {
958
1103
  ref: linkUrlInput,
959
1104
  type: "text",
960
1105
  inputmode: "url",
@@ -965,7 +1110,25 @@ var LoomaEditor = defineComponent({
965
1110
  linkError.value = "";
966
1111
  }
967
1112
  })]),
968
- h("label", { class: "looma-editor__link-new-tab" }, [h("input", {
1113
+ linkMode.value === "target" && props.linkSearch ? h("div", {
1114
+ class: "looma-editor__link-results",
1115
+ "aria-label": "Destination search results"
1116
+ }, [
1117
+ linkSearching.value ? h("p", { role: "status" }, "Searching…") : null,
1118
+ linkSelectedTarget.value ? h("p", { class: "looma-editor__link-selected" }, `Selected: ${linkSelectedTarget.value.label}${linkSelectedTarget.value.detail ? ` · ${linkSelectedTarget.value.detail}` : ""}`) : null,
1119
+ !linkSearching.value && !linkSelectedTarget.value && linkQuery.value.trim().length < 2 ? h("p", "Type at least two letters to search.") : null,
1120
+ !linkSearching.value && !linkSelectedTarget.value && linkQuery.value.trim().length >= 2 && !linkResults.value.length && !linkError.value ? h("p", "No results found.") : null,
1121
+ ...linkResults.value.map((target) => h("button", {
1122
+ key: target.id,
1123
+ type: "button",
1124
+ "data-link-result": "",
1125
+ onClick: () => selectLinkTarget(target),
1126
+ onKeydown: (event) => {
1127
+ if (event.key === "ArrowDown") moveLinkResultFocus(event, 1);
1128
+ if (event.key === "ArrowUp") moveLinkResultFocus(event, -1);
1129
+ }
1130
+ }, [h("strong", target.label), target.detail ? h("small", target.detail) : null]))
1131
+ ]) : h("label", { class: "looma-editor__link-new-tab" }, [h("input", {
969
1132
  type: "checkbox",
970
1133
  checked: linkNewTab.value,
971
1134
  onChange: (event) => {
@@ -976,7 +1139,7 @@ var LoomaEditor = defineComponent({
976
1139
  class: "looma-editor__link-error",
977
1140
  role: "alert"
978
1141
  }, linkError.value) : null,
979
- validLinkHref(linkHref.value) ? h("a", {
1142
+ linkMode.value === "url" && validLinkHref(linkHref.value) ? h("a", {
980
1143
  class: "looma-editor__link-preview",
981
1144
  href: validLinkHref(linkHref.value),
982
1145
  target: "_blank",
@@ -1000,7 +1163,7 @@ var LoomaEditor = defineComponent({
1000
1163
  h("span", {
1001
1164
  class: "looma-editor__link-context-url",
1002
1165
  title: href
1003
- }, href),
1166
+ }, linkContextTarget.value ? [h("strong", linkContextTarget.value.label), linkContextTarget.value.detail ? h("small", linkContextTarget.value.detail) : null] : href),
1004
1167
  h("button", {
1005
1168
  type: "button",
1006
1169
  onPointerdown: (event) => event.preventDefault(),
@@ -1722,7 +1885,8 @@ var LoomaEditor = defineComponent({
1722
1885
  pluginKey: "looma-text-formatting-menu",
1723
1886
  shouldShow: ({ editor: menuEditor, from, to }) => (props.toolbarMode !== "popover" || !props.toolbarOpen) && shouldShowTextFormattingToolbar(menuEditor, from, to),
1724
1887
  tippyOptions: {
1725
- appendTo: () => root.value ?? document.body,
1888
+ appendTo: () => root.value?.closest("dialog[open], [popover]") ?? document.body,
1889
+ onShow: () => announceOverlayOpen(root.value?.ownerDocument ?? document, root.value),
1726
1890
  duration: 100,
1727
1891
  maxWidth: "none",
1728
1892
  placement: "top"
@@ -1733,7 +1897,8 @@ var LoomaEditor = defineComponent({
1733
1897
  pluginKey: "looma-link-context-menu",
1734
1898
  shouldShow: ({ editor: menuEditor, from, to }) => from === to && menuEditor.isActive("link") && !linkOpen.value,
1735
1899
  tippyOptions: {
1736
- appendTo: () => root.value ?? document.body,
1900
+ appendTo: () => root.value?.closest("dialog[open], [popover]") ?? document.body,
1901
+ onShow: () => announceOverlayOpen(root.value?.ownerDocument ?? document, root.value),
1737
1902
  duration: 100,
1738
1903
  maxWidth: "none",
1739
1904
  placement: "bottom"