@threadlabs/looma 0.22.11 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.22.11",
3
+ "version": "0.22.12",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -9070,18 +9070,26 @@ html[data-ui-input-modality="touch"]
9070
9070
 
9071
9071
  .looma-editor__link-context-actions {
9072
9072
  display: flex;
9073
+ flex-wrap: wrap;
9073
9074
  align-items: center;
9074
9075
  gap: var(--ui-space-1);
9075
9076
  }
9076
9077
 
9077
9078
  .looma-editor__link-context-url {
9078
9079
  min-width: 0;
9079
- max-width: 10rem;
9080
- overflow: hidden;
9080
+ flex: 1 1 100%;
9081
9081
  padding-inline: var(--ui-space-2);
9082
9082
  color: var(--ui-text-secondary);
9083
- text-overflow: ellipsis;
9084
- white-space: nowrap;
9083
+ overflow-wrap: anywhere;
9084
+ font-size: var(--ui-font-size-sm);
9085
+ }
9086
+
9087
+ .looma-editor__link-context-url :is(strong, small) {
9088
+ display: block;
9089
+ }
9090
+
9091
+ .looma-editor__link-context-url strong {
9092
+ color: var(--ui-text-primary);
9085
9093
  }
9086
9094
 
9087
9095
  .looma-editor__link-context-actions :is(button, a) {
@@ -9198,6 +9206,57 @@ html[data-ui-input-modality="touch"]
9198
9206
  align-items: center;
9199
9207
  }
9200
9208
 
