nuxt-upload-kit 0.1.1
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/README.md +171 -0
- package/dist/module.d.mts +14 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +29 -0
- package/dist/runtime/composables/useFFMpeg.d.ts +14 -0
- package/dist/runtime/composables/useFFMpeg.js +66 -0
- package/dist/runtime/composables/useUploadKit/index.d.ts +471 -0
- package/dist/runtime/composables/useUploadKit/index.js +486 -0
- package/dist/runtime/composables/useUploadKit/plugins/image-compressor.d.ts +56 -0
- package/dist/runtime/composables/useUploadKit/plugins/image-compressor.js +137 -0
- package/dist/runtime/composables/useUploadKit/plugins/index.d.ts +4 -0
- package/dist/runtime/composables/useUploadKit/plugins/index.js +4 -0
- package/dist/runtime/composables/useUploadKit/plugins/storage/azure-datalake.d.ts +55 -0
- package/dist/runtime/composables/useUploadKit/plugins/storage/azure-datalake.js +137 -0
- package/dist/runtime/composables/useUploadKit/plugins/storage/index.d.ts +10 -0
- package/dist/runtime/composables/useUploadKit/plugins/storage/index.js +1 -0
- package/dist/runtime/composables/useUploadKit/plugins/thumbnail-generator.d.ts +8 -0
- package/dist/runtime/composables/useUploadKit/plugins/thumbnail-generator.js +99 -0
- package/dist/runtime/composables/useUploadKit/plugins/video-compressor.d.ts +72 -0
- package/dist/runtime/composables/useUploadKit/plugins/video-compressor.js +111 -0
- package/dist/runtime/composables/useUploadKit/types.d.ts +488 -0
- package/dist/runtime/composables/useUploadKit/types.js +9 -0
- package/dist/runtime/composables/useUploadKit/utils.d.ts +23 -0
- package/dist/runtime/composables/useUploadKit/utils.js +45 -0
- package/dist/runtime/composables/useUploadKit/validators/allowed-file-types.d.ts +5 -0
- package/dist/runtime/composables/useUploadKit/validators/allowed-file-types.js +17 -0
- package/dist/runtime/composables/useUploadKit/validators/duplicate-file.d.ts +13 -0
- package/dist/runtime/composables/useUploadKit/validators/duplicate-file.js +27 -0
- package/dist/runtime/composables/useUploadKit/validators/index.d.ts +4 -0
- package/dist/runtime/composables/useUploadKit/validators/index.js +4 -0
- package/dist/runtime/composables/useUploadKit/validators/max-file-size.d.ts +5 -0
- package/dist/runtime/composables/useUploadKit/validators/max-file-size.js +17 -0
- package/dist/runtime/composables/useUploadKit/validators/max-files.d.ts +5 -0
- package/dist/runtime/composables/useUploadKit/validators/max-files.js +17 -0
- package/dist/runtime/types/index.d.ts +3 -0
- package/dist/runtime/types/index.js +3 -0
- package/dist/types.d.mts +5 -0
- package/package.json +84 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import mitt from "mitt";
|
|
2
|
+
import { computed, onBeforeUnmount, readonly, ref } from "vue";
|
|
3
|
+
import { ValidatorAllowedFileTypes, ValidatorMaxFileSize, ValidatorMaxFiles } from "./validators/index.js";
|
|
4
|
+
import { PluginThumbnailGenerator, PluginImageCompressor } from "./plugins/index.js";
|
|
5
|
+
import { createPluginContext, createFileError, cleanupObjectURLs } from "./utils.js";
|
|
6
|
+
function isStoragePlugin(plugin) {
|
|
7
|
+
return plugin !== null && "upload" in plugin.hooks;
|
|
8
|
+
}
|
|
9
|
+
function getExtension(fullFileName) {
|
|
10
|
+
const lastDot = fullFileName.lastIndexOf(".");
|
|
11
|
+
if (lastDot === -1 || lastDot === fullFileName.length - 1) {
|
|
12
|
+
throw new Error("Invalid file name");
|
|
13
|
+
}
|
|
14
|
+
return fullFileName.slice(lastDot + 1).toLocaleLowerCase();
|
|
15
|
+
}
|
|
16
|
+
const defaultOptions = {
|
|
17
|
+
storage: void 0,
|
|
18
|
+
plugins: [],
|
|
19
|
+
maxFileSize: false,
|
|
20
|
+
allowedFileTypes: false,
|
|
21
|
+
maxFiles: false,
|
|
22
|
+
thumbnails: false,
|
|
23
|
+
imageCompression: false,
|
|
24
|
+
autoProceed: false
|
|
25
|
+
};
|
|
26
|
+
export const useUploadKit = (_options = {}) => {
|
|
27
|
+
const options = { ...defaultOptions, ..._options };
|
|
28
|
+
const files = ref([]);
|
|
29
|
+
const emitter = mitt();
|
|
30
|
+
const status = ref("waiting");
|
|
31
|
+
const createdObjectURLs = /* @__PURE__ */ new Map();
|
|
32
|
+
const pluginEmitFunctions = /* @__PURE__ */ new Map();
|
|
33
|
+
const getPluginEmitFn = (pluginId) => {
|
|
34
|
+
let emitFn = pluginEmitFunctions.get(pluginId);
|
|
35
|
+
if (!emitFn) {
|
|
36
|
+
emitFn = (event, payload) => {
|
|
37
|
+
const prefixedEvent = `${pluginId}:${String(event)}`;
|
|
38
|
+
emitter.emit(prefixedEvent, payload);
|
|
39
|
+
};
|
|
40
|
+
pluginEmitFunctions.set(pluginId, emitFn);
|
|
41
|
+
}
|
|
42
|
+
return emitFn;
|
|
43
|
+
};
|
|
44
|
+
let uploadFn = async () => {
|
|
45
|
+
throw new Error("No uploader configured");
|
|
46
|
+
};
|
|
47
|
+
let getRemoteFileFn = async () => {
|
|
48
|
+
throw new Error("Function to get remote file not configured");
|
|
49
|
+
};
|
|
50
|
+
const totalProgress = computed(() => {
|
|
51
|
+
if (files.value.length === 0) return 0;
|
|
52
|
+
const sum = files.value.reduce((acc, file) => acc + file.progress.percentage, 0);
|
|
53
|
+
return Math.round(sum / files.value.length);
|
|
54
|
+
});
|
|
55
|
+
const getStoragePlugin = () => {
|
|
56
|
+
if (options.storage) {
|
|
57
|
+
return options.storage;
|
|
58
|
+
}
|
|
59
|
+
if (!options.plugins) return null;
|
|
60
|
+
for (let i = options.plugins.length - 1; i >= 0; i--) {
|
|
61
|
+
const plugin = options.plugins[i] || null;
|
|
62
|
+
if (isStoragePlugin(plugin)) {
|
|
63
|
+
if (import.meta.dev) {
|
|
64
|
+
console.warn(
|
|
65
|
+
`[useUploadKit] Storage plugin "${plugin.id}" found in plugins array.
|
|
66
|
+
This is deprecated. Use the 'storage' option instead:
|
|
67
|
+
|
|
68
|
+
useUploadKit({ storage: ${plugin.id}(...) })`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return plugin;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
const addPlugin = (plugin) => {
|
|
77
|
+
const hasUploadHook = "upload" in plugin.hooks;
|
|
78
|
+
if (hasUploadHook) {
|
|
79
|
+
if (import.meta.dev) {
|
|
80
|
+
console.warn(
|
|
81
|
+
`[useUploadKit] Storage plugin "${plugin.id}" should use the 'storage' option instead of 'plugins':
|
|
82
|
+
|
|
83
|
+
useUploadKit({
|
|
84
|
+
storage: ${plugin.id}({ ... }), // \u2713 Correct
|
|
85
|
+
plugins: [...] // Only for validators/processors
|
|
86
|
+
})
|
|
87
|
+
`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (options.plugins) {
|
|
92
|
+
options.plugins.push(plugin);
|
|
93
|
+
} else {
|
|
94
|
+
options.plugins = [plugin];
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
if (options.maxFiles !== false && options.maxFiles !== void 0) {
|
|
98
|
+
addPlugin(ValidatorMaxFiles({ maxFiles: options.maxFiles }));
|
|
99
|
+
}
|
|
100
|
+
if (options.maxFileSize !== false && options.maxFileSize !== void 0) {
|
|
101
|
+
addPlugin(ValidatorMaxFileSize({ maxFileSize: options.maxFileSize }));
|
|
102
|
+
}
|
|
103
|
+
if (options.allowedFileTypes !== false && options.allowedFileTypes !== void 0 && options.allowedFileTypes.length > 0) {
|
|
104
|
+
addPlugin(ValidatorAllowedFileTypes({ allowedFileTypes: options.allowedFileTypes }));
|
|
105
|
+
}
|
|
106
|
+
if (options.thumbnails !== false && options.thumbnails !== void 0) {
|
|
107
|
+
const thumbOpts = options.thumbnails === true ? {} : options.thumbnails || {};
|
|
108
|
+
addPlugin(
|
|
109
|
+
PluginThumbnailGenerator({
|
|
110
|
+
maxWidth: thumbOpts.width ?? 128,
|
|
111
|
+
maxHeight: thumbOpts.height ?? 128,
|
|
112
|
+
quality: thumbOpts.quality ?? 1
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (options.imageCompression !== false && options.imageCompression !== void 0) {
|
|
117
|
+
const compressionOpts = options.imageCompression === true ? {} : options.imageCompression || {};
|
|
118
|
+
addPlugin(
|
|
119
|
+
PluginImageCompressor({
|
|
120
|
+
maxWidth: compressionOpts.maxWidth ?? 1920,
|
|
121
|
+
maxHeight: compressionOpts.maxHeight ?? 1920,
|
|
122
|
+
quality: compressionOpts.quality ?? 0.85,
|
|
123
|
+
outputFormat: compressionOpts.outputFormat ?? "auto",
|
|
124
|
+
minSizeToCompress: compressionOpts.minSizeToCompress ?? 1e5,
|
|
125
|
+
preserveMetadata: compressionOpts.preserveMetadata ?? true
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
emitter.on("upload:progress", ({ file, progress }) => {
|
|
130
|
+
updateFile(file.id, { progress: { percentage: progress } });
|
|
131
|
+
});
|
|
132
|
+
const updateFile = (fileId, updatedFile) => {
|
|
133
|
+
files.value = files.value.map((file) => file.id === fileId ? { ...file, ...updatedFile } : file);
|
|
134
|
+
};
|
|
135
|
+
const onUpload = (fn) => {
|
|
136
|
+
uploadFn = fn;
|
|
137
|
+
};
|
|
138
|
+
const onGetRemoteFile = (fn) => {
|
|
139
|
+
getRemoteFileFn = fn;
|
|
140
|
+
};
|
|
141
|
+
const initializeExistingFiles = async (initialFiles) => {
|
|
142
|
+
const initializedfiles = await Promise.all(
|
|
143
|
+
initialFiles.map(async (file) => {
|
|
144
|
+
if (!file.id) return null;
|
|
145
|
+
const storagePlugin = getStoragePlugin();
|
|
146
|
+
let remoteFileData;
|
|
147
|
+
if (storagePlugin?.hooks.getRemoteFile) {
|
|
148
|
+
const context = createPluginContext(storagePlugin.id, files.value, options, emitter);
|
|
149
|
+
remoteFileData = await storagePlugin.hooks.getRemoteFile(file.id, context);
|
|
150
|
+
} else {
|
|
151
|
+
remoteFileData = await getRemoteFileFn(file.id);
|
|
152
|
+
}
|
|
153
|
+
const existingFile = {
|
|
154
|
+
...file,
|
|
155
|
+
id: file.id,
|
|
156
|
+
name: file.id,
|
|
157
|
+
data: null,
|
|
158
|
+
status: "complete",
|
|
159
|
+
progress: { percentage: 100 },
|
|
160
|
+
meta: {},
|
|
161
|
+
size: remoteFileData.size,
|
|
162
|
+
mimeType: remoteFileData.mimeType,
|
|
163
|
+
remoteUrl: remoteFileData.remoteUrl,
|
|
164
|
+
preview: remoteFileData.preview || file.preview || remoteFileData.remoteUrl,
|
|
165
|
+
// Use preview from storage, passed-in value, or fallback to remoteUrl
|
|
166
|
+
source: "storage"
|
|
167
|
+
// File loaded from remote storage
|
|
168
|
+
};
|
|
169
|
+
return existingFile;
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
const filteredFiles = initializedfiles.filter((f) => f !== null);
|
|
173
|
+
files.value = [...filteredFiles];
|
|
174
|
+
};
|
|
175
|
+
const addFile = async (file) => {
|
|
176
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
177
|
+
const extension = getExtension(file.name);
|
|
178
|
+
const uploadFile = {
|
|
179
|
+
id: `${id}.${extension}`,
|
|
180
|
+
progress: {
|
|
181
|
+
percentage: 0
|
|
182
|
+
},
|
|
183
|
+
name: file.name,
|
|
184
|
+
size: file.size,
|
|
185
|
+
status: "waiting",
|
|
186
|
+
mimeType: file.type,
|
|
187
|
+
data: file,
|
|
188
|
+
source: "local",
|
|
189
|
+
meta: {
|
|
190
|
+
extension
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
try {
|
|
194
|
+
const validatedFile = await runPluginStage("validate", uploadFile);
|
|
195
|
+
if (!validatedFile) {
|
|
196
|
+
throw new Error(`File validation failed for ${file.name}`);
|
|
197
|
+
}
|
|
198
|
+
const preprocessedFile = await runPluginStage("preprocess", validatedFile);
|
|
199
|
+
const fileToAdd = preprocessedFile || validatedFile;
|
|
200
|
+
files.value.push(fileToAdd);
|
|
201
|
+
emitter.emit("file:added", fileToAdd);
|
|
202
|
+
if (options.autoProceed) {
|
|
203
|
+
upload();
|
|
204
|
+
}
|
|
205
|
+
return validatedFile;
|
|
206
|
+
} catch (err) {
|
|
207
|
+
const error = createFileError(uploadFile, err);
|
|
208
|
+
const fileWithError = { ...uploadFile, status: "error", error };
|
|
209
|
+
files.value.push(fileWithError);
|
|
210
|
+
emitter.emit("file:error", { file: fileWithError, error });
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const addFiles = async (newFiles) => {
|
|
215
|
+
const results = await Promise.allSettled(newFiles.map((file) => addFile(file)));
|
|
216
|
+
const addedFiles = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
|
|
217
|
+
return addedFiles;
|
|
218
|
+
};
|
|
219
|
+
const removeFile = async (fileId) => {
|
|
220
|
+
const file = files.value.find((f) => f.id === fileId);
|
|
221
|
+
if (!file) return;
|
|
222
|
+
if (file.remoteUrl) {
|
|
223
|
+
const storagePlugin = getStoragePlugin();
|
|
224
|
+
if (storagePlugin?.hooks.remove) {
|
|
225
|
+
try {
|
|
226
|
+
const context = createPluginContext(storagePlugin.id, files.value, options, emitter);
|
|
227
|
+
await storagePlugin.hooks.remove(file, context);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error(`Storage plugin remove error:`, error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
cleanupObjectURLs(createdObjectURLs, file.id);
|
|
234
|
+
files.value = files.value.filter((f) => f.id !== fileId);
|
|
235
|
+
emitter.emit("file:removed", file);
|
|
236
|
+
};
|
|
237
|
+
const removeFiles = (fileIds) => {
|
|
238
|
+
const removedFiles = files.value.filter((f) => fileIds.includes(f.id));
|
|
239
|
+
removedFiles.forEach((file) => {
|
|
240
|
+
cleanupObjectURLs(createdObjectURLs, file.id);
|
|
241
|
+
});
|
|
242
|
+
files.value = files.value.filter((f) => !fileIds.includes(f.id));
|
|
243
|
+
removedFiles.forEach((file) => {
|
|
244
|
+
emitter.emit("file:removed", file);
|
|
245
|
+
});
|
|
246
|
+
return removedFiles;
|
|
247
|
+
};
|
|
248
|
+
const clearFiles = () => {
|
|
249
|
+
const allFiles = [...files.value];
|
|
250
|
+
cleanupObjectURLs(createdObjectURLs);
|
|
251
|
+
files.value = [];
|
|
252
|
+
allFiles.forEach((file) => {
|
|
253
|
+
emitter.emit("file:removed", file);
|
|
254
|
+
});
|
|
255
|
+
return allFiles;
|
|
256
|
+
};
|
|
257
|
+
const getFileData = async (fileId) => {
|
|
258
|
+
const file = getFile(fileId);
|
|
259
|
+
if (file.size > 100 * 1024 * 1024) {
|
|
260
|
+
console.warn(
|
|
261
|
+
`getFileData: Loading large file (${(file.size / 1024 / 1024).toFixed(2)}MB) into memory. Consider using getFileURL() or getFileStream() for better performance.`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (file.source === "local") {
|
|
265
|
+
return file.data;
|
|
266
|
+
}
|
|
267
|
+
const response = await fetch(file.remoteUrl);
|
|
268
|
+
if (!response.ok) {
|
|
269
|
+
throw new Error(`Failed to fetch file: ${response.statusText}`);
|
|
270
|
+
}
|
|
271
|
+
return await response.blob();
|
|
272
|
+
};
|
|
273
|
+
const getFileURL = async (fileId) => {
|
|
274
|
+
const file = getFile(fileId);
|
|
275
|
+
if (file.source === "local") {
|
|
276
|
+
const existingURL = createdObjectURLs.get(file.id);
|
|
277
|
+
if (existingURL) {
|
|
278
|
+
return existingURL;
|
|
279
|
+
}
|
|
280
|
+
const objectURL = URL.createObjectURL(file.data);
|
|
281
|
+
createdObjectURLs.set(file.id, objectURL);
|
|
282
|
+
return objectURL;
|
|
283
|
+
}
|
|
284
|
+
return file.remoteUrl;
|
|
285
|
+
};
|
|
286
|
+
const getFileStream = async (fileId) => {
|
|
287
|
+
const file = getFile(fileId);
|
|
288
|
+
if (file.source === "local") {
|
|
289
|
+
return file.data.stream();
|
|
290
|
+
}
|
|
291
|
+
const response = await fetch(file.remoteUrl);
|
|
292
|
+
if (!response.ok || !response.body) {
|
|
293
|
+
throw new Error(`Failed to fetch file stream: ${response.statusText}`);
|
|
294
|
+
}
|
|
295
|
+
return response.body;
|
|
296
|
+
};
|
|
297
|
+
const replaceFileData = async (fileId, newData, newName, shouldAutoUpload) => {
|
|
298
|
+
const file = getFile(fileId);
|
|
299
|
+
cleanupObjectURLs(createdObjectURLs, fileId);
|
|
300
|
+
const updatedFile = {
|
|
301
|
+
...file,
|
|
302
|
+
source: "local",
|
|
303
|
+
data: newData,
|
|
304
|
+
name: newName || file.name,
|
|
305
|
+
size: newData.size,
|
|
306
|
+
status: "waiting",
|
|
307
|
+
// Mark as needing upload
|
|
308
|
+
progress: { percentage: 0 },
|
|
309
|
+
remoteUrl: void 0,
|
|
310
|
+
// Clear old remote URL
|
|
311
|
+
meta: {}
|
|
312
|
+
// Clear old metadata (thumbnails, dimensions, etc.)
|
|
313
|
+
};
|
|
314
|
+
const preprocessedFile = await runPluginStage("preprocess", updatedFile);
|
|
315
|
+
const finalFile = preprocessedFile || updatedFile;
|
|
316
|
+
const index = files.value.findIndex((f) => f.id === fileId);
|
|
317
|
+
if (index === -1) {
|
|
318
|
+
throw new Error(`File not found: ${fileId}`);
|
|
319
|
+
}
|
|
320
|
+
files.value[index] = finalFile;
|
|
321
|
+
emitter.emit("file:replaced", finalFile);
|
|
322
|
+
emitter.emit("file:added", finalFile);
|
|
323
|
+
const shouldUpload = shouldAutoUpload ?? options.autoProceed;
|
|
324
|
+
if (shouldUpload) {
|
|
325
|
+
upload();
|
|
326
|
+
}
|
|
327
|
+
return finalFile;
|
|
328
|
+
};
|
|
329
|
+
const reorderFile = (oldIndex, newIndex) => {
|
|
330
|
+
if (oldIndex === newIndex) {
|
|
331
|
+
if (import.meta.dev) {
|
|
332
|
+
console.warn("Cannot reorder file to the same index");
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (oldIndex < 0 || oldIndex >= files.value.length || newIndex < 0 || newIndex >= files.value.length) {
|
|
337
|
+
if (import.meta.dev) {
|
|
338
|
+
console.warn(`Cannot reorder file from ${oldIndex} to ${newIndex} since it is out of bounds`);
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const filesCopy = [...files.value];
|
|
343
|
+
const [movedFile] = filesCopy.splice(oldIndex, 1);
|
|
344
|
+
filesCopy.splice(newIndex, 0, movedFile);
|
|
345
|
+
files.value = filesCopy;
|
|
346
|
+
emitter.emit("files:reorder", { oldIndex, newIndex });
|
|
347
|
+
};
|
|
348
|
+
const getFile = (fileId) => {
|
|
349
|
+
const file = files.value.find((f) => f.id === fileId);
|
|
350
|
+
if (!file) {
|
|
351
|
+
throw new Error(`File not found: ${fileId}`);
|
|
352
|
+
}
|
|
353
|
+
return file;
|
|
354
|
+
};
|
|
355
|
+
const upload = async () => {
|
|
356
|
+
const filesToUpload = files.value.filter((f) => f.status === "waiting");
|
|
357
|
+
emitter.emit("upload:start", filesToUpload);
|
|
358
|
+
for (const file of filesToUpload) {
|
|
359
|
+
try {
|
|
360
|
+
const processedFile = await runPluginStage("process", file);
|
|
361
|
+
if (!processedFile) {
|
|
362
|
+
const error = createFileError(file, new Error("File processing failed"));
|
|
363
|
+
updateFile(file.id, { status: "error", error });
|
|
364
|
+
emitter.emit("file:error", { file, error });
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (processedFile.id !== file.id) {
|
|
368
|
+
files.value = files.value.map((f) => f.id === file.id ? processedFile : f);
|
|
369
|
+
}
|
|
370
|
+
updateFile(processedFile.id, { status: "uploading" });
|
|
371
|
+
const onProgress = (progress) => {
|
|
372
|
+
updateFile(processedFile.id, { progress: { percentage: progress } });
|
|
373
|
+
emitter.emit("upload:progress", { file: processedFile, progress });
|
|
374
|
+
};
|
|
375
|
+
const storagePlugin = getStoragePlugin();
|
|
376
|
+
let uploadResult;
|
|
377
|
+
let remoteUrl;
|
|
378
|
+
if (storagePlugin?.hooks.upload) {
|
|
379
|
+
const context = {
|
|
380
|
+
files: files.value,
|
|
381
|
+
options,
|
|
382
|
+
onProgress,
|
|
383
|
+
emit: getPluginEmitFn(storagePlugin.id)
|
|
384
|
+
};
|
|
385
|
+
const result = await storagePlugin.hooks.upload(processedFile, context);
|
|
386
|
+
uploadResult = result;
|
|
387
|
+
remoteUrl = result.url;
|
|
388
|
+
} else {
|
|
389
|
+
uploadResult = await uploadFn(processedFile, onProgress);
|
|
390
|
+
remoteUrl = typeof uploadResult === "string" ? uploadResult : void 0;
|
|
391
|
+
}
|
|
392
|
+
const currentFile = files.value.find((f) => f.id === processedFile.id);
|
|
393
|
+
const preview = currentFile?.preview || remoteUrl;
|
|
394
|
+
updateFile(processedFile.id, { status: "complete", uploadResult, remoteUrl, preview });
|
|
395
|
+
} catch (err) {
|
|
396
|
+
const error = createFileError(file, err);
|
|
397
|
+
updateFile(file.id, { status: "error", error });
|
|
398
|
+
emitter.emit("file:error", { file, error });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const completed = files.value.filter((f) => f.status === "complete");
|
|
402
|
+
emitter.emit("upload:complete", completed);
|
|
403
|
+
};
|
|
404
|
+
const reset = () => {
|
|
405
|
+
cleanupObjectURLs(createdObjectURLs);
|
|
406
|
+
files.value = [];
|
|
407
|
+
};
|
|
408
|
+
onBeforeUnmount(() => {
|
|
409
|
+
createdObjectURLs.forEach((url) => {
|
|
410
|
+
URL.revokeObjectURL(url);
|
|
411
|
+
});
|
|
412
|
+
createdObjectURLs.clear();
|
|
413
|
+
});
|
|
414
|
+
const callPluginHook = async (hook, stage, file, context) => {
|
|
415
|
+
switch (stage) {
|
|
416
|
+
case "validate":
|
|
417
|
+
await hook(file, context);
|
|
418
|
+
return file;
|
|
419
|
+
case "preprocess":
|
|
420
|
+
case "process":
|
|
421
|
+
case "complete":
|
|
422
|
+
if (!file) throw new Error("File is required for this hook type");
|
|
423
|
+
await hook(file, context);
|
|
424
|
+
return file;
|
|
425
|
+
default:
|
|
426
|
+
return file;
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
async function runPluginStage(stage, file) {
|
|
430
|
+
if (!options.plugins) return file;
|
|
431
|
+
let currentFile = file;
|
|
432
|
+
for (const plugin of options.plugins) {
|
|
433
|
+
const hook = plugin.hooks[stage];
|
|
434
|
+
if (hook) {
|
|
435
|
+
try {
|
|
436
|
+
const context = {
|
|
437
|
+
files: files.value,
|
|
438
|
+
options,
|
|
439
|
+
emit: getPluginEmitFn(plugin.id)
|
|
440
|
+
};
|
|
441
|
+
const result = await callPluginHook(hook, stage, currentFile, context);
|
|
442
|
+
if (!result) continue;
|
|
443
|
+
if (currentFile && "id" in result) {
|
|
444
|
+
currentFile = result;
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (currentFile) {
|
|
448
|
+
emitter.emit("file:error", { file: currentFile, error });
|
|
449
|
+
}
|
|
450
|
+
console.error(`Plugin ${plugin.id} ${stage} hook error:`, error);
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return currentFile;
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
// State
|
|
459
|
+
files: readonly(files),
|
|
460
|
+
totalProgress,
|
|
461
|
+
// Core Methods
|
|
462
|
+
addFiles,
|
|
463
|
+
addFile,
|
|
464
|
+
onGetRemoteFile,
|
|
465
|
+
onUpload,
|
|
466
|
+
removeFile,
|
|
467
|
+
removeFiles,
|
|
468
|
+
clearFiles,
|
|
469
|
+
reorderFile,
|
|
470
|
+
getFile,
|
|
471
|
+
upload,
|
|
472
|
+
reset,
|
|
473
|
+
status,
|
|
474
|
+
// File Data Access (for editing/processing)
|
|
475
|
+
getFileData,
|
|
476
|
+
getFileURL,
|
|
477
|
+
getFileStream,
|
|
478
|
+
replaceFileData,
|
|
479
|
+
updateFile,
|
|
480
|
+
initializeExistingFiles,
|
|
481
|
+
// Utilities
|
|
482
|
+
addPlugin,
|
|
483
|
+
// Events - autocomplete for core events, allow arbitrary strings for plugin events
|
|
484
|
+
on: emitter.on
|
|
485
|
+
};
|
|
486
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { UploadFile } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Events emitted by the image compressor plugin
|
|
4
|
+
* Note: These are automatically prefixed with "image-compressor:" by the plugin system
|
|
5
|
+
* e.g., "start" becomes "image-compressor:start"
|
|
6
|
+
*/
|
|
7
|
+
type ImageCompressorEvents = {
|
|
8
|
+
start: {
|
|
9
|
+
file: UploadFile;
|
|
10
|
+
originalSize: number;
|
|
11
|
+
};
|
|
12
|
+
complete: {
|
|
13
|
+
file: UploadFile;
|
|
14
|
+
originalSize: number;
|
|
15
|
+
compressedSize: number;
|
|
16
|
+
savedBytes: number;
|
|
17
|
+
};
|
|
18
|
+
skip: {
|
|
19
|
+
file: UploadFile;
|
|
20
|
+
reason: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
interface ImageCompressorOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Maximum width in pixels. Images wider than this will be scaled down.
|
|
26
|
+
* @default 1920
|
|
27
|
+
*/
|
|
28
|
+
maxWidth?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Maximum height in pixels. Images taller than this will be scaled down.
|
|
31
|
+
* @default 1920
|
|
32
|
+
*/
|
|
33
|
+
maxHeight?: number;
|
|
34
|
+
/**
|
|
35
|
+
* JPEG/WebP quality (0-1). Lower values = smaller files but worse quality.
|
|
36
|
+
* @default 0.85
|
|
37
|
+
*/
|
|
38
|
+
quality?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Output format. Use 'auto' to preserve original format.
|
|
41
|
+
* @default 'auto'
|
|
42
|
+
*/
|
|
43
|
+
outputFormat?: "jpeg" | "webp" | "png" | "auto";
|
|
44
|
+
/**
|
|
45
|
+
* Only compress images larger than this size (in bytes).
|
|
46
|
+
* @default 100000 (100KB)
|
|
47
|
+
*/
|
|
48
|
+
minSizeToCompress?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Preserve EXIF metadata (orientation, etc)
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
53
|
+
preserveMetadata?: boolean;
|
|
54
|
+
}
|
|
55
|
+
export declare const PluginImageCompressor: (options: ImageCompressorOptions) => import("../types.js").ProcessingPlugin<any, ImageCompressorEvents>;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { defineProcessingPlugin } from "../types.js";
|
|
2
|
+
export const PluginImageCompressor = defineProcessingPlugin((pluginOptions) => {
|
|
3
|
+
const {
|
|
4
|
+
maxWidth = 1920,
|
|
5
|
+
maxHeight = 1920,
|
|
6
|
+
quality = 0.85,
|
|
7
|
+
outputFormat = "auto",
|
|
8
|
+
minSizeToCompress = 1e5
|
|
9
|
+
// 100KB
|
|
10
|
+
} = pluginOptions;
|
|
11
|
+
return {
|
|
12
|
+
id: "image-compressor",
|
|
13
|
+
hooks: {
|
|
14
|
+
process: async (file, context) => {
|
|
15
|
+
if (!file.mimeType.startsWith("image/")) {
|
|
16
|
+
return file;
|
|
17
|
+
}
|
|
18
|
+
if (file.source !== "local") {
|
|
19
|
+
context.emit("skip", { file, reason: "Remote file, no local data to compress" });
|
|
20
|
+
return file;
|
|
21
|
+
}
|
|
22
|
+
if (file.mimeType === "image/gif") {
|
|
23
|
+
context.emit("skip", { file, reason: "GIF format not supported" });
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
if (file.mimeType === "image/svg+xml") {
|
|
27
|
+
context.emit("skip", { file, reason: "SVG already optimized" });
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
if (file.size < minSizeToCompress) {
|
|
31
|
+
context.emit("skip", {
|
|
32
|
+
file,
|
|
33
|
+
reason: `File size (${file.size} bytes) below minimum threshold`
|
|
34
|
+
});
|
|
35
|
+
return file;
|
|
36
|
+
}
|
|
37
|
+
context.emit("start", { file, originalSize: file.size });
|
|
38
|
+
try {
|
|
39
|
+
const sourceUrl = URL.createObjectURL(file.data);
|
|
40
|
+
const image = new Image();
|
|
41
|
+
image.src = sourceUrl;
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
image.onload = () => resolve();
|
|
44
|
+
image.onerror = () => reject(new Error("Failed to load image"));
|
|
45
|
+
});
|
|
46
|
+
const needsResize = image.width > maxWidth || image.height > maxHeight;
|
|
47
|
+
if (!needsResize && outputFormat === "auto") {
|
|
48
|
+
URL.revokeObjectURL(sourceUrl);
|
|
49
|
+
context.emit("skip", {
|
|
50
|
+
file,
|
|
51
|
+
reason: "Image within size limits and format is auto"
|
|
52
|
+
});
|
|
53
|
+
return file;
|
|
54
|
+
}
|
|
55
|
+
let targetWidth = image.width;
|
|
56
|
+
let targetHeight = image.height;
|
|
57
|
+
if (needsResize) {
|
|
58
|
+
const widthRatio = maxWidth / image.width;
|
|
59
|
+
const heightRatio = maxHeight / image.height;
|
|
60
|
+
const ratio = Math.min(widthRatio, heightRatio);
|
|
61
|
+
targetWidth = Math.round(image.width * ratio);
|
|
62
|
+
targetHeight = Math.round(image.height * ratio);
|
|
63
|
+
}
|
|
64
|
+
const canvas = document.createElement("canvas");
|
|
65
|
+
canvas.width = targetWidth;
|
|
66
|
+
canvas.height = targetHeight;
|
|
67
|
+
const ctx = canvas.getContext("2d");
|
|
68
|
+
if (!ctx) {
|
|
69
|
+
throw new Error("Could not get canvas context");
|
|
70
|
+
}
|
|
71
|
+
ctx.imageSmoothingEnabled = true;
|
|
72
|
+
ctx.imageSmoothingQuality = "high";
|
|
73
|
+
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
|
74
|
+
let mimeType = file.mimeType;
|
|
75
|
+
if (outputFormat === "jpeg") {
|
|
76
|
+
mimeType = "image/jpeg";
|
|
77
|
+
} else if (outputFormat === "webp") {
|
|
78
|
+
mimeType = "image/webp";
|
|
79
|
+
} else if (outputFormat === "png") {
|
|
80
|
+
mimeType = "image/png";
|
|
81
|
+
}
|
|
82
|
+
const compressedBlob = await new Promise((resolve, reject) => {
|
|
83
|
+
canvas.toBlob(
|
|
84
|
+
(blob) => {
|
|
85
|
+
if (blob) {
|
|
86
|
+
resolve(blob);
|
|
87
|
+
} else {
|
|
88
|
+
reject(new Error("Failed to compress image"));
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
mimeType,
|
|
92
|
+
quality
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
URL.revokeObjectURL(sourceUrl);
|
|
96
|
+
if (compressedBlob.size < file.size) {
|
|
97
|
+
const savedBytes = file.size - compressedBlob.size;
|
|
98
|
+
context.emit("complete", {
|
|
99
|
+
file,
|
|
100
|
+
originalSize: file.size,
|
|
101
|
+
compressedSize: compressedBlob.size,
|
|
102
|
+
savedBytes
|
|
103
|
+
});
|
|
104
|
+
let newId = file.id;
|
|
105
|
+
if (outputFormat !== "auto" && outputFormat !== file.meta.extension) {
|
|
106
|
+
const extension = outputFormat === "jpeg" ? "jpg" : outputFormat;
|
|
107
|
+
newId = file.id.replace(/\.[^.]+$/, `.${extension}`);
|
|
108
|
+
}
|
|
109
|
+
file.meta.originalSize = file.size;
|
|
110
|
+
file.meta.compressionRatio = ((file.size - compressedBlob.size) / file.size * 100).toFixed(1);
|
|
111
|
+
return {
|
|
112
|
+
...file,
|
|
113
|
+
id: newId,
|
|
114
|
+
data: compressedBlob,
|
|
115
|
+
size: compressedBlob.size,
|
|
116
|
+
mimeType,
|
|
117
|
+
meta: {
|
|
118
|
+
...file.meta,
|
|
119
|
+
compressed: true,
|
|
120
|
+
originalSize: file.size,
|
|
121
|
+
compressionRatio: file.meta.compressionRatio
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
context.emit("skip", {
|
|
126
|
+
file,
|
|
127
|
+
reason: "Compressed version larger than original"
|
|
128
|
+
});
|
|
129
|
+
return file;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.warn(`Image compression failed for ${file.name}:`, error);
|
|
132
|
+
return file;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
});
|