@threadlabs/looma 0.28.21 → 0.28.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { type LoomaImageDescriptor } from "./image-delivery.js";
3
+ /**
4
+ * Host upload result normalized into the editor's durable image descriptor.
5
+ * `url` becomes the stored `src`; optional dimensions preserve layout before
6
+ * the image loads and `responsive` opts into host-provided rendition policy.
7
+ */
8
+ export interface LoomaImageUploadResult extends Omit<LoomaImageDescriptor, "src"> {
9
+ url: string;
10
+ }
11
+ /**
12
+ * Application-owned upload boundary used by paste, drop, and file selection.
13
+ *
14
+ * @ownership The application owns network requests, persistence, and returned
15
+ * URLs. Looma owns only editor insertion after the promise fulfills.
16
+ * @failure Rejection leaves the document unchanged and is surfaced through the
17
+ * editor's `uploadError` event so the host chooses retry and user messaging.
18
+ */
19
+ export type LoomaImageUploader = (file: File) => Promise<string | LoomaImageUploadResult>;
20
+ export interface LoomaImageUploadsState {
21
+ readonly uploading: boolean;
22
+ /** The file whose upload failed last, which `retry()` sends again. */
23
+ readonly failed: File | null;
24
+ }
25
+ export interface LoomaImageUploadsOptions {
26
+ editor: Editor;
27
+ /** Read on each upload, so a host can change it while the editor runs. */
28
+ upload: () => LoomaImageUploader | null | undefined;
29
+ onError: (error: unknown, file: File) => void;
30
+ }
31
+ /**
32
+ * The editor's image uploads: each file goes to the host's uploader in turn, and the image it
33
+ * returns is inserted at the selection.
34
+ *
35
+ * @ownership The host owns the upload and its URL; this owns the uploading state, the failed file,
36
+ * and inserting the result.
37
+ * @failure A rejected upload leaves the document unchanged, records the file for `retry()`, and is
38
+ * reported through `onError`.
39
+ */
40
+ export interface LoomaImageUploads {
41
+ readonly state: LoomaImageUploadsState;
42
+ /** Calls `listener` after every state change; returns the unsubscribe function. */
43
+ subscribe(listener: () => void): () => void;
44
+ /** Uploads each file in turn and inserts its image; does nothing without an uploader. */
45
+ insert(files: Iterable<File>): Promise<void>;
46
+ /** Sends the failed file again. */
47
+ retry(): Promise<void>;
48
+ }
49
+ export declare function createImageUploads(options: LoomaImageUploadsOptions): LoomaImageUploads;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.28.21",
3
+ "version": "0.28.22",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -3,25 +3,10 @@ import "./looma-editor.css";
3
3
  import { type AnyExtension, type Editor, type JSONContent } from "@tiptap/core";
4
4
  import { type PropType, type VNode } from "vue";
5
5
  import { type LoomaSlashCommand, type LoomaMentionItem, type LoomaMentionProvider, type LoomaCodeLanguages } from "@threadlabs/looma/editor";
6
- import { type LoomaImageActivationDetail, type LoomaImageAttributeResolver, type LoomaImageSourceResolver, type LoomaImageDescriptor, type LoomaImageRenditionErrorDetail } from "../../editor/image-delivery.js";
6
+ import { type LoomaImageActivationDetail, type LoomaImageAttributeResolver, type LoomaImageSourceResolver, type LoomaImageRenditionErrorDetail } from "../../editor/image-delivery.js";
7
+ import { type LoomaImageUploader } from "../../editor/image-uploads.js";
7
8
  import { type LoomaLinkResolve, type LoomaLinkSearch } from "../../editor/link-editing.js";