9209
+ .looma-editor__link-modes {
9210
+ display: flex;
9211
+ gap: var(--ui-space-1);
9212
+ }
9213
+
9214
+ .looma-editor__link-modes button,
9215
+ .looma-editor__link-results button {
9216
+ min-height: var(--ui-control-min-block-size);
9217
+ padding: var(--ui-space-2);
9218
+ border: 1px solid var(--ui-control-border);
9219
+ border-radius: var(--ui-field-radius, var(--ui-radius-md));
9220
+ background: var(--ui-control-surface);
9221
+ color: var(--ui-text-primary);
9222
+ font: inherit;
9223
+ text-align: left;
9224
+ cursor: pointer;
9225
+ }
9226
+
9227
+ .looma-editor__link-modes button[aria-pressed="true"] {
9228
+ border-color: var(--ui-accent);
9229
+ color: var(--ui-accent);
9230
+ }
9231
+
9232
+ .looma-editor__link-results {
9233
+ display: flex;
9234
+ flex-direction: column;
9235
+ gap: var(--ui-space-1);
9236
+ max-height: 15rem;
9237
+ overflow-y: auto;
9238
+ }
9239
+
9240
+ .looma-editor__link-results p {
9241
+ margin: 0;
9242
+ color: var(--ui-text-secondary);
9243
+ font-size: var(--ui-font-size-sm);
9244
+ }
9245
+
9246
+ .looma-editor__link-results button {
9247
+ display: flex;
9248
+ flex-direction: column;
9249
+ width: 100%;
9250
+ }
9251
+
9252
+ .looma-editor__link-results button:is(:hover, :focus-visible) {
9253
+ background: var(--ui-surface-subtle);
9254
+ }
9255
+
9256
+ .looma-editor__link-results button small {
9257
+ color: var(--ui-text-secondary);
9258
+ }
9259
+
9201
9260
  .looma-editor__link-error {
9202
9261
  margin: 0;
9203
9262
  color: var(--ui-field-danger, var(--ui-danger));
@@ -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";
@@ -562,6 +562,19 @@ var LoomaEditor = defineComponent({
562
562
  type: Number,
563
563
  default: DEFAULT_MENTION_RESULT_LIMIT
564
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
+ },
565
578
  uploadImage: {
566
579
  type: Function,
567
580
  default: void 0
@@ -613,12 +626,22 @@ var LoomaEditor = defineComponent({
613
626
  let blockActionTarget = null;
614
627
  const linkAnchorId = `looma-editor-link-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
615
628
  const linkUrlInput = ref(null);
629
+ const linkFormRoot = ref(null);
616
630
  const linkOpen = ref(false);
617
631
  const linkContextEditing = ref(false);
618
632
  const linkHref = ref("");
619
633
  const linkText = ref("");
620
634
  const linkNewTab = ref(true);
621
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;
622
645
  const chipAnchorId = `looma-editor-chip-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
623
646
  const chipInput = ref(null);
624
647
  const chipOpen = ref(false);
@@ -862,6 +885,63 @@ var LoomaEditor = defineComponent({
862
885
  emit("update", value);
863
886
  }
864
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
+ };
865
945
  const openLinkEditor = (context = false) => {
866
946
  const instance = editor.value;
867
947
  if (!instance || !props.editable) return;
@@ -883,17 +963,36 @@ var LoomaEditor = defineComponent({
883
963
  linkNewTab.value = attrs.target !== "_self";
884
964
  linkText.value = empty && !existing ? "" : instance.state.doc.textBetween(from, to);
885
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 ?? "";
886
969
  linkContextEditing.value = context;
887
970
  linkOpen.value = !context;
888
- 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());
889
983
  };
890
984
  const saveLink = () => {
891
985
  const instance = editor.value;
892
986
  const selection = linkSelection;
893
987
  if (!instance || !selection) return;
894
- const href = validLinkHref(linkHref.value);
988
+ const href = linkMode.value === "target" ? validLinkHref(linkSelectedTarget.value?.href ?? "") : validLinkHref(linkHref.value);
895
989
  if (!href) {
896
- 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.";
897
996
  linkUrlInput.value?.focus();
898
997
  return;
899
998
  }
@@ -945,6 +1044,7 @@ var LoomaEditor = defineComponent({
945
1044
  linkContextEditing.value = false;
946
1045
  };
947
1046
  const renderLinkForm = () => h("form", {
1047
+ ref: linkFormRoot,
948
1048
  class: "looma-editor__link-form",
949
1049
  "aria-label": "Edit link",
950
1050
  onSubmit: (event) => {
@@ -959,7 +1059,47 @@ var LoomaEditor = defineComponent({
959
1059
  linkError.value = "";
960
1060
  }
961
1061
  })]) : null,
962
- 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", {
963
1103
  ref: linkUrlInput,
964
1104
  type: "text",
965
1105
  inputmode: "url",
@@ -970,7 +1110,25 @@ var LoomaEditor = defineComponent({
970
1110
  linkError.value = "";
971
1111
  }
972
1112
  })]),
973
- 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", {
974
1132
  type: "checkbox",
975
1133
  checked: linkNewTab.value,
976
1134
  onChange: (event) => {
@@ -981,7 +1139,7 @@ var LoomaEditor = defineComponent({
981
1139
  class: "looma-editor__link-error",
982
1140
  role: "alert"
983
1141
  }, linkError.value) : null,
984
- validLinkHref(linkHref.value) ? h("a", {
1142
+ linkMode.value === "url" && validLinkHref(linkHref.value) ? h("a", {
985
1143
  class: "looma-editor__link-preview",
986
1144
  href: validLinkHref(linkHref.value),
987
1145
  target: "_blank",
@@ -1005,7 +1163,7 @@ var LoomaEditor = defineComponent({
1005
1163
  h("span", {
1006
1164
  class: "looma-editor__link-context-url",
1007
1165
  title: href
1008
- }, href),
1166
+ }, linkContextTarget.value ? [h("strong", linkContextTarget.value.label), linkContextTarget.value.detail ? h("small", linkContextTarget.value.detail) : null] : href),
1009
1167
  h("button", {
1010
1168
  type: "button",
1011
1169
  onPointerdown: (event) => event.preventDefault(),