@tekus/design-system 5.34.0 → 5.36.0
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/fesm2022/tekus-design-system-components-action-group.mjs +1 -1
- package/fesm2022/tekus-design-system-components-action-group.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-button.mjs +24 -3
- package/fesm2022/tekus-design-system-components-button.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-card.mjs +1 -1
- package/fesm2022/tekus-design-system-components-card.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-carousel.mjs +234 -0
- package/fesm2022/tekus-design-system-components-carousel.mjs.map +1 -0
- package/fesm2022/tekus-design-system-components-color-picker.mjs +1 -1
- package/fesm2022/tekus-design-system-components-color-picker.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-drawer.mjs +1 -1
- package/fesm2022/tekus-design-system-components-drawer.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-dropdown.mjs +1 -1
- package/fesm2022/tekus-design-system-components-dropdown.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-fallback-view.mjs +1 -1
- package/fesm2022/tekus-design-system-components-fallback-view.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-header.mjs +29 -0
- package/fesm2022/tekus-design-system-components-header.mjs.map +1 -0
- package/fesm2022/tekus-design-system-components-icon.mjs +2 -2
- package/fesm2022/tekus-design-system-components-icon.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-modal.mjs +51 -31
- package/fesm2022/tekus-design-system-components-modal.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-process-steps.mjs +132 -0
- package/fesm2022/tekus-design-system-components-process-steps.mjs.map +1 -0
- package/fesm2022/tekus-design-system-components-sidebar-layout.mjs +20 -6
- package/fesm2022/tekus-design-system-components-sidebar-layout.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-stepper.mjs +1 -1
- package/fesm2022/tekus-design-system-components-stepper.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-table.mjs +1 -1
- package/fesm2022/tekus-design-system-components-table.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-toast.mjs +1 -1
- package/fesm2022/tekus-design-system-components-toast.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-toolbar.mjs +1 -1
- package/fesm2022/tekus-design-system-components-toolbar.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-topbar.mjs +1 -1
- package/fesm2022/tekus-design-system-components-topbar.mjs.map +1 -1
- package/fesm2022/tekus-design-system-components-uploader.mjs +1256 -0
- package/fesm2022/tekus-design-system-components-uploader.mjs.map +1 -0
- package/package.json +17 -1
- package/types/tekus-design-system-components-button.d.ts +20 -1
- package/types/tekus-design-system-components-carousel.d.ts +200 -0
- package/types/tekus-design-system-components-header.d.ts +33 -0
- package/types/tekus-design-system-components-modal.d.ts +31 -13
- package/types/tekus-design-system-components-process-steps.d.ts +72 -0
- package/types/tekus-design-system-components-sidebar-layout.d.ts +11 -1
- package/types/tekus-design-system-components-uploader.d.ts +524 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, Injector, signal, computed, untracked, runInInjectionContext, Injectable, input, output, ChangeDetectionStrategy, Component, viewChild, resource } from '@angular/core';
|
|
3
|
+
import { IconComponent } from '@tekus/design-system/components/icon';
|
|
4
|
+
import { ProcessStepsComponent } from '@tekus/design-system/components/process-steps';
|
|
5
|
+
import { toObservable } from '@angular/core/rxjs-interop';
|
|
6
|
+
import { from, concatMap, catchError, throwError, toArray, map, expand, EMPTY, takeWhile, timer, tap, filter, defaultIfEmpty, last, take, finalize, isObservable, firstValueFrom, of, fromEvent } from 'rxjs';
|
|
7
|
+
import { ButtonComponent } from '@tekus/design-system/components/button';
|
|
8
|
+
import { ProgressBarComponent } from '@tekus/design-system/components/progress-bar';
|
|
9
|
+
import { HttpBackend, HttpClient, HttpEventType } from '@angular/common/http';
|
|
10
|
+
|
|
11
|
+
/** Queue backing store — provided by `provideUploader()`, actively injected by `UploaderQueueService`. */
|
|
12
|
+
const UPLOADER_STORE = new InjectionToken('UPLOADER_STORE');
|
|
13
|
+
/** Client-side preview/metadata generator — provided by `provideUploader()`, actively injected internally. */
|
|
14
|
+
const UPLOADER_PREVIEW_PROVIDER = new InjectionToken('UPLOADER_PREVIEW_PROVIDER');
|
|
15
|
+
/**
|
|
16
|
+
* HTTP transport — provided by `provideUploader()`, but never injected
|
|
17
|
+
* internally. Registered purely so the consumer's own `steps`/
|
|
18
|
+
* `multipart.uploadPart` functions can `inject(UPLOADER_TRANSPORT)` instead
|
|
19
|
+
* of instantiating `HttpClient` themselves.
|
|
20
|
+
*/
|
|
21
|
+
const UPLOADER_TRANSPORT = new InjectionToken('UPLOADER_TRANSPORT');
|
|
22
|
+
|
|
23
|
+
/** Runs `config.steps` one after another, reporting progress as each resolves. */
|
|
24
|
+
function runSequentialStepsFlow(params) {
|
|
25
|
+
const { item, payload, context, config, store, handleError, reportProgress } = params;
|
|
26
|
+
const steps = config.steps || [];
|
|
27
|
+
const totalSteps = steps.length;
|
|
28
|
+
return from(steps).pipe(concatMap((step, index) => {
|
|
29
|
+
return executeStep(step, { file: item.file, context, payload, item }, index, totalSteps, store, config, reportProgress).pipe(catchError((err) => {
|
|
30
|
+
handleError(item.id, err, config, context);
|
|
31
|
+
return throwError(() => err);
|
|
32
|
+
}));
|
|
33
|
+
}), toArray(), map(() => context));
|
|
34
|
+
}
|
|
35
|
+
/** Runs `config.multipart`: `init` → chunks sequentially via `uploadPart` → `complete`. */
|
|
36
|
+
function runMultipartFlow(params) {
|
|
37
|
+
const { item, payload, context, config, store, handleError, reportProgress } = params;
|
|
38
|
+
const multipart = config.multipart;
|
|
39
|
+
const chunkSize = multipart.chunkSizeMB * 1024 * 1024;
|
|
40
|
+
const totalChunks = Math.ceil(item.file.size / chunkSize);
|
|
41
|
+
return multipart.init({ file: item.file, payload }).pipe(map((initResult) => {
|
|
42
|
+
Object.assign(context, initResult);
|
|
43
|
+
reportProgress(0);
|
|
44
|
+
return { partNumber: 0 };
|
|
45
|
+
}), expand(({ partNumber }) => {
|
|
46
|
+
if (partNumber >= totalChunks) {
|
|
47
|
+
return EMPTY;
|
|
48
|
+
}
|
|
49
|
+
const start = partNumber * chunkSize;
|
|
50
|
+
const end = Math.min(start + chunkSize, item.file.size);
|
|
51
|
+
const chunk = item.file.slice(start, end);
|
|
52
|
+
return executeChunk(multipart, {
|
|
53
|
+
file: item.file,
|
|
54
|
+
chunk,
|
|
55
|
+
partNumber,
|
|
56
|
+
context,
|
|
57
|
+
payload,
|
|
58
|
+
metadata: item.metadata
|
|
59
|
+
}, totalChunks, store, item.id, config, reportProgress);
|
|
60
|
+
}), takeWhile(({ partNumber }) => partNumber <= totalChunks, true), toArray(), concatMap(() => {
|
|
61
|
+
const scale = 100 / (config.totalSteps || 1);
|
|
62
|
+
reportProgress(Math.round(scale), config.resolveText?.('FINISHING') ?? 'FINISHING');
|
|
63
|
+
return multipart.complete({ context, payload }).pipe(map(() => {
|
|
64
|
+
return context;
|
|
65
|
+
}));
|
|
66
|
+
}), catchError((err) => {
|
|
67
|
+
handleError(item.id, err, config, context);
|
|
68
|
+
return throwError(() => err);
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Runs `config.polling` after the byte upload succeeds: calls `checkStatus`
|
|
73
|
+
* every `intervalMs`, merging `progress`/`statusText`/`steps`/`stepsStatus`/
|
|
74
|
+
* `stepsTitle`/`stepsDescription`/`stepsCountdownSeconds`/`stepsCountdownLabel`
|
|
75
|
+
* into the item as they arrive, until `status` stops being `'loading'`.
|
|
76
|
+
*/
|
|
77
|
+
function runPolling(params) {
|
|
78
|
+
const { id, payload, context, config, store, handleError } = params;
|
|
79
|
+
const pollConfig = config.polling;
|
|
80
|
+
const interval = pollConfig.intervalMs || 3000;
|
|
81
|
+
return timer(0, interval).pipe(concatMap(() => pollConfig.checkStatus({ context, payload })), takeWhile((response) => response.status === 'loading', true), map((response) => {
|
|
82
|
+
if (response.status === 'error') {
|
|
83
|
+
throw new Error(response.error || 'POLLING_FAILED');
|
|
84
|
+
}
|
|
85
|
+
const updates = {};
|
|
86
|
+
if (response.progress !== undefined) {
|
|
87
|
+
updates.progress = response.progress;
|
|
88
|
+
}
|
|
89
|
+
if (response.statusText !== undefined) {
|
|
90
|
+
updates.statusText = response.statusText;
|
|
91
|
+
}
|
|
92
|
+
if (response.steps !== undefined) {
|
|
93
|
+
updates.steps = response.steps;
|
|
94
|
+
}
|
|
95
|
+
if (response.stepsStatus !== undefined) {
|
|
96
|
+
updates.stepsStatus = response.stepsStatus;
|
|
97
|
+
}
|
|
98
|
+
if (response.stepsTitle !== undefined) {
|
|
99
|
+
updates.stepsTitle = response.stepsTitle;
|
|
100
|
+
}
|
|
101
|
+
if (response.stepsDescription !== undefined) {
|
|
102
|
+
updates.stepsDescription = response.stepsDescription;
|
|
103
|
+
}
|
|
104
|
+
if (response.stepsCountdownSeconds !== undefined) {
|
|
105
|
+
updates.stepsCountdownSeconds = response.stepsCountdownSeconds;
|
|
106
|
+
}
|
|
107
|
+
if (response.stepsCountdownLabel !== undefined) {
|
|
108
|
+
updates.stepsCountdownLabel = response.stepsCountdownLabel;
|
|
109
|
+
}
|
|
110
|
+
if (Object.keys(updates).length > 0) {
|
|
111
|
+
store.update(id, updates);
|
|
112
|
+
}
|
|
113
|
+
return response;
|
|
114
|
+
}), catchError((err) => {
|
|
115
|
+
handleError(id, err, config, context);
|
|
116
|
+
return throwError(() => err);
|
|
117
|
+
}), toArray(), map((responses) => responses.at(-1)));
|
|
118
|
+
}
|
|
119
|
+
function executeStep(step, params, index, totalSteps, store, config, reportProgress) {
|
|
120
|
+
const scale = 100 / (config.totalSteps || 1);
|
|
121
|
+
return step(params).pipe(map((result) => {
|
|
122
|
+
Object.assign(params.context, result);
|
|
123
|
+
const id = params.item?.id;
|
|
124
|
+
if (id) {
|
|
125
|
+
const progress = Math.round(((index + 1) / totalSteps) * scale);
|
|
126
|
+
reportProgress(progress);
|
|
127
|
+
}
|
|
128
|
+
config.hooks?.onStepComplete?.({
|
|
129
|
+
stepIndex: index,
|
|
130
|
+
context: params.context
|
|
131
|
+
});
|
|
132
|
+
return params.context;
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
function executeChunk(multipart, params, totalChunks, store, itemId, config, reportProgress) {
|
|
136
|
+
const scale = 100 / (config.totalSteps || 1);
|
|
137
|
+
return multipart.uploadPart(params).pipe(tap((val) => {
|
|
138
|
+
if (typeof val === 'number' && totalChunks > 0) {
|
|
139
|
+
const currentPartOffset = (params.partNumber / totalChunks) * scale;
|
|
140
|
+
const chunkContribution = (val / 100) * (scale / totalChunks);
|
|
141
|
+
reportProgress(Math.round(currentPartOffset + chunkContribution));
|
|
142
|
+
}
|
|
143
|
+
}), filter((val) => typeof val === 'object' && val !== null), defaultIfEmpty({}), last(), map((result) => {
|
|
144
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
145
|
+
Object.assign(params.context, result);
|
|
146
|
+
}
|
|
147
|
+
const progress = totalChunks > 0
|
|
148
|
+
? Math.round(((params.partNumber + 1) / totalChunks) * scale)
|
|
149
|
+
: Math.round(scale);
|
|
150
|
+
reportProgress(progress);
|
|
151
|
+
return { partNumber: params.partNumber + 1 };
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Fallback values applied by `mergeConfigWithDefaults` when the consumer omits them. */
|
|
156
|
+
const UPLOADER_DEFAULTS = {
|
|
157
|
+
chunkSizeMB: 5,
|
|
158
|
+
concurrencyLimit: 4,
|
|
159
|
+
pollingIntervalMs: 3000
|
|
160
|
+
};
|
|
161
|
+
/** Fills in `UPLOADER_DEFAULTS` for whatever `config` leaves unset — never overrides an explicit value. */
|
|
162
|
+
function mergeConfigWithDefaults(config) {
|
|
163
|
+
let refined = { ...config };
|
|
164
|
+
if (refined.concurrencyLimit === undefined) {
|
|
165
|
+
refined = {
|
|
166
|
+
...refined,
|
|
167
|
+
concurrencyLimit: UPLOADER_DEFAULTS.concurrencyLimit
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (refined.multipart) {
|
|
171
|
+
refined = {
|
|
172
|
+
...refined,
|
|
173
|
+
multipart: {
|
|
174
|
+
...refined.multipart,
|
|
175
|
+
chunkSizeMB: refined.multipart.chunkSizeMB ?? UPLOADER_DEFAULTS.chunkSizeMB
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (refined.polling) {
|
|
180
|
+
refined = {
|
|
181
|
+
...refined,
|
|
182
|
+
polling: {
|
|
183
|
+
...refined.polling,
|
|
184
|
+
intervalMs: refined.polling.intervalMs ?? UPLOADER_DEFAULTS.pollingIntervalMs
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return refined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Checks a file against `UploadFlowConfig.restrictions` before it's queued. */
|
|
192
|
+
class UploaderValidator {
|
|
193
|
+
/**
|
|
194
|
+
* Checks integrity (non-empty) first, then extension/size — `rules` (per-extension
|
|
195
|
+
* `maxSizeMB`) if set, otherwise the legacy `allowedExtensions`/`maxSizeMB` pair.
|
|
196
|
+
*/
|
|
197
|
+
static validate(file, restrictions) {
|
|
198
|
+
const integrityResult = this.checkFileIntegrity(file);
|
|
199
|
+
if (!integrityResult.isValid) {
|
|
200
|
+
return integrityResult;
|
|
201
|
+
}
|
|
202
|
+
return restrictions.rules?.length
|
|
203
|
+
? this.checkGranularRules(file, restrictions.rules)
|
|
204
|
+
: this.checkLegacyRestrictions(file, restrictions);
|
|
205
|
+
}
|
|
206
|
+
static checkFileIntegrity(file) {
|
|
207
|
+
if (file.size === 0) {
|
|
208
|
+
return {
|
|
209
|
+
isValid: false,
|
|
210
|
+
error: 'FILE_CORRUPT',
|
|
211
|
+
detail: { fileName: file.name, fileSize: 0 }
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return { isValid: true };
|
|
215
|
+
}
|
|
216
|
+
static checkGranularRules(file, rules) {
|
|
217
|
+
const fileName = file.name.toLowerCase();
|
|
218
|
+
const matchingRule = rules.find(rule => rule.extensions.some(ext => fileName.endsWith(ext.toLowerCase())));
|
|
219
|
+
return matchingRule
|
|
220
|
+
? this.checkRuleMaxSize(file, matchingRule)
|
|
221
|
+
: this.buildGranularInvalidFormatResult(fileName, rules);
|
|
222
|
+
}
|
|
223
|
+
static buildGranularInvalidFormatResult(fileName, rules) {
|
|
224
|
+
return {
|
|
225
|
+
isValid: false,
|
|
226
|
+
error: 'INVALID_FORMAT',
|
|
227
|
+
detail: {
|
|
228
|
+
fileName,
|
|
229
|
+
allowedExtensionsByType: rules.map(rule => rule.extensions)
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
static checkRuleMaxSize(file, rule) {
|
|
234
|
+
if (rule.maxSizeMB !== undefined && file.size > rule.maxSizeMB * 1024 * 1024) {
|
|
235
|
+
return {
|
|
236
|
+
isValid: false,
|
|
237
|
+
error: 'FILE_TOO_LARGE',
|
|
238
|
+
detail: { fileSize: file.size, maxSizeMB: rule.maxSizeMB }
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return { isValid: true };
|
|
242
|
+
}
|
|
243
|
+
static checkLegacyRestrictions(file, restrictions) {
|
|
244
|
+
const maxSizeResult = this.checkLegacyMaxSize(file, restrictions);
|
|
245
|
+
if (!maxSizeResult.isValid) {
|
|
246
|
+
return maxSizeResult;
|
|
247
|
+
}
|
|
248
|
+
return this.checkLegacyExtension(file, restrictions);
|
|
249
|
+
}
|
|
250
|
+
static checkLegacyMaxSize(file, restrictions) {
|
|
251
|
+
if (restrictions.maxSizeMB &&
|
|
252
|
+
file.size > restrictions.maxSizeMB * 1024 * 1024) {
|
|
253
|
+
return {
|
|
254
|
+
isValid: false,
|
|
255
|
+
error: 'FILE_TOO_LARGE',
|
|
256
|
+
detail: { fileSize: file.size, maxSizeMB: restrictions.maxSizeMB }
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return { isValid: true };
|
|
260
|
+
}
|
|
261
|
+
static checkLegacyExtension(file, restrictions) {
|
|
262
|
+
const fileName = file.name.toLowerCase();
|
|
263
|
+
const isAllowed = !restrictions.allowedExtensions ||
|
|
264
|
+
restrictions.allowedExtensions.some(ext => fileName.endsWith(ext.toLowerCase()));
|
|
265
|
+
return isAllowed
|
|
266
|
+
? { isValid: true }
|
|
267
|
+
: this.buildLegacyInvalidFormatResult(fileName, restrictions);
|
|
268
|
+
}
|
|
269
|
+
static buildLegacyInvalidFormatResult(fileName, restrictions) {
|
|
270
|
+
return {
|
|
271
|
+
isValid: false,
|
|
272
|
+
error: 'INVALID_FORMAT',
|
|
273
|
+
detail: { fileName, allowedExtensions: restrictions.allowedExtensions }
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const STATUS_KEY_MAP = {
|
|
279
|
+
loading: 'UPLOAD_IN_PROGRESS',
|
|
280
|
+
success: 'FULL_LOAD',
|
|
281
|
+
error: 'LOAD_ERROR',
|
|
282
|
+
ready: 'READY',
|
|
283
|
+
analyzing: 'ANALYZING',
|
|
284
|
+
transforming: 'TRANSFORMING',
|
|
285
|
+
queued: 'PROCESSING'
|
|
286
|
+
};
|
|
287
|
+
const ERROR_CODES = new Set([
|
|
288
|
+
'FILE_CORRUPT',
|
|
289
|
+
'INVALID_FORMAT',
|
|
290
|
+
'FILE_TOO_LARGE',
|
|
291
|
+
'ANALYSIS_FAILED',
|
|
292
|
+
'PROCESS_FAILED',
|
|
293
|
+
'EXTERNAL_VALIDATION_FAILED',
|
|
294
|
+
'POLLING_FAILED',
|
|
295
|
+
'NO_UPLOAD_STRATEGY_CONFIGURED'
|
|
296
|
+
]);
|
|
297
|
+
/**
|
|
298
|
+
* The engine — orchestrates the whole upload flow (validation, analysis,
|
|
299
|
+
* transform, `steps`/`multipart`, `polling`, lifecycle hooks) driven purely
|
|
300
|
+
* by the `UploadFlowConfig` passed to `addToQueue`. Not `providedIn: 'root'`;
|
|
301
|
+
* `provideUploader()` gives each `tk-uploader` its own instance.
|
|
302
|
+
*/
|
|
303
|
+
class UploaderQueueService {
|
|
304
|
+
constructor() {
|
|
305
|
+
this.store = inject(UPLOADER_STORE);
|
|
306
|
+
this.previewProvider = inject(UPLOADER_PREVIEW_PROVIDER);
|
|
307
|
+
this.injector = inject(Injector);
|
|
308
|
+
this.activeSubscriptions = new Map();
|
|
309
|
+
this.isManualTriggered = signal(false, ...(ngDevMode ? [{ debugName: "isManualTriggered" }] : /* istanbul ignore next */ []));
|
|
310
|
+
this.uploadQueue = this.store.uploadQueue;
|
|
311
|
+
/** Every state transition recorded so far — not currently surfaced anywhere by default. */
|
|
312
|
+
this.auditHistory = signal([], ...(ngDevMode ? [{ debugName: "auditHistory" }] : /* istanbul ignore next */ []));
|
|
313
|
+
/** Whether any item is actively `loading` right now. */
|
|
314
|
+
this.isUploading = computed(() => this.uploadQueue().some((i) => i.status === 'loading'), ...(ngDevMode ? [{ debugName: "isUploading" }] : /* istanbul ignore next */ []));
|
|
315
|
+
/** Whether any item is mid-flight (`analyzing`/`transforming`/`loading`) — client-side or network work in progress. */
|
|
316
|
+
this.isBusy = computed(() => this.uploadQueue().some((i) => ['analyzing', 'transforming', 'loading'].includes(i.status)), ...(ngDevMode ? [{ debugName: "isBusy" }] : /* istanbul ignore next */ []));
|
|
317
|
+
/** `!isBusy()` — whether it's safe to close/navigate away without losing in-flight work. */
|
|
318
|
+
this.canSafetyClose = computed(() => !this.isBusy(), ...(ngDevMode ? [{ debugName: "canSafetyClose" }] : /* istanbul ignore next */ []));
|
|
319
|
+
}
|
|
320
|
+
ngOnDestroy() {
|
|
321
|
+
this.clearQueue();
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Adds one or more files to the queue and starts them through validation →
|
|
325
|
+
* analysis → transform → (auto-start or wait for `startUpload`/`startAllReady`).
|
|
326
|
+
* `config` becomes `currentConfig`, reused by every later call until the next `addToQueue`.
|
|
327
|
+
*/
|
|
328
|
+
addToQueue(files, config) {
|
|
329
|
+
this.isManualTriggered.set(false);
|
|
330
|
+
const mergedConfig = mergeConfigWithDefaults(config);
|
|
331
|
+
this.currentConfig = mergedConfig;
|
|
332
|
+
const fileList = Array.isArray(files) ? files : [files];
|
|
333
|
+
fileList.forEach((file) => {
|
|
334
|
+
const id = crypto.randomUUID();
|
|
335
|
+
const newItem = {
|
|
336
|
+
id,
|
|
337
|
+
file,
|
|
338
|
+
progress: 0,
|
|
339
|
+
status: 'analyzing',
|
|
340
|
+
statusKey: this.getStatusKey('analyzing'),
|
|
341
|
+
statusText: this.getStatusText('analyzing')
|
|
342
|
+
};
|
|
343
|
+
this.addAuditEntry(id, 'none', 'analyzing');
|
|
344
|
+
this.store.add(newItem);
|
|
345
|
+
mergedConfig.hooks?.onFileAdded?.(file);
|
|
346
|
+
untracked(() => {
|
|
347
|
+
if (mergedConfig.restrictions) {
|
|
348
|
+
const result = UploaderValidator.validate(file, mergedConfig.restrictions);
|
|
349
|
+
if (!result.isValid) {
|
|
350
|
+
this.handleError(id, result.error, mergedConfig, undefined, result.detail, 'invalid');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
this.runAnalysis(id, file, mergedConfig);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
runAnalysis(id, file, config) {
|
|
359
|
+
const analysisResource = runInInjectionContext(this.injector, () => this.previewProvider.generatePreview(file));
|
|
360
|
+
const sub = toObservable(analysisResource.value, {
|
|
361
|
+
injector: this.injector
|
|
362
|
+
})
|
|
363
|
+
.pipe(filter((val) => !!val), take(1), finalize(() => {
|
|
364
|
+
this.activeSubscriptions.delete(`${id}_analysis`);
|
|
365
|
+
analysisResource.destroy();
|
|
366
|
+
}))
|
|
367
|
+
.subscribe((analysis) => {
|
|
368
|
+
if (analysis) {
|
|
369
|
+
this.handleAnalysisResult(id, analysis, config);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
this.activeSubscriptions.set(`${id}_analysis`, sub);
|
|
373
|
+
}
|
|
374
|
+
async handleAnalysisResult(id, analysis, config) {
|
|
375
|
+
if (!this.uploadQueue().some((i) => i.id === id)) {
|
|
376
|
+
// The item was removed while analysis was still in flight — the store never
|
|
377
|
+
// got `previewUrl`, so `removeFromQueue`'s own revoke couldn't find it either.
|
|
378
|
+
if (analysis.previewUrl?.startsWith('blob:')) {
|
|
379
|
+
URL.revokeObjectURL(analysis.previewUrl);
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (analysis.analysisError) {
|
|
384
|
+
const errorKey = analysis.technicalError || 'ANALYSIS_FAILED';
|
|
385
|
+
this.handleError(id, errorKey, config, {}, analysis.technicalError);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.patchItem(id, {
|
|
389
|
+
previewUrl: analysis.previewUrl,
|
|
390
|
+
previewBlob: analysis.previewBlob,
|
|
391
|
+
previewBase64: analysis.previewBase64,
|
|
392
|
+
metadata: analysis.metadata
|
|
393
|
+
});
|
|
394
|
+
const isValid = await this.validateExternalRestrictions(id, analysis, config);
|
|
395
|
+
if (!isValid) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
await this.runTransformation(id, config);
|
|
399
|
+
}
|
|
400
|
+
async validateExternalRestrictions(id, analysis, config) {
|
|
401
|
+
if (config.hooks?.validate) {
|
|
402
|
+
let validation;
|
|
403
|
+
const validation$ = config.hooks.validate({
|
|
404
|
+
file: this.uploadQueue().find((i) => i.id === id)
|
|
405
|
+
.file,
|
|
406
|
+
metadata: analysis.metadata
|
|
407
|
+
});
|
|
408
|
+
if (isObservable(validation$)) {
|
|
409
|
+
validation = await firstValueFrom(validation$.pipe(catchError(() => of(false))));
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
validation = validation$;
|
|
413
|
+
}
|
|
414
|
+
if (validation !== true) {
|
|
415
|
+
this.handleError(id, typeof validation === 'string' && validation
|
|
416
|
+
? new Error(validation)
|
|
417
|
+
: new Error('EXTERNAL_VALIDATION_FAILED'), config, {});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
async runTransformation(id, config) {
|
|
424
|
+
if (config.hooks?.onTransform) {
|
|
425
|
+
this.patchItem(id, { status: 'transforming' });
|
|
426
|
+
try {
|
|
427
|
+
const item = this.uploadQueue().find((i) => i.id === id);
|
|
428
|
+
const payload = await config.hooks.onTransform(item);
|
|
429
|
+
this.patchItem(id, { payload });
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
this.handleError(id, err, config, {});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const isAutoStart = typeof config.autoStart === 'function'
|
|
437
|
+
? config.autoStart()
|
|
438
|
+
: config.autoStart;
|
|
439
|
+
if (isAutoStart !== false || this.isManualTriggered()) {
|
|
440
|
+
this.patchItem(id, { status: 'queued' });
|
|
441
|
+
this.checkConcurrencyAndProcess(config);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
this.patchItem(id, { status: 'ready' });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** Manually starts a single `ready` item (when `autoStart` is `false`). */
|
|
448
|
+
startUpload(id) {
|
|
449
|
+
if (!this.currentConfig) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.patchItem(id, { status: 'queued' });
|
|
453
|
+
this.checkConcurrencyAndProcess(this.currentConfig);
|
|
454
|
+
}
|
|
455
|
+
/** Manually starts every `ready` item at once (when `autoStart` is `false`). */
|
|
456
|
+
startAllReady() {
|
|
457
|
+
if (!this.currentConfig) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
this.isManualTriggered.set(true);
|
|
461
|
+
this.uploadQueue().forEach((item) => {
|
|
462
|
+
if (item.status === 'ready') {
|
|
463
|
+
this.patchItem(item.id, { status: 'queued' });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
this.checkConcurrencyAndProcess(this.currentConfig);
|
|
467
|
+
}
|
|
468
|
+
checkConcurrencyAndProcess(config) {
|
|
469
|
+
const limit = config.concurrencyLimit;
|
|
470
|
+
const activeCount = this.uploadQueue().filter((i) => i.status === 'loading').length;
|
|
471
|
+
if (activeCount < limit) {
|
|
472
|
+
const nextItem = this.uploadQueue().find((i) => i.status === 'queued');
|
|
473
|
+
if (nextItem) {
|
|
474
|
+
this.processUpload(nextItem, config);
|
|
475
|
+
this.checkConcurrencyAndProcess(config);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
processUpload(item, config) {
|
|
480
|
+
const context = {};
|
|
481
|
+
this.startUploadFlow(item, config);
|
|
482
|
+
const subscription = this.getExecutionStream(item, config, context)
|
|
483
|
+
.pipe(concatMap(() => {
|
|
484
|
+
if (config.polling) {
|
|
485
|
+
return runPolling({
|
|
486
|
+
id: item.id,
|
|
487
|
+
payload: item.payload,
|
|
488
|
+
context,
|
|
489
|
+
config,
|
|
490
|
+
store: this.store,
|
|
491
|
+
handleError: this.handleError.bind(this)
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return of(context);
|
|
495
|
+
}), finalize(() => this.finalizeItem(item.id, config)))
|
|
496
|
+
.subscribe({
|
|
497
|
+
next: () => {
|
|
498
|
+
this.patchItem(item.id, { status: 'success', progress: 100 });
|
|
499
|
+
config.hooks?.onSuccess?.({ context, payload: item.payload });
|
|
500
|
+
this.checkAllComplete(config);
|
|
501
|
+
},
|
|
502
|
+
error: () => this.checkAllComplete(config)
|
|
503
|
+
});
|
|
504
|
+
this.activeSubscriptions.set(item.id, subscription);
|
|
505
|
+
}
|
|
506
|
+
startUploadFlow(item, config) {
|
|
507
|
+
this.patchItem(item.id, {
|
|
508
|
+
status: 'loading',
|
|
509
|
+
progress: 0,
|
|
510
|
+
statusText: this.getStatusText('loading')
|
|
511
|
+
});
|
|
512
|
+
config.hooks?.onBeforeStart?.(item.file);
|
|
513
|
+
}
|
|
514
|
+
getExecutionStream(item, config, context) {
|
|
515
|
+
if (!config.multipart && !config.steps?.length) {
|
|
516
|
+
const error = new Error('NO_UPLOAD_STRATEGY_CONFIGURED');
|
|
517
|
+
this.handleError(item.id, error, config, context);
|
|
518
|
+
return throwError(() => error);
|
|
519
|
+
}
|
|
520
|
+
const params = {
|
|
521
|
+
item,
|
|
522
|
+
payload: item.payload,
|
|
523
|
+
context,
|
|
524
|
+
config,
|
|
525
|
+
store: this.store,
|
|
526
|
+
handleError: this.handleError.bind(this),
|
|
527
|
+
reportProgress: (progress, text) => this.patchItem(item.id, { progress, statusText: text })
|
|
528
|
+
};
|
|
529
|
+
return config.multipart
|
|
530
|
+
? runMultipartFlow(params)
|
|
531
|
+
: runSequentialStepsFlow(params);
|
|
532
|
+
}
|
|
533
|
+
finalizeItem(id, config) {
|
|
534
|
+
this.activeSubscriptions.delete(id);
|
|
535
|
+
this.checkConcurrencyAndProcess(config);
|
|
536
|
+
}
|
|
537
|
+
extractErrorCode(error) {
|
|
538
|
+
if (typeof error === 'string' && ERROR_CODES.has(error)) {
|
|
539
|
+
return error;
|
|
540
|
+
}
|
|
541
|
+
if (error instanceof Error &&
|
|
542
|
+
ERROR_CODES.has(error.message)) {
|
|
543
|
+
return error.message;
|
|
544
|
+
}
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
getErrorText(error) {
|
|
548
|
+
const code = this.extractErrorCode(error);
|
|
549
|
+
if (code) {
|
|
550
|
+
return this.resolveUploaderKeyText(code);
|
|
551
|
+
}
|
|
552
|
+
if (error instanceof Error) {
|
|
553
|
+
return error.message;
|
|
554
|
+
}
|
|
555
|
+
if (typeof error === 'string') {
|
|
556
|
+
return error;
|
|
557
|
+
}
|
|
558
|
+
return this.getStatusText('error');
|
|
559
|
+
}
|
|
560
|
+
resolveUploaderKeyText(key) {
|
|
561
|
+
return this.currentConfig?.resolveText
|
|
562
|
+
? this.currentConfig.resolveText(key)
|
|
563
|
+
: key;
|
|
564
|
+
}
|
|
565
|
+
handleError(id, error, config, context = undefined, detail, status = 'error') {
|
|
566
|
+
const errorMessage = this.getErrorText(error);
|
|
567
|
+
this.patchItem(id, {
|
|
568
|
+
status,
|
|
569
|
+
error: errorMessage
|
|
570
|
+
}, detail);
|
|
571
|
+
config.hooks?.onError?.({ error, context });
|
|
572
|
+
}
|
|
573
|
+
checkAllComplete(config) {
|
|
574
|
+
const allTerminal = this.uploadQueue().every((i) => ['success', 'error', 'invalid'].includes(i.status));
|
|
575
|
+
if (allTerminal) {
|
|
576
|
+
this.isManualTriggered.set(false);
|
|
577
|
+
config.hooks?.onAllComplete?.();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Removes an item outright — revokes its preview blob URL and cancels any
|
|
582
|
+
* in-flight upload subscription first. This is outside `UploadFlowConfig`'s
|
|
583
|
+
* contract on purpose: `tk-uploader` only ever reports a removal request
|
|
584
|
+
* (see `UploaderRemoveRequest`/`removeRequested`), the consumer decides
|
|
585
|
+
* whether/when to actually call this.
|
|
586
|
+
*
|
|
587
|
+
* Deliberately does NOT cancel a still-pending `${id}_analysis` subscription:
|
|
588
|
+
* doing so would drop the analysis result (and any blob URL it already created)
|
|
589
|
+
* on the floor with nothing left to revoke it. Letting it resolve naturally lets
|
|
590
|
+
* `handleAnalysisResult`'s own "item still in the queue?" check catch it instead.
|
|
591
|
+
*/
|
|
592
|
+
removeFromQueue(id) {
|
|
593
|
+
this.revokeItemPreview(id);
|
|
594
|
+
this.cancelSubscription(id);
|
|
595
|
+
this.store.remove(id);
|
|
596
|
+
}
|
|
597
|
+
revokeItemPreview(id) {
|
|
598
|
+
const item = this.uploadQueue().find((i) => i.id === id);
|
|
599
|
+
if (item?.previewUrl?.startsWith('blob:')) {
|
|
600
|
+
URL.revokeObjectURL(item.previewUrl);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
cancelSubscription(key) {
|
|
604
|
+
const sub = this.activeSubscriptions.get(key);
|
|
605
|
+
if (sub) {
|
|
606
|
+
sub.unsubscribe();
|
|
607
|
+
this.activeSubscriptions.delete(key);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/** Cancels an item's in-flight subscription without removing it — `startUpload` resumes it. */
|
|
611
|
+
pauseUpload(id) {
|
|
612
|
+
this.cancelSubscription(id);
|
|
613
|
+
}
|
|
614
|
+
/** Revokes every preview blob URL, cancels every in-flight subscription, and empties the store. */
|
|
615
|
+
clearQueue() {
|
|
616
|
+
this.uploadQueue().forEach((item) => {
|
|
617
|
+
if (item.previewUrl?.startsWith('blob:')) {
|
|
618
|
+
URL.revokeObjectURL(item.previewUrl);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
this.activeSubscriptions.forEach((sub) => sub.unsubscribe());
|
|
622
|
+
this.activeSubscriptions.clear();
|
|
623
|
+
this.store.clear();
|
|
624
|
+
}
|
|
625
|
+
getStatusKey(status) {
|
|
626
|
+
return STATUS_KEY_MAP[status] || status.toUpperCase();
|
|
627
|
+
}
|
|
628
|
+
getStatusText(status) {
|
|
629
|
+
const key = this.getStatusKey(status);
|
|
630
|
+
if (this.currentConfig?.resolveText) {
|
|
631
|
+
return this.currentConfig.resolveText(key);
|
|
632
|
+
}
|
|
633
|
+
return status;
|
|
634
|
+
}
|
|
635
|
+
patchItem(id, updates, detail) {
|
|
636
|
+
const currentItem = this.uploadQueue().find((i) => i.id === id);
|
|
637
|
+
if (!currentItem) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (updates.status && updates.status !== currentItem.status) {
|
|
641
|
+
this.addAuditEntry(id, currentItem.status, updates.status, detail);
|
|
642
|
+
updates.statusKey = this.getStatusKey(updates.status);
|
|
643
|
+
updates.statusText ??= this.getStatusText(updates.status);
|
|
644
|
+
}
|
|
645
|
+
const cleanUpdates = { ...updates };
|
|
646
|
+
if (cleanUpdates.statusText === undefined) {
|
|
647
|
+
delete cleanUpdates.statusText;
|
|
648
|
+
}
|
|
649
|
+
this.store.update(id, cleanUpdates);
|
|
650
|
+
}
|
|
651
|
+
addAuditEntry(itemId, from, toStatus, detail) {
|
|
652
|
+
this.auditHistory.update((history) => [
|
|
653
|
+
...history,
|
|
654
|
+
{
|
|
655
|
+
itemId,
|
|
656
|
+
from,
|
|
657
|
+
to: toStatus,
|
|
658
|
+
timestamp: Date.now(),
|
|
659
|
+
key: this.getStatusKey(toStatus),
|
|
660
|
+
errorDetail: detail
|
|
661
|
+
}
|
|
662
|
+
]);
|
|
663
|
+
}
|
|
664
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderQueueService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
665
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderQueueService }); }
|
|
666
|
+
}
|
|
667
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderQueueService, decorators: [{
|
|
668
|
+
type: Injectable
|
|
669
|
+
}] });
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Horizontal file card — icon/preview, title, plain progress bar while
|
|
673
|
+
* `status` is `'loading'` (otherwise `statusText`), and a trailing action
|
|
674
|
+
* button. Internal to `tk-uploader`; not part of the public API.
|
|
675
|
+
*/
|
|
676
|
+
class UploaderFileCardComponent {
|
|
677
|
+
constructor() {
|
|
678
|
+
this.title = input.required(...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
|
|
679
|
+
this.status = input.required(...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
|
|
680
|
+
/** 0-100, shown only while `status` is `'loading'`. */
|
|
681
|
+
this.progress = input(0, ...(ngDevMode ? [{ debugName: "progress" }] : /* istanbul ignore next */ []));
|
|
682
|
+
this.previewUrl = input(...(ngDevMode ? [undefined, { debugName: "previewUrl" }] : /* istanbul ignore next */ []));
|
|
683
|
+
/** Fallback icon shown when there's no `previewUrl`. */
|
|
684
|
+
this.icon = input('folders', ...(ngDevMode ? [{ debugName: "icon" }] : /* istanbul ignore next */ []));
|
|
685
|
+
/** Shown instead of the progress bar whenever `status` isn't `'loading'`. */
|
|
686
|
+
this.statusText = input.required(...(ngDevMode ? [{ debugName: "statusText" }] : /* istanbul ignore next */ []));
|
|
687
|
+
/**
|
|
688
|
+
* Set by the consumer while it's waiting on its own confirmation for this
|
|
689
|
+
* item's removal — this card never confirms anything itself, it only
|
|
690
|
+
* reflects that a decision is pending by disabling the remove button (no
|
|
691
|
+
* double-clicks while waiting).
|
|
692
|
+
*/
|
|
693
|
+
this.isPendingRemoval = input(false, ...(ngDevMode ? [{ debugName: "isPendingRemoval" }] : /* istanbul ignore next */ []));
|
|
694
|
+
/**
|
|
695
|
+
* Icon for the trailing button — defaults to the remove affordance
|
|
696
|
+
* (`trash-can`). `tk-uploader` overrides this for its `placeholder` card,
|
|
697
|
+
* where the same button re-opens the file picker instead of removing
|
|
698
|
+
* anything (there's nothing in the real queue to remove).
|
|
699
|
+
*/
|
|
700
|
+
this.trailingIcon = input('trash-can', ...(ngDevMode ? [{ debugName: "trailingIcon" }] : /* istanbul ignore next */ []));
|
|
701
|
+
/** Severity for the trailing button — `danger` reads as destructive, `secondary` doesn't. */
|
|
702
|
+
this.trailingSeverity = input('danger', ...(ngDevMode ? [{ debugName: "trailingSeverity" }] : /* istanbul ignore next */ []));
|
|
703
|
+
/**
|
|
704
|
+
* Tooltip/accessible label for the trailing button — this card has no copy of its own
|
|
705
|
+
* (no i18n coupling, same reasoning as `UploadFlowConfig.resolveText`), so it stays
|
|
706
|
+
* unlabeled unless the consumer supplies text here.
|
|
707
|
+
*/
|
|
708
|
+
this.trailingLabel = input(...(ngDevMode ? [undefined, { debugName: "trailingLabel" }] : /* istanbul ignore next */ []));
|
|
709
|
+
/** Emitted on trailing-button click — its meaning depends on `trailingIcon`/context, this card has no opinion on it. */
|
|
710
|
+
this.dismissed = output();
|
|
711
|
+
this.isProcessing = computed(() => this.status() === 'loading', ...(ngDevMode ? [{ debugName: "isProcessing" }] : /* istanbul ignore next */ []));
|
|
712
|
+
}
|
|
713
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderFileCardComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
714
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: UploaderFileCardComponent, isStandalone: true, selector: "tk-uploader-file-card", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: true, transformFunction: null }, status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: true, transformFunction: null }, progress: { classPropertyName: "progress", publicName: "progress", isSignal: true, isRequired: false, transformFunction: null }, previewUrl: { classPropertyName: "previewUrl", publicName: "previewUrl", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, statusText: { classPropertyName: "statusText", publicName: "statusText", isSignal: true, isRequired: true, transformFunction: null }, isPendingRemoval: { classPropertyName: "isPendingRemoval", publicName: "isPendingRemoval", isSignal: true, isRequired: false, transformFunction: null }, trailingIcon: { classPropertyName: "trailingIcon", publicName: "trailingIcon", isSignal: true, isRequired: false, transformFunction: null }, trailingSeverity: { classPropertyName: "trailingSeverity", publicName: "trailingSeverity", isSignal: true, isRequired: false, transformFunction: null }, trailingLabel: { classPropertyName: "trailingLabel", publicName: "trailingLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dismissed: "dismissed" }, ngImport: i0, template: "<article class=\"tk-uploader-file-card\" [attr.data-status]=\"status()\">\n <div class=\"tk-uploader-file-card__media\">\n @if (previewUrl()) {\n <img\n [src]=\"previewUrl()\"\n alt=\"File preview\"\n class=\"tk-uploader-file-card__preview\" />\n } @else {\n <tk-icon [icon]=\"icon()\" class=\"tk-uploader-file-card__media-icon\" />\n }\n </div>\n\n <div class=\"tk-uploader-file-card__info\">\n <h4 class=\"tk-uploader-file-card__title\">{{ title() }}</h4>\n\n @if (isProcessing()) {\n <!-- statusText is intentionally hidden while loading: tk-progress-bar only shows a\n number, so with multiple files (no per-item tk-process-steps) there's no textual\n feedback during this state \u2014 see uploader.mdx \"Known limitations\". -->\n <div class=\"tk-uploader-file-card__progress\">\n <tk-progress-bar [value]=\"progress()\" size=\"medium\" [showValue]=\"true\" />\n </div>\n } @else {\n <p class=\"tk-uploader-file-card__status-text\">{{ statusText() }}</p>\n }\n </div>\n\n <tk-button\n variant=\"outlined\"\n [severity]=\"trailingSeverity()\"\n size=\"small\"\n [icon]=\"trailingIcon()\"\n [tooltipText]=\"trailingLabel()\"\n class=\"tk-uploader-file-card__remove\"\n [disabled]=\"isPendingRemoval()\"\n (clicked)=\"dismissed.emit()\" />\n</article>\n", styles: [".tk-uploader-file-card{--card-status-color: var(--tk-color-border-default);position:relative;display:flex;align-items:center;gap:var(--tk-spacing-base-150);padding:var(--tk-spacing-base-100);background-color:var(--tk-color-background-soft);border:1px solid var(--card-status-color);border-radius:var(--tk-borderRadius-m);overflow:hidden;width:100%;transition:border-color .2s ease-in-out}.tk-uploader-file-card__media{display:flex;align-items:center;justify-content:center;flex-shrink:0;width:3.5rem;height:3.5rem;border-radius:var(--tk-borderRadius-s);background-color:var(--tk-color-primary-muted);overflow:hidden}.tk-uploader-file-card__media-icon{color:var(--tk-color-primary-default);font-size:1.5rem}.tk-uploader-file-card__preview{width:100%;height:100%;object-fit:cover}.tk-uploader-file-card__info{flex:1 0 0;min-width:0;display:flex;flex-direction:column;gap:var(--tk-spacing-base-50)}.tk-uploader-file-card__title{margin:0;font-size:var(--tk-font-size-paragraph-m);font-weight:600;color:var(--tk-color-text-default);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tk-uploader-file-card__status-text{margin:0;font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default)}.tk-uploader-file-card__progress{display:flex;flex-direction:column}.tk-uploader-file-card__remove{flex-shrink:0}.tk-uploader-file-card[data-status=success]{--card-status-color: var(--tk-color-feedback-success-default)}.tk-uploader-file-card[data-status=error],.tk-uploader-file-card[data-status=invalid]{--card-status-color: var(--tk-color-feedback-danger-default)}\n"], dependencies: [{ kind: "component", type: ButtonComponent, selector: "tk-button", inputs: ["label", "disabled", "type", "severity", "variant", "link", "icon", "iconPosition", "tooltipText", "full", "ariaLabel", "size"], outputs: ["clicked"] }, { kind: "component", type: IconComponent, selector: "tk-icon", inputs: ["icon", "styleIcon", "color", "size", "disabled"] }, { kind: "component", type: ProgressBarComponent, selector: "tk-progress-bar", inputs: ["value", "showValue", "size"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
715
|
+
}
|
|
716
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderFileCardComponent, decorators: [{
|
|
717
|
+
type: Component,
|
|
718
|
+
args: [{ selector: 'tk-uploader-file-card', standalone: true, imports: [ButtonComponent, IconComponent, ProgressBarComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<article class=\"tk-uploader-file-card\" [attr.data-status]=\"status()\">\n <div class=\"tk-uploader-file-card__media\">\n @if (previewUrl()) {\n <img\n [src]=\"previewUrl()\"\n alt=\"File preview\"\n class=\"tk-uploader-file-card__preview\" />\n } @else {\n <tk-icon [icon]=\"icon()\" class=\"tk-uploader-file-card__media-icon\" />\n }\n </div>\n\n <div class=\"tk-uploader-file-card__info\">\n <h4 class=\"tk-uploader-file-card__title\">{{ title() }}</h4>\n\n @if (isProcessing()) {\n <!-- statusText is intentionally hidden while loading: tk-progress-bar only shows a\n number, so with multiple files (no per-item tk-process-steps) there's no textual\n feedback during this state \u2014 see uploader.mdx \"Known limitations\". -->\n <div class=\"tk-uploader-file-card__progress\">\n <tk-progress-bar [value]=\"progress()\" size=\"medium\" [showValue]=\"true\" />\n </div>\n } @else {\n <p class=\"tk-uploader-file-card__status-text\">{{ statusText() }}</p>\n }\n </div>\n\n <tk-button\n variant=\"outlined\"\n [severity]=\"trailingSeverity()\"\n size=\"small\"\n [icon]=\"trailingIcon()\"\n [tooltipText]=\"trailingLabel()\"\n class=\"tk-uploader-file-card__remove\"\n [disabled]=\"isPendingRemoval()\"\n (clicked)=\"dismissed.emit()\" />\n</article>\n", styles: [".tk-uploader-file-card{--card-status-color: var(--tk-color-border-default);position:relative;display:flex;align-items:center;gap:var(--tk-spacing-base-150);padding:var(--tk-spacing-base-100);background-color:var(--tk-color-background-soft);border:1px solid var(--card-status-color);border-radius:var(--tk-borderRadius-m);overflow:hidden;width:100%;transition:border-color .2s ease-in-out}.tk-uploader-file-card__media{display:flex;align-items:center;justify-content:center;flex-shrink:0;width:3.5rem;height:3.5rem;border-radius:var(--tk-borderRadius-s);background-color:var(--tk-color-primary-muted);overflow:hidden}.tk-uploader-file-card__media-icon{color:var(--tk-color-primary-default);font-size:1.5rem}.tk-uploader-file-card__preview{width:100%;height:100%;object-fit:cover}.tk-uploader-file-card__info{flex:1 0 0;min-width:0;display:flex;flex-direction:column;gap:var(--tk-spacing-base-50)}.tk-uploader-file-card__title{margin:0;font-size:var(--tk-font-size-paragraph-m);font-weight:600;color:var(--tk-color-text-default);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tk-uploader-file-card__status-text{margin:0;font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default)}.tk-uploader-file-card__progress{display:flex;flex-direction:column}.tk-uploader-file-card__remove{flex-shrink:0}.tk-uploader-file-card[data-status=success]{--card-status-color: var(--tk-color-feedback-success-default)}.tk-uploader-file-card[data-status=error],.tk-uploader-file-card[data-status=invalid]{--card-status-color: var(--tk-color-feedback-danger-default)}\n"] }]
|
|
719
|
+
}], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: true }] }], status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: true }] }], progress: [{ type: i0.Input, args: [{ isSignal: true, alias: "progress", required: false }] }], previewUrl: [{ type: i0.Input, args: [{ isSignal: true, alias: "previewUrl", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], statusText: [{ type: i0.Input, args: [{ isSignal: true, alias: "statusText", required: true }] }], isPendingRemoval: [{ type: i0.Input, args: [{ isSignal: true, alias: "isPendingRemoval", required: false }] }], trailingIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingIcon", required: false }] }], trailingSeverity: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingSeverity", required: false }] }], trailingLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingLabel", required: false }] }], dismissed: [{ type: i0.Output, args: ["dismissed"] }] } });
|
|
720
|
+
|
|
721
|
+
const TERMINAL_STATUSES = new Set(['success', 'error', 'invalid']);
|
|
722
|
+
const DEFAULT_STEPS_TITLE = 'Processing';
|
|
723
|
+
function resolveRemovePhase(status) {
|
|
724
|
+
if (status === 'loading') {
|
|
725
|
+
return 'in-progress';
|
|
726
|
+
}
|
|
727
|
+
return TERMINAL_STATUSES.has(status) ? 'post-upload' : 'pre-upload';
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Drag-and-drop upload zone with a queue of file cards. Fully agnostic: it
|
|
731
|
+
* has no opinion on *how* a file gets uploaded — validation, upload
|
|
732
|
+
* mechanics, post-upload polling and every lifecycle callback are supplied
|
|
733
|
+
* by the consumer through `config` (`UploadFlowConfig`). Requires
|
|
734
|
+
* `provideUploader()` in the providers of the hosting component (or a parent).
|
|
735
|
+
*/
|
|
736
|
+
class UploaderComponent {
|
|
737
|
+
constructor() {
|
|
738
|
+
this.queueService = inject(UploaderQueueService);
|
|
739
|
+
/** Idle-state heading, shown before any file is selected. */
|
|
740
|
+
this.title = input('Drag and drop your file here', ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
|
|
741
|
+
/** Whether more than one file can be added to the queue at once. */
|
|
742
|
+
this.multiple = input(false, ...(ngDevMode ? [{ debugName: "multiple" }] : /* istanbul ignore next */ []));
|
|
743
|
+
/** Idle-state helper text. */
|
|
744
|
+
this.subtitle = input('or click to browse', ...(ngDevMode ? [{ debugName: "subtitle" }] : /* istanbul ignore next */ []));
|
|
745
|
+
/** Optional helper line shown below the drop zone while it's empty. */
|
|
746
|
+
this.helperText = input(null, ...(ngDevMode ? [{ debugName: "helperText" }] : /* istanbul ignore next */ []));
|
|
747
|
+
/** Label for the optional link appended to `helperText`. */
|
|
748
|
+
this.helperLinkText = input(null, ...(ngDevMode ? [{ debugName: "helperLinkText" }] : /* istanbul ignore next */ []));
|
|
749
|
+
/** URL for the optional link appended to `helperText`. */
|
|
750
|
+
this.helperLinkUrl = input(null, ...(ngDevMode ? [{ debugName: "helperLinkUrl" }] : /* istanbul ignore next */ []));
|
|
751
|
+
/** Everything this instance needs to run its flow — see `UploadFlowConfig`. */
|
|
752
|
+
this.config = input.required(...(ngDevMode ? [{ debugName: "config" }] : /* istanbul ignore next */ []));
|
|
753
|
+
/**
|
|
754
|
+
* Terminal "existing file" card shown instead of the idle dropzone while
|
|
755
|
+
* the queue is empty (e.g. edit mode: an already-loaded entity) — see
|
|
756
|
+
* `LoaderPlaceholder`.
|
|
757
|
+
*/
|
|
758
|
+
this.placeholder = input(null, ...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
|
|
759
|
+
/**
|
|
760
|
+
* Ids the consumer is currently waiting on its own confirmation for (see
|
|
761
|
+
* `removeRequested`). Matching cards disable their remove button so the
|
|
762
|
+
* user can't double-trigger a request while a decision is pending — this
|
|
763
|
+
* component still has no opinion on what that confirmation looks like.
|
|
764
|
+
*/
|
|
765
|
+
this.pendingRemovalIds = input([], ...(ngDevMode ? [{ debugName: "pendingRemovalIds" }] : /* istanbul ignore next */ []));
|
|
766
|
+
/**
|
|
767
|
+
* Tooltip/accessible label for a real queue item's remove button — `tk-uploader` has no
|
|
768
|
+
* copy of its own (same reasoning as `UploadFlowConfig.resolveText`), so the button stays
|
|
769
|
+
* unlabeled unless the consumer supplies text here.
|
|
770
|
+
*/
|
|
771
|
+
this.removeLabel = input(...(ngDevMode ? [undefined, { debugName: "removeLabel" }] : /* istanbul ignore next */ []));
|
|
772
|
+
/** Tooltip/accessible label for the `placeholder` card's trailing (re-upload) button. */
|
|
773
|
+
this.placeholderReplaceLabel = input(...(ngDevMode ? [undefined, { debugName: "placeholderReplaceLabel" }] : /* istanbul ignore next */ []));
|
|
774
|
+
/** Emitted with the raw `File`(s) as soon as they're picked/dropped — `File[]` when `multiple`, a single `File` otherwise. */
|
|
775
|
+
this.fileSelected = output();
|
|
776
|
+
/**
|
|
777
|
+
* Emitted when a file-card's remove button is clicked — `tk-uploader` never
|
|
778
|
+
* removes the item itself. Inspect `phase` to decide whether to confirm
|
|
779
|
+
* (typically for `in-progress`) before calling
|
|
780
|
+
* `UploaderQueueService.removeFromQueue(id)`.
|
|
781
|
+
*/
|
|
782
|
+
this.removeRequested = output();
|
|
783
|
+
/**
|
|
784
|
+
* Emitted when an item's `tk-process-steps` countdown (`stepsCountdownSeconds`)
|
|
785
|
+
* reaches zero — `tk-uploader` never acts on it itself (doesn't remove the
|
|
786
|
+
* item, doesn't close anything). The consumer decides what that means: close
|
|
787
|
+
* a modal, remove the item, show a toast, etc.
|
|
788
|
+
*/
|
|
789
|
+
this.stepsCountdownFinished = output();
|
|
790
|
+
this.fileInput = viewChild.required('fileInput');
|
|
791
|
+
this.queue = this.queueService.uploadQueue;
|
|
792
|
+
this.isDragging = signal(false, ...(ngDevMode ? [{ debugName: "isDragging" }] : /* istanbul ignore next */ []));
|
|
793
|
+
/**
|
|
794
|
+
* Files already in a terminal state (`success` / `error` / `invalid`) are
|
|
795
|
+
* done — they don't consume capacity anymore even though they stay visible
|
|
796
|
+
* in the queue until manually removed. Capacity is about what's still
|
|
797
|
+
* in-flight, not the size of the visible history.
|
|
798
|
+
*/
|
|
799
|
+
this.activeCount = computed(() => this.queue().filter((item) => !TERMINAL_STATUSES.has(item.status)).length, ...(ngDevMode ? [{ debugName: "activeCount" }] : /* istanbul ignore next */ []));
|
|
800
|
+
this.isAtCapacity = computed(() => !this.multiple() && this.activeCount() >= 1, ...(ngDevMode ? [{ debugName: "isAtCapacity" }] : /* istanbul ignore next */ []));
|
|
801
|
+
/**
|
|
802
|
+
* Native `accept` attribute for the hidden `<input type="file">`, derived
|
|
803
|
+
* from `config.restrictions` (`rules[].extensions` or `allowedExtensions`)
|
|
804
|
+
* — the same list that actually validates a file — so there is a single
|
|
805
|
+
* source of truth instead of a separate, independently-settable input that
|
|
806
|
+
* could drift out of sync with it. This is only a hint for the OS file
|
|
807
|
+
* picker: it does not filter drag & drop and does not validate anything.
|
|
808
|
+
*/
|
|
809
|
+
this.accept = computed(() => {
|
|
810
|
+
const restrictions = this.config().restrictions;
|
|
811
|
+
if (!restrictions) {
|
|
812
|
+
return '';
|
|
813
|
+
}
|
|
814
|
+
const extensions = restrictions.rules?.length
|
|
815
|
+
? restrictions.rules.flatMap((rule) => rule.extensions)
|
|
816
|
+
: restrictions.allowedExtensions;
|
|
817
|
+
return extensions?.join(',') ?? '';
|
|
818
|
+
}, ...(ngDevMode ? [{ debugName: "accept" }] : /* istanbul ignore next */ []));
|
|
819
|
+
this.dragCounter = 0;
|
|
820
|
+
}
|
|
821
|
+
onDragOver(event) {
|
|
822
|
+
if (this.isAtCapacity()) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
event.preventDefault();
|
|
826
|
+
event.stopPropagation();
|
|
827
|
+
}
|
|
828
|
+
onDragEnter(event) {
|
|
829
|
+
if (this.isAtCapacity()) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
event.preventDefault();
|
|
833
|
+
event.stopPropagation();
|
|
834
|
+
this.dragCounter++;
|
|
835
|
+
this.isDragging.set(true);
|
|
836
|
+
}
|
|
837
|
+
onDragLeave(event) {
|
|
838
|
+
event.preventDefault();
|
|
839
|
+
event.stopPropagation();
|
|
840
|
+
this.dragCounter--;
|
|
841
|
+
if (this.dragCounter === 0) {
|
|
842
|
+
this.isDragging.set(false);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
onDrop(event) {
|
|
846
|
+
if (this.isAtCapacity()) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
event.preventDefault();
|
|
850
|
+
event.stopPropagation();
|
|
851
|
+
this.dragCounter = 0;
|
|
852
|
+
this.isDragging.set(false);
|
|
853
|
+
if (event.dataTransfer?.files) {
|
|
854
|
+
this.handleFiles(event.dataTransfer.files);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
onFileChange(event) {
|
|
858
|
+
if (this.isAtCapacity()) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const input = event.target;
|
|
862
|
+
if (input.files) {
|
|
863
|
+
this.handleFiles(input.files);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
onCancelItem(id) {
|
|
867
|
+
const item = this.queue().find((i) => i.id === id);
|
|
868
|
+
if (!item) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
this.removeRequested.emit({ id, phase: resolveRemovePhase(item.status) });
|
|
872
|
+
}
|
|
873
|
+
isPendingRemoval(id) {
|
|
874
|
+
return this.pendingRemovalIds().includes(id);
|
|
875
|
+
}
|
|
876
|
+
stepsTitleFor(stepsTitle) {
|
|
877
|
+
return stepsTitle ?? DEFAULT_STEPS_TITLE;
|
|
878
|
+
}
|
|
879
|
+
/** Opens the OS file picker programmatically — a no-op while `isAtCapacity()`. */
|
|
880
|
+
triggerBrowse() {
|
|
881
|
+
if (this.isAtCapacity()) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
this.fileInput().nativeElement.click();
|
|
885
|
+
}
|
|
886
|
+
handleFiles(fileList) {
|
|
887
|
+
const allFiles = Array.from(fileList);
|
|
888
|
+
if (allFiles.length === 0) {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const files = this.multiple() ? allFiles : allFiles.slice(0, 1);
|
|
892
|
+
this.queueService.addToQueue(files, this.config());
|
|
893
|
+
this.emitFileSelection(files);
|
|
894
|
+
}
|
|
895
|
+
emitFileSelection(files) {
|
|
896
|
+
if (this.multiple()) {
|
|
897
|
+
this.fileSelected.emit(files);
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
this.fileSelected.emit(files[0]);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
904
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: UploaderComponent, isStandalone: true, selector: "tk-uploader", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, subtitle: { classPropertyName: "subtitle", publicName: "subtitle", isSignal: true, isRequired: false, transformFunction: null }, helperText: { classPropertyName: "helperText", publicName: "helperText", isSignal: true, isRequired: false, transformFunction: null }, helperLinkText: { classPropertyName: "helperLinkText", publicName: "helperLinkText", isSignal: true, isRequired: false, transformFunction: null }, helperLinkUrl: { classPropertyName: "helperLinkUrl", publicName: "helperLinkUrl", isSignal: true, isRequired: false, transformFunction: null }, config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, pendingRemovalIds: { classPropertyName: "pendingRemovalIds", publicName: "pendingRemovalIds", isSignal: true, isRequired: false, transformFunction: null }, removeLabel: { classPropertyName: "removeLabel", publicName: "removeLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholderReplaceLabel: { classPropertyName: "placeholderReplaceLabel", publicName: "placeholderReplaceLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { fileSelected: "fileSelected", removeRequested: "removeRequested", stepsCountdownFinished: "stepsCountdownFinished" }, viewQueries: [{ propertyName: "fileInput", first: true, predicate: ["fileInput"], descendants: true, isSignal: true }], ngImport: i0, template: "<div\n class=\"tk-uploader\"\n [class.tk-uploader--dragging]=\"isDragging()\"\n [class.tk-uploader--has-file]=\"queue().length > 0 || !!placeholder()\"\n [class.tk-uploader--disabled]=\"isAtCapacity()\"\n (dragover)=\"onDragOver($event)\"\n (dragenter)=\"onDragEnter($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\">\n <input\n #fileInput\n type=\"file\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileChange($event)\"\n hidden />\n\n @if (queue().length === 0) {\n @if (placeholder()) {\n <!-- Stays a div, not a button: tk-uploader-file-card renders its own trailing\n <button> (replace icon), and a <button> cannot contain another <button>. -->\n <div\n class=\"tk-uploader__placeholder\"\n [class.tk-uploader__placeholder--dragging]=\"isDragging()\"\n role=\"button\"\n tabindex=\"0\"\n (click)=\"fileInput.click()\"\n (keydown.enter)=\"fileInput.click()\"\n (keydown.space)=\"fileInput.click(); $event.preventDefault()\">\n <tk-uploader-file-card\n [title]=\"placeholder()!.title\"\n [status]=\"placeholder()!.status\"\n [statusText]=\"placeholder()!.statusText\"\n [progress]=\"placeholder()!.progress ?? 0\"\n [previewUrl]=\"placeholder()!.previewUrl\"\n trailingIcon=\"upload\"\n trailingSeverity=\"secondary\"\n [trailingLabel]=\"placeholderReplaceLabel()\"\n (dismissed)=\"fileInput.click()\" />\n </div>\n } @else {\n <!-- Native <button>, not a div with role=\"button\": no nested interactive\n children here, so the accessible, semantic element is the right fit. -->\n <button type=\"button\" class=\"tk-uploader__idle\" (click)=\"fileInput.click()\">\n <div class=\"tk-uploader__illustration\">\n <tk-icon icon=\"upload\" color=\"primary\" />\n </div>\n\n <div class=\"tk-uploader__text\">\n <h3 class=\"tk-uploader__title\">\n {{ title() }}\n </h3>\n <p class=\"tk-uploader__subtitle\">\n {{ subtitle() }}\n </p>\n </div>\n </button>\n }\n } @else {\n <div class=\"tk-uploader__list\">\n @for (item of queue(); track item.id) {\n <tk-uploader-file-card\n [title]=\"item.file.name\"\n [status]=\"item.status\"\n [statusText]=\"item.statusText\"\n [progress]=\"item.progress\"\n [previewUrl]=\"item.previewUrl\"\n [isPendingRemoval]=\"isPendingRemoval(item.id)\"\n [trailingLabel]=\"removeLabel()\"\n (dismissed)=\"onCancelItem(item.id)\" />\n\n <!-- Gated to !multiple: a step-by-step breakdown per item gets noisy with several\n files uploading at once, so multiple falls back to the plain progress bar even\n when checkStatus returns steps. See uploader.mdx \"Known limitations\". -->\n @if (!multiple() && item.steps) {\n <tk-process-steps\n [steps]=\"item.steps\"\n [title]=\"stepsTitleFor(item.stepsTitle)\"\n [description]=\"item.stepsDescription\"\n [status]=\"item.stepsStatus ?? 'progress'\"\n [showIcon]=\"false\"\n [compact]=\"true\"\n [countdownSeconds]=\"item.stepsCountdownSeconds\"\n [countdownLabel]=\"item.stepsCountdownLabel ?? 'Closing in {seconds}s\u2026'\"\n (countdownFinished)=\"stepsCountdownFinished.emit(item.id)\" />\n }\n }\n </div>\n }\n</div>\n\n@if (queue().length === 0 && helperText()) {\n <div class=\"tk-uploader__helper\">\n <span>{{ helperText() }}</span>\n @if (helperLinkText() && helperLinkUrl()) {\n <a\n [href]=\"helperLinkUrl()\"\n target=\"_blank\"\n rel=\"noopener\"\n class=\"tk-uploader__helper-link\">\n {{ helperLinkText() }}\n </a>\n }\n </div>\n}\n", styles: [".tk-uploader{position:relative;overflow:hidden;border:1px dashed var(--tk-color-border-default);border-radius:var(--tk-borderRadius-m);background-color:var(--tk-color-background-default);padding:var(--tk-spacing-base-200) var(--tk-spacing-base-100);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:all .2s ease-in-out;width:100%}.tk-uploader--has-file{border-color:transparent;border-radius:0;background-color:var(--tk-color-base-surface-0);justify-content:flex-start;padding:0;overflow:visible}.tk-uploader--dragging{background-color:var(--tk-color-primary-muted);border-color:var(--tk-color-border-default);padding:var(--tk-spacing-base-200) var(--tk-spacing-base-100)}.tk-uploader--dragging>*{pointer-events:none}.tk-uploader--disabled{cursor:default;opacity:.8}.tk-uploader--disabled .tk-uploader__idle,.tk-uploader--disabled .tk-uploader__placeholder{cursor:default;pointer-events:none}.tk-uploader__idle{border:none;background:none;padding:0;margin:0;font:inherit;color:inherit;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--tk-spacing-base-150);text-align:center;width:100%;height:100%}.tk-uploader__illustration{display:flex;align-items:center;justify-content:center}.tk-uploader__text{display:flex;flex-direction:column;align-items:center;gap:var(--tk-spacing-base-100)}.tk-uploader__title{margin:0;font-size:var(--tk-font-size-paragraph-m);color:var(--tk-color-text-default);font-weight:600}.tk-uploader__subtitle{margin:0;font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default);font-weight:400}.tk-uploader__list,.tk-uploader__placeholder{width:100%;display:flex;flex-direction:column;gap:var(--tk-spacing-base-100);transition:transform .2s ease-in-out}.tk-uploader__list--dragging,.tk-uploader__placeholder--dragging{filter:brightness(1.05)}.tk-uploader__helper{display:flex;flex-wrap:wrap;gap:var(--tk-spacing-base-25);font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default);margin-top:var(--tk-spacing-base-100)}.tk-uploader__helper-link{color:var(--tk-color-primary-default);font-weight:600;text-decoration:none}.tk-uploader__helper-link:hover{text-decoration:underline}\n"], dependencies: [{ kind: "component", type: UploaderFileCardComponent, selector: "tk-uploader-file-card", inputs: ["title", "status", "progress", "previewUrl", "icon", "statusText", "isPendingRemoval", "trailingIcon", "trailingSeverity", "trailingLabel"], outputs: ["dismissed"] }, { kind: "component", type: IconComponent, selector: "tk-icon", inputs: ["icon", "styleIcon", "color", "size", "disabled"] }, { kind: "component", type: ProcessStepsComponent, selector: "tk-process-steps", inputs: ["steps", "title", "description", "icon", "status", "showIcon", "compact", "errors", "countdownSeconds", "countdownLabel"], outputs: ["countdownFinished"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
905
|
+
}
|
|
906
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderComponent, decorators: [{
|
|
907
|
+
type: Component,
|
|
908
|
+
args: [{ selector: 'tk-uploader', standalone: true, imports: [UploaderFileCardComponent, IconComponent, ProcessStepsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div\n class=\"tk-uploader\"\n [class.tk-uploader--dragging]=\"isDragging()\"\n [class.tk-uploader--has-file]=\"queue().length > 0 || !!placeholder()\"\n [class.tk-uploader--disabled]=\"isAtCapacity()\"\n (dragover)=\"onDragOver($event)\"\n (dragenter)=\"onDragEnter($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\">\n <input\n #fileInput\n type=\"file\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileChange($event)\"\n hidden />\n\n @if (queue().length === 0) {\n @if (placeholder()) {\n <!-- Stays a div, not a button: tk-uploader-file-card renders its own trailing\n <button> (replace icon), and a <button> cannot contain another <button>. -->\n <div\n class=\"tk-uploader__placeholder\"\n [class.tk-uploader__placeholder--dragging]=\"isDragging()\"\n role=\"button\"\n tabindex=\"0\"\n (click)=\"fileInput.click()\"\n (keydown.enter)=\"fileInput.click()\"\n (keydown.space)=\"fileInput.click(); $event.preventDefault()\">\n <tk-uploader-file-card\n [title]=\"placeholder()!.title\"\n [status]=\"placeholder()!.status\"\n [statusText]=\"placeholder()!.statusText\"\n [progress]=\"placeholder()!.progress ?? 0\"\n [previewUrl]=\"placeholder()!.previewUrl\"\n trailingIcon=\"upload\"\n trailingSeverity=\"secondary\"\n [trailingLabel]=\"placeholderReplaceLabel()\"\n (dismissed)=\"fileInput.click()\" />\n </div>\n } @else {\n <!-- Native <button>, not a div with role=\"button\": no nested interactive\n children here, so the accessible, semantic element is the right fit. -->\n <button type=\"button\" class=\"tk-uploader__idle\" (click)=\"fileInput.click()\">\n <div class=\"tk-uploader__illustration\">\n <tk-icon icon=\"upload\" color=\"primary\" />\n </div>\n\n <div class=\"tk-uploader__text\">\n <h3 class=\"tk-uploader__title\">\n {{ title() }}\n </h3>\n <p class=\"tk-uploader__subtitle\">\n {{ subtitle() }}\n </p>\n </div>\n </button>\n }\n } @else {\n <div class=\"tk-uploader__list\">\n @for (item of queue(); track item.id) {\n <tk-uploader-file-card\n [title]=\"item.file.name\"\n [status]=\"item.status\"\n [statusText]=\"item.statusText\"\n [progress]=\"item.progress\"\n [previewUrl]=\"item.previewUrl\"\n [isPendingRemoval]=\"isPendingRemoval(item.id)\"\n [trailingLabel]=\"removeLabel()\"\n (dismissed)=\"onCancelItem(item.id)\" />\n\n <!-- Gated to !multiple: a step-by-step breakdown per item gets noisy with several\n files uploading at once, so multiple falls back to the plain progress bar even\n when checkStatus returns steps. See uploader.mdx \"Known limitations\". -->\n @if (!multiple() && item.steps) {\n <tk-process-steps\n [steps]=\"item.steps\"\n [title]=\"stepsTitleFor(item.stepsTitle)\"\n [description]=\"item.stepsDescription\"\n [status]=\"item.stepsStatus ?? 'progress'\"\n [showIcon]=\"false\"\n [compact]=\"true\"\n [countdownSeconds]=\"item.stepsCountdownSeconds\"\n [countdownLabel]=\"item.stepsCountdownLabel ?? 'Closing in {seconds}s\u2026'\"\n (countdownFinished)=\"stepsCountdownFinished.emit(item.id)\" />\n }\n }\n </div>\n }\n</div>\n\n@if (queue().length === 0 && helperText()) {\n <div class=\"tk-uploader__helper\">\n <span>{{ helperText() }}</span>\n @if (helperLinkText() && helperLinkUrl()) {\n <a\n [href]=\"helperLinkUrl()\"\n target=\"_blank\"\n rel=\"noopener\"\n class=\"tk-uploader__helper-link\">\n {{ helperLinkText() }}\n </a>\n }\n </div>\n}\n", styles: [".tk-uploader{position:relative;overflow:hidden;border:1px dashed var(--tk-color-border-default);border-radius:var(--tk-borderRadius-m);background-color:var(--tk-color-background-default);padding:var(--tk-spacing-base-200) var(--tk-spacing-base-100);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:all .2s ease-in-out;width:100%}.tk-uploader--has-file{border-color:transparent;border-radius:0;background-color:var(--tk-color-base-surface-0);justify-content:flex-start;padding:0;overflow:visible}.tk-uploader--dragging{background-color:var(--tk-color-primary-muted);border-color:var(--tk-color-border-default);padding:var(--tk-spacing-base-200) var(--tk-spacing-base-100)}.tk-uploader--dragging>*{pointer-events:none}.tk-uploader--disabled{cursor:default;opacity:.8}.tk-uploader--disabled .tk-uploader__idle,.tk-uploader--disabled .tk-uploader__placeholder{cursor:default;pointer-events:none}.tk-uploader__idle{border:none;background:none;padding:0;margin:0;font:inherit;color:inherit;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--tk-spacing-base-150);text-align:center;width:100%;height:100%}.tk-uploader__illustration{display:flex;align-items:center;justify-content:center}.tk-uploader__text{display:flex;flex-direction:column;align-items:center;gap:var(--tk-spacing-base-100)}.tk-uploader__title{margin:0;font-size:var(--tk-font-size-paragraph-m);color:var(--tk-color-text-default);font-weight:600}.tk-uploader__subtitle{margin:0;font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default);font-weight:400}.tk-uploader__list,.tk-uploader__placeholder{width:100%;display:flex;flex-direction:column;gap:var(--tk-spacing-base-100);transition:transform .2s ease-in-out}.tk-uploader__list--dragging,.tk-uploader__placeholder--dragging{filter:brightness(1.05)}.tk-uploader__helper{display:flex;flex-wrap:wrap;gap:var(--tk-spacing-base-25);font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default);margin-top:var(--tk-spacing-base-100)}.tk-uploader__helper-link{color:var(--tk-color-primary-default);font-weight:600;text-decoration:none}.tk-uploader__helper-link:hover{text-decoration:underline}\n"] }]
|
|
909
|
+
}], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], subtitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "subtitle", required: false }] }], helperText: [{ type: i0.Input, args: [{ isSignal: true, alias: "helperText", required: false }] }], helperLinkText: [{ type: i0.Input, args: [{ isSignal: true, alias: "helperLinkText", required: false }] }], helperLinkUrl: [{ type: i0.Input, args: [{ isSignal: true, alias: "helperLinkUrl", required: false }] }], config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], pendingRemovalIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "pendingRemovalIds", required: false }] }], removeLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "removeLabel", required: false }] }], placeholderReplaceLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholderReplaceLabel", required: false }] }], fileSelected: [{ type: i0.Output, args: ["fileSelected"] }], removeRequested: [{ type: i0.Output, args: ["removeRequested"] }], stepsCountdownFinished: [{ type: i0.Output, args: ["stepsCountdownFinished"] }], fileInput: [{ type: i0.ViewChild, args: ['fileInput', { isSignal: true }] }] } });
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Default `IUploaderStore` — a plain in-memory signal, nothing persisted.
|
|
913
|
+
* Registered by `provideUploader()` under `UPLOADER_STORE`; one instance per
|
|
914
|
+
* `tk-uploader` (not `providedIn: 'root'`), so each gets its own queue.
|
|
915
|
+
*/
|
|
916
|
+
class UploaderStore {
|
|
917
|
+
constructor() {
|
|
918
|
+
this.queue = signal([], ...(ngDevMode ? [{ debugName: "queue" }] : /* istanbul ignore next */ []));
|
|
919
|
+
this.uploadQueue = this.queue.asReadonly();
|
|
920
|
+
}
|
|
921
|
+
add(item) {
|
|
922
|
+
this.queue.update((items) => [...items, item]);
|
|
923
|
+
}
|
|
924
|
+
update(id, updates) {
|
|
925
|
+
this.queue.update((items) => items.map((item) => (item.id === id ? { ...item, ...updates } : item)));
|
|
926
|
+
}
|
|
927
|
+
remove(id) {
|
|
928
|
+
this.queue.update((items) => items.filter((item) => item.id !== id));
|
|
929
|
+
}
|
|
930
|
+
clear() {
|
|
931
|
+
this.queue.set([]);
|
|
932
|
+
}
|
|
933
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
934
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderStore }); }
|
|
935
|
+
}
|
|
936
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderStore, decorators: [{
|
|
937
|
+
type: Injectable
|
|
938
|
+
}] });
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Default `IUploaderPreviewProvider` — generates a client-side preview/
|
|
942
|
+
* metadata pass during `analyzing` (dimensions for images/video, duration
|
|
943
|
+
* for audio/video, a downscaled preview frame), entirely in the browser
|
|
944
|
+
* with no network call. Registered by `provideUploader()` under
|
|
945
|
+
* `UPLOADER_PREVIEW_PROVIDER`.
|
|
946
|
+
*/
|
|
947
|
+
class UploaderPreviewProvider {
|
|
948
|
+
/** Kicks off the analysis as an Angular `resource()`, re-run if `file` changes identity. */
|
|
949
|
+
generatePreview(file) {
|
|
950
|
+
return resource({
|
|
951
|
+
params: () => file,
|
|
952
|
+
loader: ({ params: f }) => this.analyzeFile(f)
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
async analyzeFile(file) {
|
|
956
|
+
const type = this.normalizeMimeType(file);
|
|
957
|
+
try {
|
|
958
|
+
return await this.dispatchByType(file, type);
|
|
959
|
+
}
|
|
960
|
+
catch (error) {
|
|
961
|
+
return this.createErrorResult(file, type, error);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
dispatchByType(file, type) {
|
|
965
|
+
if (type.startsWith('image/')) {
|
|
966
|
+
return this.processImageFile(file, type);
|
|
967
|
+
}
|
|
968
|
+
if (type.startsWith('video/')) {
|
|
969
|
+
return this.processVideoFile(file, type);
|
|
970
|
+
}
|
|
971
|
+
if (type.startsWith('audio/')) {
|
|
972
|
+
return this.processAudioFile(file, type);
|
|
973
|
+
}
|
|
974
|
+
return Promise.resolve(this.createAnalysisResult(file, type, { size: file.size }));
|
|
975
|
+
}
|
|
976
|
+
createAnalysisResult(file, mimeType, metadata, previews) {
|
|
977
|
+
return {
|
|
978
|
+
previewUrl: previews?.url || '',
|
|
979
|
+
previewBase64: previews?.base64 || '',
|
|
980
|
+
previewBlob: previews?.blob,
|
|
981
|
+
analysisError: false,
|
|
982
|
+
metadata: { ...metadata, mimeType }
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
createErrorResult(file, mimeType, error) {
|
|
986
|
+
return {
|
|
987
|
+
previewUrl: '',
|
|
988
|
+
previewBase64: '',
|
|
989
|
+
previewBlob: undefined,
|
|
990
|
+
analysisError: true,
|
|
991
|
+
technicalError: this.resolveTechnicalError(mimeType),
|
|
992
|
+
errorDetail: error,
|
|
993
|
+
metadata: { size: file.size, mimeType }
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
resolveTechnicalError(mimeType) {
|
|
997
|
+
const errorMap = {
|
|
998
|
+
'image/': 'PROCESS_FAILED',
|
|
999
|
+
'video/': 'PROCESS_FAILED',
|
|
1000
|
+
'audio/': 'PROCESS_FAILED'
|
|
1001
|
+
};
|
|
1002
|
+
return (Object.entries(errorMap).find(([key]) => mimeType.startsWith(key))?.[1] ||
|
|
1003
|
+
'ANALYSIS_FAILED');
|
|
1004
|
+
}
|
|
1005
|
+
resolveMimeFromExtension(file) {
|
|
1006
|
+
const extension = file.name.split('.').pop()?.toLowerCase();
|
|
1007
|
+
const mimeMap = {
|
|
1008
|
+
zip: 'application/zip',
|
|
1009
|
+
pdf: 'application/pdf',
|
|
1010
|
+
jpg: 'image/jpeg',
|
|
1011
|
+
jpeg: 'image/jpeg',
|
|
1012
|
+
png: 'image/png',
|
|
1013
|
+
gif: 'image/gif',
|
|
1014
|
+
mp4: 'video/mp4',
|
|
1015
|
+
mp3: 'audio/mpeg'
|
|
1016
|
+
};
|
|
1017
|
+
return extension ? mimeMap[extension] : undefined;
|
|
1018
|
+
}
|
|
1019
|
+
normalizeMimeType(file) {
|
|
1020
|
+
const byExtension = this.resolveMimeFromExtension(file);
|
|
1021
|
+
if (byExtension) {
|
|
1022
|
+
return byExtension;
|
|
1023
|
+
}
|
|
1024
|
+
const type = file.type.toLowerCase();
|
|
1025
|
+
const isZipVariant = type === 'application/x-zip-compressed' ||
|
|
1026
|
+
type === 'application/zip-compressed';
|
|
1027
|
+
return isZipVariant ? 'application/zip' : type || 'application/octet-stream';
|
|
1028
|
+
}
|
|
1029
|
+
async processImageFile(file, type) {
|
|
1030
|
+
const img = new Image();
|
|
1031
|
+
const objectUrl = URL.createObjectURL(file);
|
|
1032
|
+
img.src = objectUrl;
|
|
1033
|
+
try {
|
|
1034
|
+
await this.waitForReady(img, 'load');
|
|
1035
|
+
return await this.buildImageAnalysis(img, file, type);
|
|
1036
|
+
}
|
|
1037
|
+
finally {
|
|
1038
|
+
URL.revokeObjectURL(objectUrl);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
async buildImageAnalysis(img, file, type) {
|
|
1042
|
+
const preview = await this.captureScaledFrame(img, img.naturalWidth, img.naturalHeight);
|
|
1043
|
+
return this.createAnalysisResult(file, type, this.buildImageMetadata(img, file), preview);
|
|
1044
|
+
}
|
|
1045
|
+
buildImageMetadata(img, file) {
|
|
1046
|
+
return {
|
|
1047
|
+
width: img.naturalWidth,
|
|
1048
|
+
height: img.naturalHeight,
|
|
1049
|
+
aspectRatio: img.naturalWidth / img.naturalHeight,
|
|
1050
|
+
size: file.size
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
async processAudioFile(file, type) {
|
|
1054
|
+
const audio = new Audio();
|
|
1055
|
+
const objectUrl = URL.createObjectURL(file);
|
|
1056
|
+
audio.src = objectUrl;
|
|
1057
|
+
try {
|
|
1058
|
+
await this.waitForReady(audio, 'loadedmetadata');
|
|
1059
|
+
return this.createAnalysisResult(file, type, {
|
|
1060
|
+
durationMs: audio.duration * 1000,
|
|
1061
|
+
size: file.size
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
finally {
|
|
1065
|
+
URL.revokeObjectURL(objectUrl);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async processVideoFile(file, type) {
|
|
1069
|
+
const video = this.createVideoElement(file);
|
|
1070
|
+
try {
|
|
1071
|
+
await this.waitForReady(video, 'loadedmetadata');
|
|
1072
|
+
await this.seekToTime(video, 1);
|
|
1073
|
+
return await this.buildVideoAnalysis(video, file, type);
|
|
1074
|
+
}
|
|
1075
|
+
finally {
|
|
1076
|
+
URL.revokeObjectURL(video.src);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
async buildVideoAnalysis(video, file, type) {
|
|
1080
|
+
const preview = await this.captureScaledFrame(video, video.videoWidth, video.videoHeight);
|
|
1081
|
+
return this.createAnalysisResult(file, type, this.buildVideoMetadata(video, file), preview);
|
|
1082
|
+
}
|
|
1083
|
+
buildVideoMetadata(video, file) {
|
|
1084
|
+
return {
|
|
1085
|
+
width: video.videoWidth,
|
|
1086
|
+
height: video.videoHeight,
|
|
1087
|
+
durationMs: video.duration * 1000,
|
|
1088
|
+
aspectRatio: video.videoWidth / video.videoHeight,
|
|
1089
|
+
size: file.size
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
createVideoElement(file) {
|
|
1093
|
+
const video = document.createElement('video');
|
|
1094
|
+
video.muted = true;
|
|
1095
|
+
video.playsInline = true;
|
|
1096
|
+
video.src = URL.createObjectURL(file);
|
|
1097
|
+
return video;
|
|
1098
|
+
}
|
|
1099
|
+
async captureScaledFrame(source, naturalWidth, naturalHeight) {
|
|
1100
|
+
const { width, height } = this.calculateDimensions(naturalWidth, naturalHeight);
|
|
1101
|
+
return this.capturePreviewFrame(source, width, height);
|
|
1102
|
+
}
|
|
1103
|
+
async capturePreviewFrame(source, width, height) {
|
|
1104
|
+
const canvas = this.prepareCanvas(width, height);
|
|
1105
|
+
const ctx = canvas.getContext('2d');
|
|
1106
|
+
ctx?.drawImage(source, 0, 0, width, height);
|
|
1107
|
+
const base64 = canvas.toDataURL('image/jpeg', 0.8);
|
|
1108
|
+
const blob = (await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.8)));
|
|
1109
|
+
const url = URL.createObjectURL(blob);
|
|
1110
|
+
return { url, blob, base64 };
|
|
1111
|
+
}
|
|
1112
|
+
async waitForReady(target, readyEvent) {
|
|
1113
|
+
await Promise.race([
|
|
1114
|
+
firstValueFrom(fromEvent(target, readyEvent)),
|
|
1115
|
+
firstValueFrom(fromEvent(target, 'error')).then(() => {
|
|
1116
|
+
throw new Error('PROCESS_FAILED');
|
|
1117
|
+
})
|
|
1118
|
+
]);
|
|
1119
|
+
}
|
|
1120
|
+
seekToTime(video, seconds) {
|
|
1121
|
+
video.currentTime = seconds;
|
|
1122
|
+
return firstValueFrom(fromEvent(video, 'seeked'));
|
|
1123
|
+
}
|
|
1124
|
+
prepareCanvas(width, height) {
|
|
1125
|
+
const canvas = document.createElement('canvas');
|
|
1126
|
+
canvas.width = width;
|
|
1127
|
+
canvas.height = height;
|
|
1128
|
+
return canvas;
|
|
1129
|
+
}
|
|
1130
|
+
calculateDimensions(originalWidth, originalHeight, maxSize = 400) {
|
|
1131
|
+
if (originalWidth <= maxSize && originalHeight <= maxSize) {
|
|
1132
|
+
return { width: originalWidth, height: originalHeight };
|
|
1133
|
+
}
|
|
1134
|
+
const ratio = originalWidth / originalHeight;
|
|
1135
|
+
if (originalWidth > originalHeight) {
|
|
1136
|
+
return { width: maxSize, height: maxSize / ratio };
|
|
1137
|
+
}
|
|
1138
|
+
return { width: maxSize * ratio, height: maxSize };
|
|
1139
|
+
}
|
|
1140
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderPreviewProvider, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1141
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderPreviewProvider }); }
|
|
1142
|
+
}
|
|
1143
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderPreviewProvider, decorators: [{
|
|
1144
|
+
type: Injectable
|
|
1145
|
+
}] });
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Default `IUploaderTransport` — a bare `HttpClient` (via `HttpBackend`, so
|
|
1149
|
+
* it bypasses app-level interceptors) that reports upload progress as a
|
|
1150
|
+
* plain 0-100 number. Registered by `provideUploader()` under
|
|
1151
|
+
* `UPLOADER_TRANSPORT`, for the consumer's own `steps`/`multipart.uploadPart`
|
|
1152
|
+
* functions to `inject()` — `tk-uploader` never calls this itself.
|
|
1153
|
+
*/
|
|
1154
|
+
class TkUploadTransportService {
|
|
1155
|
+
constructor() {
|
|
1156
|
+
this.handler = inject(HttpBackend);
|
|
1157
|
+
this.http = new HttpClient(this.handler);
|
|
1158
|
+
}
|
|
1159
|
+
upload(url, file, method = 'PUT', headers = {}, reportProgress = true, observe = 'events', withCredentials = false) {
|
|
1160
|
+
const finalHeaders = this.resolveHeaders(headers ?? {}, file);
|
|
1161
|
+
if (observe === 'body') {
|
|
1162
|
+
return this.http.request(method, url, {
|
|
1163
|
+
body: file,
|
|
1164
|
+
headers: finalHeaders,
|
|
1165
|
+
reportProgress,
|
|
1166
|
+
observe: 'body',
|
|
1167
|
+
withCredentials
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
return this.http
|
|
1171
|
+
.request(method, url, {
|
|
1172
|
+
body: file,
|
|
1173
|
+
headers: finalHeaders,
|
|
1174
|
+
reportProgress,
|
|
1175
|
+
observe: 'events',
|
|
1176
|
+
withCredentials
|
|
1177
|
+
})
|
|
1178
|
+
.pipe(map((event) => this.mapUploadEvent(event)));
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Merges the caller's headers with a normalized `Content-Type`
|
|
1182
|
+
* (defaulting `.zip`-flavored mime types to `application/zip`).
|
|
1183
|
+
* @private
|
|
1184
|
+
*/
|
|
1185
|
+
resolveHeaders(headers, file) {
|
|
1186
|
+
const finalHeaders = { ...headers };
|
|
1187
|
+
const contentType = this.resolveContentType(headers, file);
|
|
1188
|
+
if (contentType) {
|
|
1189
|
+
finalHeaders['Content-Type'] = contentType;
|
|
1190
|
+
delete finalHeaders['content-type'];
|
|
1191
|
+
}
|
|
1192
|
+
return finalHeaders;
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* @private
|
|
1196
|
+
*/
|
|
1197
|
+
resolveContentType(headers, file) {
|
|
1198
|
+
const contentType = this.headerValue(headers['Content-Type']) ||
|
|
1199
|
+
this.headerValue(headers['content-type']) ||
|
|
1200
|
+
file.type ||
|
|
1201
|
+
'';
|
|
1202
|
+
const fileName = file instanceof File ? file.name : '';
|
|
1203
|
+
const isZip = fileName.toLowerCase().endsWith('.zip') ||
|
|
1204
|
+
contentType === 'application/x-zip-compressed' ||
|
|
1205
|
+
contentType === 'application/zip-compressed';
|
|
1206
|
+
return isZip ? 'application/zip' : contentType;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* @private
|
|
1210
|
+
*/
|
|
1211
|
+
headerValue(value) {
|
|
1212
|
+
return Array.isArray(value) ? value.join(',') : (value ?? '');
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* @private
|
|
1216
|
+
*/
|
|
1217
|
+
mapUploadEvent(event) {
|
|
1218
|
+
switch (event.type) {
|
|
1219
|
+
case HttpEventType.UploadProgress:
|
|
1220
|
+
return Math.round((100 * event.loaded) / (event.total || 1));
|
|
1221
|
+
case HttpEventType.Response:
|
|
1222
|
+
return 100;
|
|
1223
|
+
default:
|
|
1224
|
+
return 0;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TkUploadTransportService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1228
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TkUploadTransportService }); }
|
|
1229
|
+
}
|
|
1230
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TkUploadTransportService, decorators: [{
|
|
1231
|
+
type: Injectable
|
|
1232
|
+
}] });
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Registers every service `tk-uploader` depends on (queue, preview,
|
|
1236
|
+
* transport) — add to the `providers` of the component that hosts
|
|
1237
|
+
* `tk-uploader` (or a parent). Each instance gets its own queue.
|
|
1238
|
+
*/
|
|
1239
|
+
function provideUploader() {
|
|
1240
|
+
return [
|
|
1241
|
+
UploaderStore,
|
|
1242
|
+
{ provide: UPLOADER_STORE, useExisting: UploaderStore },
|
|
1243
|
+
UploaderPreviewProvider,
|
|
1244
|
+
{ provide: UPLOADER_PREVIEW_PROVIDER, useExisting: UploaderPreviewProvider },
|
|
1245
|
+
TkUploadTransportService,
|
|
1246
|
+
{ provide: UPLOADER_TRANSPORT, useExisting: TkUploadTransportService },
|
|
1247
|
+
UploaderQueueService
|
|
1248
|
+
];
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Generated bundle index. Do not edit.
|
|
1253
|
+
*/
|
|
1254
|
+
|
|
1255
|
+
export { UPLOADER_PREVIEW_PROVIDER, UPLOADER_STORE, UPLOADER_TRANSPORT, UploaderComponent, UploaderQueueService, provideUploader };
|
|
1256
|
+
//# sourceMappingURL=tekus-design-system-components-uploader.mjs.map
|