8
- /**
9
- * Host upload result normalized into the editor's durable image descriptor.
10
- * `url` becomes the stored `src`; optional dimensions preserve layout before
11
- * the image loads and `responsive` opts into host-provided rendition policy.
12
- */
13
- export interface LoomaImageUploadResult extends Omit<LoomaImageDescriptor, "src"> {
14
- url: string;
15
- }
16
- /**
17
- * Application-owned upload boundary used by paste, drop, and file selection.
18
- *
19
- * @ownership The application owns network requests, persistence, and returned
20
- * URLs. Looma owns only editor insertion after the promise fulfills.
21
- * @failure Rejection leaves the document unchanged and is surfaced through the
22
- * editor's `uploadError` event so the host chooses retry and user messaging.
23
- */
24
- export type LoomaImageUploader = (file: File) => Promise<string | LoomaImageUploadResult>;
9
+ export type { LoomaImageUploader, LoomaImageUploadResult } from "../../editor/image-uploads.js";
25
10
  /**
26
11
  * Chooses whether formatting controls follow a selection, occupy persistent
27
12
  * editor chrome, or open from an app-owned button (`popover`, with a text-only
@@ -1138,6 +1138,64 @@ function runBlockAction(editor, action) {
1138
1138
  return true;
1139
1139
  }
1140
1140
  //#endregion
1141
+ //#region src/editor/image-uploads.ts
1142
+ function createImageUploads(options) {
1143
+ const { editor } = options;
1144
+ const listeners = /* @__PURE__ */ new Set();
1145
+ let state = {
1146
+ uploading: false,
1147
+ failed: null
1148
+ };
1149
+ const set = (patch) => {
1150
+ state = {
1151
+ ...state,
1152
+ ...patch
1153
+ };
1154
+ listeners.forEach((listener) => listener());
1155
+ };
1156
+ const insertOne = async (file) => {
1157
+ const upload = options.upload();
1158
+ if (!upload || editor.isDestroyed) return;
1159
+ set({ uploading: true });
1160
+ try {
1161
+ const result = await upload(file);
1162
+ const image = typeof result === "string" ? { url: result } : result;
1163
+ const width = normalizeImageDimension(image.width);
1164
+ const height = normalizeImageDimension(image.height);
1165
+ if (!editor.isDestroyed) editor.chain().focus().insertContent(imageUploadContent({
1166
+ src: image.url,
1167
+ alt: image.alt ?? file.name,
1168
+ ...width ? { width } : {},
1169
+ ...height ? { height } : {},
1170
+ ...image.responsive === true ? { responsive: true } : {}
1171
+ })).run();
1172
+ set({ failed: null });
1173
+ } catch (error) {
1174
+ set({ failed: file });
1175
+ options.onError(error, file);
1176
+ } finally {
1177
+ set({ uploading: false });
1178
+ }
1179
+ };
1180
+ return {
1181
+ get state() {
1182
+ return state;
1183
+ },
1184
+ subscribe(listener) {
1185
+ listeners.add(listener);
1186
+ return () => {
1187
+ listeners.delete(listener);
1188
+ };
1189
+ },
1190
+ async insert(files) {
1191
+ for (const file of files) await insertOne(file);
1192
+ },
1193
+ async retry() {
1194
+ if (state.failed) await insertOne(state.failed);
1195
+ }
1196
+ };
1197
+ }
1198
+ //#endregion
1141
1199
  //#region src/editor/formatting-selection.ts
1142
1200
  var HOLD_MS = 500;
1143
1201
  var HOLD_SLOP_PX = 8;
