@threadlabs/looma 0.1.13 → 0.1.14

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/editor.css CHANGED
@@ -149,10 +149,16 @@
149
149
 
150
150
  .looma-editor .ProseMirror img {
151
151
  max-width: 100%;
152
+ height: auto;
152
153
  margin: var(--ui-space-3) 0;
153
154
  border-radius: var(--ui-radius-2);
154
155
  }
155
156
 
157
+ .looma-editor .ProseMirror img[data-looma-image]:focus-visible {
158
+ outline: 2px solid var(--ui-focus-ring);
159
+ outline-offset: 3px;
160
+ }
161
+
156
162
  .looma-editor__file-input {
157
163
  position: absolute;
158
164
  width: 0;
@@ -161,6 +167,42 @@
161
167
  pointer-events: none;
162
168
  }
163
169
 
170
+ .looma-editor__upload-error {
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: space-between;
174
+ gap: var(--ui-space-3);
175
+ margin-top: var(--ui-space-3);
176
+ padding: var(--ui-space-2) var(--ui-space-3);
177
+ border: 1px solid var(--ui-danger-solid);
178
+ border-radius: var(--ui-radius-2);
179
+ background: var(--ui-danger-soft);
180
+ color: var(--ui-text);
181
+ font-size: var(--ui-font-size-sm);
182
+ }
183
+
184
+ .looma-editor__upload-error button {
185
+ min-height: 2rem;
186
+ padding: 0 var(--ui-space-3);
187
+ border: 0;
188
+ border-radius: var(--ui-radius-1);
189
+ background: var(--ui-danger-solid);
190
+ color: var(--ui-text-on-accent);
191
+ cursor: pointer;
192
+ font: inherit;
193
+ font-weight: var(--ui-font-semibold);
194
+ }
195
+
196
+ .looma-editor__upload-error button:focus-visible {
197
+ outline: 2px solid var(--ui-focus-ring);
198
+ outline-offset: 2px;
199
+ }
200
+
201
+ .looma-editor__upload-error button:disabled {
202
+ cursor: wait;
203
+ opacity: 0.65;
204
+ }
205
+
164
206
  .looma-editor__drop-overlay {
165
207
  position: absolute;
166
208
  z-index: var(--ui-editor-drop-overlay-z-index, 10);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "description": "Looma web components, responsive layout primitives, a complete editor, and supported Vue adapters.",
6
6
  "license": "MIT",
@@ -4,6 +4,7 @@ import * as vue from 'vue';
4
4
  import { PropType } from 'vue';
5
5
  import { TableContextMenuActionEventDetail, InsertTableEventDetail, TableOverlayActionEventDetail, SlashMenuHighlightEventDetail, SlashMenuSelectEventDetail } from '@threadlabs/looma/editor/ui';
6
6
  import { JSONContent, AnyExtension, Editor } from '@tiptap/core';
7
+ import { OverlayTrigger } from '@threadlabs/looma/core';
7
8
 
8
9
  interface VueEditorAdapterEventMap {
9
10
  tableAction: TableContextMenuActionEventDetail;
@@ -87,9 +88,33 @@ declare const EDITOR_ADAPTER_COMPONENT_TAG_MAP: {
87
88
  readonly EditorTableOverlay: "ui-editor-table-overlay";
88
89
  };
89
90
 
90
- interface LoomaImageUploadResult {
91
- url: string;
91
+ interface LoomaImageDescriptor {
92
+ src: string;
92
93
  alt?: string;
94
+ width?: number;
95
+ height?: number;
96
+ responsive?: boolean;
97
+ }
98
+ /** Browser-only attributes resolved by a host image-delivery provider. */
99
+ interface LoomaImageRenditionAttributes {
100
+ src?: string;
101
+ srcset?: string;
102
+ sizes?: string;
103
+ loading?: "eager" | "lazy";
104
+ decoding?: "async" | "auto" | "sync";
105
+ fetchPriority?: "auto" | "high" | "low";
106
+ }
107
+ type LoomaImageAttributeResolver = (image: LoomaImageDescriptor) => LoomaImageRenditionAttributes | undefined;
108
+ type LoomaImageActivationTrigger = Exclude<OverlayTrigger, "programmatic">;
109
+ interface LoomaImageActivationDetail extends LoomaImageDescriptor {
110
+ trigger: LoomaImageActivationTrigger;
111
+ }
112
+ interface LoomaImageRenditionErrorDetail extends LoomaImageDescriptor {
113
+ trigger: "programmatic";
114
+ }
115
+
116
+ interface LoomaImageUploadResult extends Omit<LoomaImageDescriptor, "src"> {
117
+ url: string;
93
118
  }
94
119
  type LoomaImageUploader = (file: File) => Promise<string | LoomaImageUploadResult>;
95
120
  declare const LoomaEditor: vue.DefineComponent<vue.ExtractPropTypes<{
@@ -124,6 +149,10 @@ declare const LoomaEditor: vue.DefineComponent<vue.ExtractPropTypes<{
124
149
  type: PropType<LoomaImageUploader | undefined>;
125
150
  default: undefined;
126
151
  };
152
+ resolveImageAttributes: {
153
+ type: PropType<LoomaImageAttributeResolver | undefined>;
154
+ default: undefined;
155
+ };
127
156
  }>, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
128
157
  [key: string]: any;
129
158
  }>, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {
@@ -131,6 +160,8 @@ declare const LoomaEditor: vue.DefineComponent<vue.ExtractPropTypes<{
131
160
  update: (_value: JSONContent) => true;
132
161
  ready: (_editor: Editor) => true;
133
162
  uploadError: (_error: unknown, _file: File) => true;
163
+ imageActivate: (_detail: LoomaImageActivationDetail) => true;
164
+ imageRenditionError: (_detail: LoomaImageRenditionErrorDetail) => true;
134
165
  }, string, vue.PublicProps, Readonly<vue.ExtractPropTypes<{
135
166
  modelValue: {
136
167
  type: PropType<JSONContent>;
@@ -163,17 +194,24 @@ declare const LoomaEditor: vue.DefineComponent<vue.ExtractPropTypes<{
163
194
  type: PropType<LoomaImageUploader | undefined>;
164
195
  default: undefined;
165
196
  };
197
+ resolveImageAttributes: {
198
+ type: PropType<LoomaImageAttributeResolver | undefined>;
199
+ default: undefined;
200
+ };
166
201
  }>> & Readonly<{
167
202
  "onUpdate:modelValue"?: (_value: JSONContent) => any;
168
203
  onUpdate?: (_value: JSONContent) => any;
169
204
  onReady?: (_editor: Editor) => any;
170
205
  onUploadError?: (_error: unknown, _file: File) => any;
206
+ onImageActivate?: (_detail: LoomaImageActivationDetail) => any;
207
+ onImageRenditionError?: (_detail: LoomaImageRenditionErrorDetail) => any;
171
208
  }>, {
172
209
  placeholder: string;
173
210
  modelValue: JSONContent;
174
211
  editable: boolean;
175
212
  extensions: AnyExtension[];
176
213
  uploadImage: LoomaImageUploader | undefined;
214
+ resolveImageAttributes: LoomaImageAttributeResolver | undefined;
177
215
  }, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
178
216
 
179
- export { EDITOR_ADAPTER_COMPONENT_TAG_MAP, EditorInsertTableGrid, EditorSlashMenu, EditorTableContextMenu, EditorTableOverlay, EditorTableToolbar, EditorToolbar, LoomaEditor, type LoomaImageUploadResult, type LoomaImageUploader, type VueEditorAdapterEventMap };
217
+ export { EDITOR_ADAPTER_COMPONENT_TAG_MAP, EditorInsertTableGrid, EditorSlashMenu, EditorTableContextMenu, EditorTableOverlay, EditorTableToolbar, EditorToolbar, LoomaEditor, type LoomaImageActivationDetail, type LoomaImageActivationTrigger, type LoomaImageAttributeResolver, type LoomaImageDescriptor, type LoomaImageRenditionAttributes, type LoomaImageRenditionErrorDetail, type LoomaImageUploadResult, type LoomaImageUploader, type VueEditorAdapterEventMap };
@@ -130,6 +130,188 @@ import {
130
130
  getVisualViewportRect,
131
131
  LOOMA_ICONS
132
132
  } from "@threadlabs/looma/core";
133
+
134
+ // src/editor/image-delivery.ts
135
+ import { Extension } from "@tiptap/core";
136
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
137
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
138
+ function optionalString(value) {
139
+ return typeof value === "string" && value.length > 0 ? value : void 0;
140
+ }
141
+ function normalizeImageDimension(value) {
142
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
143
+ }
144
+ function imageDescriptorFromAttrs(attrs) {
145
+ const src = optionalString(attrs.src);
146
+ if (!src) return null;
147
+ const alt = typeof attrs.alt === "string" ? attrs.alt : void 0;
148
+ const width = normalizeImageDimension(attrs.width);
149
+ const height = normalizeImageDimension(attrs.height);
150
+ return {
151
+ src,
152
+ ...alt !== void 0 ? { alt } : {},
153
+ ...width ? { width } : {},
154
+ ...height ? { height } : {},
155
+ ...attrs.responsive === true ? { responsive: true } : {}
156
+ };
157
+ }
158
+ function transientAttributes(resolver, image) {
159
+ const attributes = {
160
+ "aria-label": image.alt ? `Open image: ${image.alt}` : "Open image",
161
+ "data-looma-image": "",
162
+ role: "button",
163
+ src: image.src,
164
+ tabindex: "0"
165
+ };
166
+ if (!image.responsive || !resolver) return attributes;
167
+ let rendition;
168
+ try {
169
+ rendition = resolver(image);
170
+ } catch {
171
+ return attributes;
172
+ }
173
+ if (!rendition) return attributes;
174
+ const src = optionalString(rendition.src);
175
+ const srcset = optionalString(rendition.srcset);
176
+ const sizes = optionalString(rendition.sizes);
177
+ if (src) attributes.src = src;
178
+ if (srcset) attributes.srcset = srcset;
179
+ if (sizes) attributes.sizes = sizes;
180
+ if (rendition.loading === "eager" || rendition.loading === "lazy") {
181
+ attributes.loading = rendition.loading;
182
+ }
183
+ if (["async", "auto", "sync"].includes(rendition.decoding ?? "")) {
184
+ attributes.decoding = rendition.decoding;
185
+ }
186
+ if (["auto", "high", "low"].includes(rendition.fetchPriority ?? "")) {
187
+ attributes.fetchpriority = rendition.fetchPriority;
188
+ }
189
+ if (src && src !== image.src || srcset) attributes["data-looma-rendition"] = "true";
190
+ return attributes;
191
+ }
192
+ function createDecorations(doc, resolver, failedRenditions) {
193
+ const decorations = [];
194
+ const currentSources = /* @__PURE__ */ new Set();
195
+ doc.descendants((node, position) => {
196
+ if (node.type.name !== "image") return;
197
+ const image = imageDescriptorFromAttrs(node.attrs);
198
+ if (!image) return;
199
+ currentSources.add(image.src);
200
+ decorations.push(Decoration.node(
201
+ position,
202
+ position + node.nodeSize,
203
+ transientAttributes(failedRenditions.has(image.src) ? void 0 : resolver, image)
204
+ ));
205
+ });
206
+ for (const src of failedRenditions) {
207
+ if (!currentSources.has(src)) failedRenditions.delete(src);
208
+ }
209
+ return DecorationSet.create(doc, decorations);
210
+ }
211
+ function rangeContainsImage(doc, from, to) {
212
+ const start = Math.max(0, Math.min(from - 1, doc.content.size));
213
+ const end = Math.max(start, Math.min(to + 1, doc.content.size));
214
+ let found = false;
215
+ doc.nodesBetween(start, end, (node) => {
216
+ if (node.type.name !== "image") return !found;
217
+ found = true;
218
+ return false;
219
+ });
220
+ return found;
221
+ }
222
+ function transactionChangesImages(transaction) {
223
+ let changed = false;
224
+ transaction.mapping.maps.forEach((stepMap) => {
225
+ stepMap.forEach((oldStart, oldEnd, newStart, newEnd) => {
226
+ if (rangeContainsImage(transaction.before, oldStart, oldEnd) || rangeContainsImage(transaction.doc, newStart, newEnd)) changed = true;
227
+ });
228
+ });
229
+ return changed;
230
+ }
231
+ function createLoomaImageDeliveryController(options) {
232
+ const pluginKey = new PluginKey("looma-image-delivery");
233
+ const failedRenditions = /* @__PURE__ */ new Set();
234
+ const refresh = (editor) => {
235
+ editor.view.dispatch(editor.state.tr.setMeta(pluginKey, true));
236
+ };
237
+ const extension = Extension.create({
238
+ name: "loomaImageDelivery",
239
+ addGlobalAttributes() {
240
+ return [{
241
+ types: ["image"],
242
+ attributes: {
243
+ width: {
244
+ default: null,
245
+ parseHTML: (element) => normalizeImageDimension(Number(element.getAttribute("width"))) ?? null,
246
+ renderHTML: (attributes) => normalizeImageDimension(attributes.width) ? { width: normalizeImageDimension(attributes.width) } : {}
247
+ },
248
+ height: {
249
+ default: null,
250
+ parseHTML: (element) => normalizeImageDimension(Number(element.getAttribute("height"))) ?? null,
251
+ renderHTML: (attributes) => normalizeImageDimension(attributes.height) ? { height: normalizeImageDimension(attributes.height) } : {}
252
+ },
253
+ responsive: {
254
+ default: null,
255
+ parseHTML: (element) => element.hasAttribute("data-looma-responsive") || null,
256
+ renderHTML: (attributes) => attributes.responsive === true ? { "data-looma-responsive": "" } : {}
257
+ }
258
+ }
259
+ }];
260
+ },
261
+ addProseMirrorPlugins() {
262
+ return [new Plugin({
263
+ key: pluginKey,
264
+ state: {
265
+ init: (_, state) => createDecorations(
266
+ state.doc,
267
+ options.resolveAttributes(),
268
+ failedRenditions
269
+ ),
270
+ apply: (transaction, previous) => {
271
+ if (!transaction.getMeta(pluginKey) && !transactionChangesImages(transaction)) {
272
+ return previous.map(transaction.mapping, transaction.doc);
273
+ }
274
+ return createDecorations(
275
+ transaction.doc,
276
+ options.resolveAttributes(),
277
+ failedRenditions
278
+ );
279
+ }
280
+ },
281
+ props: {
282
+ decorations: (state) => pluginKey.getState(state)
283
+ }
284
+ })];
285
+ }
286
+ });
287
+ return {
288
+ extension,
289
+ markRenditionFailed: (editor, src) => {
290
+ if (failedRenditions.has(src)) return false;
291
+ failedRenditions.add(src);
292
+ refresh(editor);
293
+ return true;
294
+ },
295
+ reset: (editor) => {
296
+ failedRenditions.clear();
297
+ refresh(editor);
298
+ }
299
+ };
300
+ }
301
+ function imageUploadContent(image) {
302
+ return {
303
+ type: "image",
304
+ attrs: {
305
+ src: image.src,
306
+ alt: image.alt,
307
+ ...image.width ? { width: image.width } : {},
308
+ ...image.height ? { height: image.height } : {},
309
+ ...image.responsive === true ? { responsive: true } : {}
310
+ }
311
+ };
312
+ }
313
+
314
+ // src/editor/LoomaEditor.ts
133
315
  var EMPTY_DOCUMENT = { type: "doc", content: [] };
134
316
  var EMPTY_CAPABILITIES = {
135
317
  canAddRowBefore: false,
@@ -189,13 +371,19 @@ var LoomaEditor = defineComponent2({
189
371
  uploadImage: {
190
372
  type: Function,
191
373
  default: void 0
374
+ },
375
+ resolveImageAttributes: {
376
+ type: Function,
377
+ default: void 0
192
378
  }
193
379
  },
194
380
  emits: {
195
381
  "update:modelValue": (_value) => true,
196
382
  update: (_value) => true,
197
383
  ready: (_editor) => true,
198
- uploadError: (_error, _file) => true
384
+ uploadError: (_error, _file) => true,
385
+ imageActivate: (_detail) => true,
386
+ imageRenditionError: (_detail) => true
199
387
  },
200
388
  setup(props, { attrs, emit, expose }) {
201
389
  const root = ref(null);
@@ -208,6 +396,7 @@ var LoomaEditor = defineComponent2({
208
396
  const mobileToolbarShell = ref(null);
209
397
  const dragOver = ref(false);
210
398
  const uploading = ref(false);
399
+ const failedUpload = ref(null);
211
400
  const tablePickerOpen = ref(false);
212
401
  const tablePickerStyle = ref({});
213
402
  const mobileToolbarStyle = ref({});
@@ -249,9 +438,13 @@ var LoomaEditor = defineComponent2({
249
438
  Object.assign(slash, state);
250
439
  }
251
440
  });
441
+ const imageDelivery = createLoomaImageDeliveryController({
442
+ resolveAttributes: () => props.resolveImageAttributes
443
+ });
252
444
  const editor = useEditor({
253
445
  extensions: [
254
446
  ...getDefaultEditorExtensions({ placeholder: props.placeholder }),
447
+ imageDelivery.extension,
255
448
  slashExtension,
256
449
  ...props.extensions
257
450
  ],
@@ -265,6 +458,10 @@ var LoomaEditor = defineComponent2({
265
458
  }
266
459
  });
267
460
  watch(() => props.editable, (editable) => editor.value?.setEditable(editable));
461
+ watch(() => props.resolveImageAttributes, () => {
462
+ const instance = editor.value;
463
+ if (instance) imageDelivery.reset(instance);
464
+ });
268
465
  watch(() => props.modelValue, (value) => {
269
466
  const instance = editor.value;
270
467
  if (!instance || sameDocument(instance.getJSON(), value)) return;
@@ -441,6 +638,49 @@ var LoomaEditor = defineComponent2({
441
638
  nextTick(updateTableUi);
442
639
  });
443
640
  };
641
+ const imageForElement = (element) => {
642
+ const instance = editor.value;
643
+ if (!instance) return null;
644
+ try {
645
+ const position = instance.view.posAtDOM(element, 0);
646
+ const node = instance.state.doc.nodeAt(position);
647
+ return node?.type.name === "image" ? imageDescriptorFromAttrs(node.attrs) : null;
648
+ } catch {
649
+ return null;
650
+ }
651
+ };
652
+ const imageElementForEvent = (event) => {
653
+ const element = event.target instanceof Element ? event.target.closest("img[data-looma-image]") : null;
654
+ return element && root.value?.contains(element) ? element : null;
655
+ };
656
+ const activateImage = (element, trigger) => {
657
+ const image = imageForElement(element);
658
+ if (image) emit("imageActivate", { ...image, trigger });
659
+ };
660
+ const onImageClick = (event) => {
661
+ const element = imageElementForEvent(event);
662
+ if (element && !props.editable) activateImage(element, "pointer");
663
+ };
664
+ const onImageDoubleClick = (event) => {
665
+ const element = imageElementForEvent(event);
666
+ if (element && props.editable) activateImage(element, "pointer");
667
+ };
668
+ const onImageKeyDown = (event) => {
669
+ if (event.key !== "Enter" && event.key !== " ") return;
670
+ const element = imageElementForEvent(event);
671
+ if (!element) return;
672
+ event.preventDefault();
673
+ activateImage(element, "keyboard");
674
+ };
675
+ const onImageError = (event) => {
676
+ const element = imageElementForEvent(event);
677
+ if (!element || element.dataset.loomaRendition !== "true") return;
678
+ const image = imageForElement(element);
679
+ if (!image) return;
680
+ const instance = editor.value;
681
+ if (!instance || !imageDelivery.markRenditionFailed(instance, image.src)) return;
682
+ emit("imageRenditionError", { ...image, trigger: "programmatic" });
683
+ };
444
684
  onMounted(() => {
445
685
  updateMobileViewport();
446
686
  window.addEventListener("resize", onViewportChange);
@@ -450,6 +690,7 @@ var LoomaEditor = defineComponent2({
450
690
  document.addEventListener("pointerdown", onDocumentPointerDown, true);
451
691
  document.addEventListener("pointerdown", onResizePointerDown, true);
452
692
  document.addEventListener("pointerup", onResizePointerUp, true);
693
+ root.value?.addEventListener("error", onImageError, true);
453
694
  });
454
695
  onBeforeUnmount(() => {
455
696
  window.removeEventListener("resize", onViewportChange);
@@ -459,6 +700,7 @@ var LoomaEditor = defineComponent2({
459
700
  document.removeEventListener("pointerdown", onDocumentPointerDown, true);
460
701
  document.removeEventListener("pointerdown", onResizePointerDown, true);
461
702
  document.removeEventListener("pointerup", onResizePointerUp, true);
703
+ root.value?.removeEventListener("error", onImageError, true);
462
704
  unbindEditorUi(editor.value);
463
705
  if (dragLeaveTimer) clearTimeout(dragLeaveTimer);
464
706
  if (tablePointerFrame !== null) cancelAnimationFrame(tablePointerFrame);
@@ -469,8 +711,18 @@ var LoomaEditor = defineComponent2({
469
711
  try {
470
712
  const result = await props.uploadImage(file);
471
713
  const image = typeof result === "string" ? { url: result } : result;
472
- editor.value.chain().focus().setImage({ src: image.url, alt: image.alt ?? file.name }).run();
714
+ const width = normalizeImageDimension(image.width);
715
+ const height = normalizeImageDimension(image.height);
716
+ editor.value.chain().focus().insertContent(imageUploadContent({
717
+ src: image.url,
718
+ alt: image.alt ?? file.name,
719
+ ...width ? { width } : {},
720
+ ...height ? { height } : {},
721
+ ...image.responsive === true ? { responsive: true } : {}
722
+ })).run();
723
+ failedUpload.value = null;
473
724
  } catch (error) {
725
+ failedUpload.value = { file };
474
726
  emit("uploadError", error, file);
475
727
  } finally {
476
728
  uploading.value = false;
@@ -658,7 +910,10 @@ var LoomaEditor = defineComponent2({
658
910
  dragOver.value = false;
659
911
  }, 100);
660
912
  },
661
- onDrop
913
+ onDrop,
914
+ onClick: onImageClick,
915
+ onDblclick: onImageDoubleClick,
916
+ onKeydown: onImageKeyDown
662
917
  }, [
663
918
  instance && props.editable && !mobile.value ? h2(BubbleMenu, {
664
919
  editor: instance,
@@ -703,6 +958,20 @@ var LoomaEditor = defineComponent2({
703
958
  "aria-hidden": "true",
704
959
  onChange: onFileChange
705
960
  }),
961
+ failedUpload.value ? h2("div", {
962
+ class: "looma-editor__upload-error",
963
+ role: "alert"
964
+ }, [
965
+ h2("span", "Couldn\u2019t upload image."),
966
+ h2("button", {
967
+ type: "button",
968
+ disabled: uploading.value,
969
+ onClick: () => {
970
+ const attempt = failedUpload.value;
971
+ if (attempt) void insertImage(attempt.file);
972
+ }
973
+ }, uploading.value ? "Retrying\u2026" : "Retry")
974
+ ]) : null,
706
975
  dragOver.value ? h2("div", { class: "looma-editor__drop-overlay", "aria-hidden": "true" }, [
707
976
  loomaIcon("image"),
708
977
  h2("span", "Drop image to upload")