@@ -3025,8 +3083,11 @@ var LoomaEditor = defineComponent({
3025
3083
  const mobileToolbarShell = ref(null);
3026
3084
  const codeLanguageShell = ref(null);
3027
3085
  const dragOver = ref(false);
3028
- const uploading = ref(false);
3029
- const failedUpload = ref(null);
3086
+ let uploads;
3087
+ const uploadState = shallowRef({
3088
+ uploading: false,
3089
+ failed: null
3090
+ });
3030
3091
  const tablePickerOpen = ref(false);
3031
3092
  const mobileToolbarStyle = ref({});
3032
3093
  const mobile = ref(typeof window !== "undefined" && window.innerWidth <= 767);
@@ -3263,6 +3324,15 @@ var LoomaEditor = defineComponent({
3263
3324
  linkState.value = engine.state;
3264
3325
  });
3265
3326
  linkEditing = engine;
3327
+ const imageUploads = createImageUploads({
3328
+ editor: session.editor,
3329
+ upload: () => props.uploadImage,
3330
+ onError: (error, file) => emit("uploadError", error, file)
3331
+ });
3332
+ imageUploads.subscribe(() => {
3333
+ uploadState.value = imageUploads.state;
3334
+ });
3335
+ uploads = imageUploads;
3266
3336
  const instance = session.editor;
3267
3337
  formatting = createFormattingSelection({
3268
3338
  editor: instance,
@@ -3867,41 +3937,18 @@ var LoomaEditor = defineComponent({
3867
3937
  if (dragLeaveTimer) clearTimeout(dragLeaveTimer);
3868
3938
  if (tablePointerFrame !== null) cancelAnimationFrame(tablePointerFrame);
3869
3939
  });
3870
- const insertImage = async (file) => {
3871
- if (!props.uploadImage || !editor.value) return;
3872
- uploading.value = true;
3873
- try {
3874
- const result = await props.uploadImage(file);
3875
- const image = typeof result === "string" ? { url: result } : result;
3876
- const width = normalizeImageDimension(image.width);
3877
- const height = normalizeImageDimension(image.height);
3878
- editor.value.chain().focus().insertContent(imageUploadContent({
3879
- src: image.url,
3880
- alt: image.alt ?? file.name,
3881
- ...width ? { width } : {},
3882
- ...height ? { height } : {},
3883
- ...image.responsive === true ? { responsive: true } : {}
3884
- })).run();
3885
- failedUpload.value = null;
3886
- } catch (error) {
3887
- failedUpload.value = { file };
3888
- emit("uploadError", error, file);
3889
- } finally {
3890
- uploading.value = false;
3891
- }
3892
- };
3893
3940
  const onFileChange = async (event) => {
3894
3941
  const input = event.target;
3895
3942
  const files = Array.from(input.files ?? []);
3896
3943
  input.value = "";
3897
- for (const file of files) await insertImage(file);
3944
+ await uploads?.insert(files);
3898
3945
  };
3899
3946
  const onDrop = async (event) => {
3900
3947
  event.preventDefault();
3901
3948
  dragOver.value = false;
3902
3949
  if (!props.editable) return;
3903
3950
  const files = Array.from(event.dataTransfer?.files ?? []).filter((file) => file.type.startsWith("image/"));
3904
- for (const file of files) await insertImage(file);
3951
+ await uploads?.insert(files);
3905
3952
  };
3906
3953
  const onContextMenu = (event) => {
3907
3954
  const instance = editor.value;
@@ -4158,7 +4205,7 @@ var LoomaEditor = defineComponent({
4158
4205
  variant: tablePickerOpen.value ? "solid" : "ghost",
4159
4206
  "aria-expanded": tablePickerOpen.value ? "true" : "false"
4160
4207
  }, () => loomaIcon("table")),
4161
- commandButton(uploading.value ? "Uploading image" : "Insert image", "image", false, uploading.value || !props.uploadImage, () => fileInput.value?.click()),
4208
+ commandButton(uploadState.value.uploading ? "Uploading image" : "Insert image", "image", false, uploadState.value.uploading || !props.uploadImage, () => fileInput.value?.click()),
4162
4209
  h("span", {
4163
4210
  class: "divider",
4164
4211
  "aria-hidden": "true"
@@ -4349,17 +4396,16 @@ var LoomaEditor = defineComponent({
4349
4396
  "aria-hidden": "true",
4350
4397
  onChange: onFileChange
4351
4398
  }),
4352
- failedUpload.value ? h("div", {
4399
+ uploadState.value.failed ? h("div", {
4353
4400
  class: "looma-editor__upload-error",
4354
4401
  role: "alert"
4355
4402
  }, [h("span", "Couldn’t upload image."), h("button", {
4356
4403
  type: "button",
4357
- disabled: uploading.value,
4404
+ disabled: uploadState.value.uploading,
4358
4405
  onClick: () => {
4359
- const attempt = failedUpload.value;
4360
- if (attempt) insertImage(attempt.file);
4406
+ uploads?.retry();
4361
4407
  }
4362
- }, uploading.value ? "Retrying…" : "Retry")]) : null,
4408
+ }, uploadState.value.uploading ? "Retrying…" : "Retry")]) : null,
4363
4409
  dragOver.value ? h("div", {
4364
4410
  class: "looma-editor__drop-overlay",
4365
4411
  "aria-hidden": "true"