@sd-angular/core 19.0.46 → 19.0.47
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/components/upload-file/src/upload-file.component.d.ts +28 -2
- package/fesm2022/sd-angular-core-components-upload-file.mjs +142 -56
- package/fesm2022/sd-angular-core-components-upload-file.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-select.mjs +15 -16
- package/fesm2022/sd-angular-core-forms-select.mjs.map +1 -1
- package/package.json +13 -13
|
@@ -66,8 +66,34 @@ export declare class SdUploadFile<TArgs = any> {
|
|
|
66
66
|
*/
|
|
67
67
|
getFiles: () => Promise<File[]>;
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
69
|
+
* Upload the local files referenced by the model, then replace their temporary
|
|
70
|
+
* keys with server IDs. Call and await this method before submitting the model.
|
|
71
|
+
*
|
|
72
|
+
* Files are read from the model and local snapshots/cache, so pending or failed metadata
|
|
73
|
+
* requests do not omit files. Each distinct local key is uploaded once per
|
|
74
|
+
* batch; duplicate occurrences retain their positions and share the returned ID.
|
|
75
|
+
* Server IDs and URLs are preserved even when they have no preview metadata.
|
|
76
|
+
*
|
|
77
|
+
* Concurrent calls share the active batch. Changes made while it is pending
|
|
78
|
+
* are preserved: removed keys stay removed, reordering is retained, and newly
|
|
79
|
+
* added local keys remain pending for a subsequent call. After destruction, no
|
|
80
|
+
* new upload starts and an in-flight upload cannot update the model.
|
|
81
|
+
*
|
|
82
|
+
* @returns A promise resolving to void after the batch finishes. The preview
|
|
83
|
+
* metadata refresh may still be pending. With no local files this is a no-op.
|
|
84
|
+
* A missing upload handler displays the configuration error and resolves
|
|
85
|
+
* without changing the model or cache.
|
|
86
|
+
* @throws The upload handler's error, without changing the model or cache.
|
|
87
|
+
* @throws Error if the handler does not return exactly one non-empty string ID
|
|
88
|
+
* per distinct local key; the model and cache are retained in that case.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* import { SdUploadFile } from '@sd-angular/core/components/upload-file';
|
|
92
|
+
*
|
|
93
|
+
* async function submit(control: SdUploadFile) {
|
|
94
|
+
* await control.upload();
|
|
95
|
+
* return control.model(); // Server IDs/URLs for the uploaded batch.
|
|
96
|
+
* }
|
|
71
97
|
*/
|
|
72
98
|
upload: () => Promise<void>;
|
|
73
99
|
/**
|
|
@@ -199,6 +199,9 @@ class SdUploadFile {
|
|
|
199
199
|
#canvas2 = `C${Utilities.generateUuid()}`;
|
|
200
200
|
#name = Utilities.generateUuid();
|
|
201
201
|
#formGroup;
|
|
202
|
+
#uploadPromise;
|
|
203
|
+
#destroyed = false;
|
|
204
|
+
#localFiles = new Map();
|
|
202
205
|
formControl = new SdFormControl();
|
|
203
206
|
// why: component là OnPush nhưng template đọc formControl.touched/errors như thuộc tính
|
|
204
207
|
// THƯỜNG (không reactive). Khi cha submit (markAllAsTouched) hoặc validator gắn lỗi, không
|
|
@@ -324,19 +327,34 @@ class SdUploadFile {
|
|
|
324
327
|
}
|
|
325
328
|
});
|
|
326
329
|
// Reactive sync for model changes
|
|
327
|
-
effect(
|
|
328
|
-
const
|
|
330
|
+
effect(onCleanup => {
|
|
331
|
+
const modelValue = this.model();
|
|
332
|
+
const values = modelValue || [];
|
|
333
|
+
let active = true;
|
|
334
|
+
let cancelReaders;
|
|
335
|
+
// why: model có thể đổi trước lần effect kế tiếp; cleanup thôi chưa chặn được
|
|
336
|
+
// Promise cũ resolve trong khoảng đó. Destroy cũng phải chặn mọi emit/ghi state.
|
|
337
|
+
const isCurrent = () => active && !this.#destroyed && this.model() === modelValue;
|
|
338
|
+
onCleanup(() => {
|
|
339
|
+
active = false;
|
|
340
|
+
cancelReaders?.();
|
|
341
|
+
});
|
|
329
342
|
this.formControl.setValue(values);
|
|
330
343
|
if (Array.isArray(values)) {
|
|
331
344
|
this.#details(values).then(previewFiles => {
|
|
345
|
+
if (!isCurrent())
|
|
346
|
+
return;
|
|
332
347
|
this.previewFiles.set(previewFiles);
|
|
333
348
|
this.filesChanged.emit(this.previewFiles()
|
|
334
349
|
.map(e => e.file || e.idOrKey || e.src)
|
|
335
350
|
.filter(val => !!val));
|
|
336
|
-
|
|
351
|
+
// why: consumer của filesChanged có thể đổi model hoặc destroy đồng bộ.
|
|
352
|
+
if (isCurrent())
|
|
353
|
+
cancelReaders = this.#preview(isCurrent);
|
|
337
354
|
});
|
|
338
355
|
}
|
|
339
356
|
else {
|
|
357
|
+
this.#localFiles.clear();
|
|
340
358
|
this.previewFiles.set([]);
|
|
341
359
|
}
|
|
342
360
|
});
|
|
@@ -364,6 +382,8 @@ class SdUploadFile {
|
|
|
364
382
|
});
|
|
365
383
|
// Cleanup on destroy
|
|
366
384
|
this.#destroyRef.onDestroy(() => {
|
|
385
|
+
this.#destroyed = true;
|
|
386
|
+
this.#localFiles.clear();
|
|
367
387
|
this.#revokePreviewBlobURLs();
|
|
368
388
|
this.#formGroup?.removeControl(this.#name);
|
|
369
389
|
});
|
|
@@ -495,17 +515,28 @@ class SdUploadFile {
|
|
|
495
515
|
}
|
|
496
516
|
return null;
|
|
497
517
|
};
|
|
498
|
-
#preview = () => {
|
|
518
|
+
#preview = (isCurrent) => {
|
|
519
|
+
const readers = [];
|
|
499
520
|
for (const previewFile of this.previewFiles().filter(e => e.file)) {
|
|
500
521
|
const reader = new FileReader();
|
|
501
522
|
reader.onload = evt => {
|
|
523
|
+
if (!isCurrent())
|
|
524
|
+
return;
|
|
502
525
|
previewFile.previewSrc = evt.target.result;
|
|
503
526
|
previewFile.isPreviewImage = previewFile.file?.type.split('/')[0] === 'image' && previewFile.file?.type !== 'image/tiff';
|
|
504
527
|
// trigger signal update
|
|
505
528
|
this.previewFiles.set([...this.previewFiles()]);
|
|
506
529
|
};
|
|
530
|
+
readers.push(reader);
|
|
507
531
|
reader.readAsDataURL(previewFile.file);
|
|
508
532
|
}
|
|
533
|
+
return () => {
|
|
534
|
+
for (const reader of readers) {
|
|
535
|
+
reader.onload = null;
|
|
536
|
+
if (reader.readyState === FileReader.LOADING)
|
|
537
|
+
reader.abort();
|
|
538
|
+
}
|
|
539
|
+
};
|
|
509
540
|
};
|
|
510
541
|
// ─── Public Event Handlers ────────────────────────────────────────────
|
|
511
542
|
onUpload = () => {
|
|
@@ -535,6 +566,7 @@ class SdUploadFile {
|
|
|
535
566
|
};
|
|
536
567
|
#uploadFile = async (originFiles) => {
|
|
537
568
|
const currentModel = [...(this.model() || [])];
|
|
569
|
+
const pickedFiles = new Map();
|
|
538
570
|
if (originFiles.length + currentModel.length > this.max()) {
|
|
539
571
|
this.#notifyService.warning(this.#i18n.t('core.component.upload-file.too-many-files'));
|
|
540
572
|
return;
|
|
@@ -551,9 +583,15 @@ class SdUploadFile {
|
|
|
551
583
|
}
|
|
552
584
|
const hashedKey = this.#uploadFileService.add(file);
|
|
553
585
|
if (hashedKey) {
|
|
586
|
+
pickedFiles.set(hashedKey, file);
|
|
554
587
|
currentModel.push(hashedKey);
|
|
555
588
|
}
|
|
556
589
|
}
|
|
590
|
+
// why: giữ batch riêng qua từng await validate/resize; cache chung và effect của
|
|
591
|
+
// model cũ có thể mất hash trước khi picker hoàn tất. Chỉ nhận ownership khi commit.
|
|
592
|
+
for (const [key, file] of pickedFiles)
|
|
593
|
+
this.#localFiles.set(key, file);
|
|
594
|
+
this.#snapshotFiles(currentModel);
|
|
557
595
|
this.model.set(currentModel);
|
|
558
596
|
this.#touch();
|
|
559
597
|
};
|
|
@@ -685,50 +723,81 @@ class SdUploadFile {
|
|
|
685
723
|
return items.map(e => e?.file).filter(file => !!file);
|
|
686
724
|
};
|
|
687
725
|
/**
|
|
688
|
-
*
|
|
689
|
-
*
|
|
726
|
+
* Upload the local files referenced by the model, then replace their temporary
|
|
727
|
+
* keys with server IDs. Call and await this method before submitting the model.
|
|
728
|
+
*
|
|
729
|
+
* Files are read from the model and local snapshots/cache, so pending or failed metadata
|
|
730
|
+
* requests do not omit files. Each distinct local key is uploaded once per
|
|
731
|
+
* batch; duplicate occurrences retain their positions and share the returned ID.
|
|
732
|
+
* Server IDs and URLs are preserved even when they have no preview metadata.
|
|
733
|
+
*
|
|
734
|
+
* Concurrent calls share the active batch. Changes made while it is pending
|
|
735
|
+
* are preserved: removed keys stay removed, reordering is retained, and newly
|
|
736
|
+
* added local keys remain pending for a subsequent call. After destruction, no
|
|
737
|
+
* new upload starts and an in-flight upload cannot update the model.
|
|
738
|
+
*
|
|
739
|
+
* @returns A promise resolving to void after the batch finishes. The preview
|
|
740
|
+
* metadata refresh may still be pending. With no local files this is a no-op.
|
|
741
|
+
* A missing upload handler displays the configuration error and resolves
|
|
742
|
+
* without changing the model or cache.
|
|
743
|
+
* @throws The upload handler's error, without changing the model or cache.
|
|
744
|
+
* @throws Error if the handler does not return exactly one non-empty string ID
|
|
745
|
+
* per distinct local key; the model and cache are retained in that case.
|
|
746
|
+
*
|
|
747
|
+
* @example
|
|
748
|
+
* import { SdUploadFile } from '@sd-angular/core/components/upload-file';
|
|
749
|
+
*
|
|
750
|
+
* async function submit(control: SdUploadFile) {
|
|
751
|
+
* await control.upload();
|
|
752
|
+
* return control.model(); // Server IDs/URLs for the uploaded batch.
|
|
753
|
+
* }
|
|
690
754
|
*/
|
|
691
|
-
upload =
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
755
|
+
upload = () => {
|
|
756
|
+
if (this.#destroyed)
|
|
757
|
+
return Promise.resolve();
|
|
758
|
+
if (!this.#uploadPromise) {
|
|
759
|
+
// why: hai lần submit đồng thời phải dùng chung batch, tránh upload trùng File.
|
|
760
|
+
this.#uploadPromise = this.#uploadPendingFiles().finally(() => {
|
|
761
|
+
this.#uploadPromise = undefined;
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
return this.#uploadPromise;
|
|
765
|
+
};
|
|
766
|
+
#uploadPendingFiles = async () => {
|
|
767
|
+
const pending = [
|
|
768
|
+
...new Map(this.#snapshotFiles(this.model())
|
|
769
|
+
.filter(entry => entry.file)
|
|
770
|
+
.map(entry => [entry.key, entry])).values(),
|
|
771
|
+
];
|
|
772
|
+
if (!pending.length)
|
|
773
|
+
return;
|
|
774
|
+
const uploadHandler = this.#getUploadHandler();
|
|
775
|
+
if (!uploadHandler) {
|
|
776
|
+
const key = this.key();
|
|
777
|
+
this.#notifyService.error(key === undefined
|
|
778
|
+
? this.#i18n.t('core.component.upload-file.no-configuration')
|
|
779
|
+
: this.#i18n.t('core.component.upload-file.no-configuration-key', { key }));
|
|
695
780
|
return;
|
|
696
781
|
}
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
if (uploadHandler) {
|
|
703
|
-
idOrKeys = await uploadHandler(uploadedFiles, this.args()).then(results => {
|
|
704
|
-
for (const uploadedFile of uploadedFiles) {
|
|
705
|
-
this.#uploadFileService.remove(uploadedFile);
|
|
706
|
-
}
|
|
707
|
-
return results;
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
else {
|
|
711
|
-
const key = this.key();
|
|
712
|
-
this.#notifyService.error(key === undefined
|
|
713
|
-
? this.#i18n.t('core.component.upload-file.no-configuration')
|
|
714
|
-
: this.#i18n.t('core.component.upload-file.no-configuration-key', { key }));
|
|
715
|
-
}
|
|
782
|
+
const idOrKeys = await uploadHandler(pending.map(entry => entry.file), this.args());
|
|
783
|
+
if (!Array.isArray(idOrKeys) ||
|
|
784
|
+
idOrKeys.length !== pending.length ||
|
|
785
|
+
pending.some((_, index) => typeof idOrKeys[index] !== 'string' || !idOrKeys[index])) {
|
|
786
|
+
throw new Error('[sd-upload-file] Upload must return one non-empty string ID per file.');
|
|
716
787
|
}
|
|
717
|
-
|
|
718
|
-
for (const
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
}
|
|
788
|
+
const replacements = new Map(pending.map((entry, index) => [entry.key, idOrKeys[index]]));
|
|
789
|
+
for (const entry of pending) {
|
|
790
|
+
this.#uploadFileService.remove(entry.key.toString());
|
|
791
|
+
this.#localFiles.delete(entry.key.toString());
|
|
792
|
+
}
|
|
793
|
+
if (this.#destroyed)
|
|
794
|
+
return;
|
|
795
|
+
// why: thay đúng hash trên model MỚI NHẤT, không phục hồi file đã xoá hoặc
|
|
796
|
+
// ghi đè các thao tác thêm/sắp xếp trong lúc upload đang chờ.
|
|
797
|
+
const values = this.model();
|
|
798
|
+
if (Array.isArray(values)) {
|
|
799
|
+
this.model.set(values.map(key => replacements.get(key) ?? key));
|
|
730
800
|
}
|
|
731
|
-
this.model.set(results);
|
|
732
801
|
};
|
|
733
802
|
/**
|
|
734
803
|
* Mở popup preview ảnh.
|
|
@@ -771,20 +840,23 @@ class SdUploadFile {
|
|
|
771
840
|
* cần render ra ảnh/tệp tương ứng.
|
|
772
841
|
*/
|
|
773
842
|
#details = async (keys) => {
|
|
774
|
-
|
|
843
|
+
const entries = this.#snapshotFiles(keys);
|
|
844
|
+
if (!entries.length) {
|
|
775
845
|
return [];
|
|
776
846
|
}
|
|
777
|
-
const idOrKeys =
|
|
847
|
+
const idOrKeys = entries.filter(entry => !this.#isCdn(entry.key) && !entry.file).map(entry => entry.key);
|
|
778
848
|
let detailsList = [];
|
|
779
849
|
const detailsHandler = this.#getDetailsHandler();
|
|
780
850
|
if (idOrKeys.length && detailsHandler) {
|
|
781
|
-
|
|
851
|
+
try {
|
|
852
|
+
detailsList = await detailsHandler(idOrKeys, this.args());
|
|
853
|
+
}
|
|
854
|
+
catch (error) {
|
|
782
855
|
console.error(error);
|
|
783
|
-
|
|
784
|
-
});
|
|
856
|
+
}
|
|
785
857
|
}
|
|
786
858
|
const results = [];
|
|
787
|
-
for (const key
|
|
859
|
+
for (const { key, file } of entries) {
|
|
788
860
|
if (this.#isCdn(key)) {
|
|
789
861
|
const cdn = key?.toString();
|
|
790
862
|
const extension = this.#getExtension(cdn);
|
|
@@ -799,9 +871,7 @@ class SdUploadFile {
|
|
|
799
871
|
extension,
|
|
800
872
|
});
|
|
801
873
|
}
|
|
802
|
-
else if (
|
|
803
|
-
const hashedKey = key?.toString();
|
|
804
|
-
const file = this.#uploadFileService.get(hashedKey);
|
|
874
|
+
else if (file) {
|
|
805
875
|
const lastDot = file.name.lastIndexOf('.');
|
|
806
876
|
const extension = file.name.substring(lastDot + 1);
|
|
807
877
|
const isPreviewImage = (file.type.split('/')[0] === 'image' && file.type !== 'image/tiff') || IsImage(extension);
|
|
@@ -842,10 +912,26 @@ class SdUploadFile {
|
|
|
842
912
|
return false;
|
|
843
913
|
return idOrKey.startsWith('http');
|
|
844
914
|
};
|
|
845
|
-
#
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
915
|
+
#snapshotFiles = (keys) => {
|
|
916
|
+
const values = Array.isArray(keys) ? keys : [];
|
|
917
|
+
const activeKeys = new Set(values);
|
|
918
|
+
for (const key of this.#localFiles.keys()) {
|
|
919
|
+
if (!activeKeys.has(key))
|
|
920
|
+
this.#localFiles.delete(key);
|
|
921
|
+
}
|
|
922
|
+
// why: cache có thể bị upload xoá trong lúc chờ details; giữ File theo snapshot
|
|
923
|
+
// của từng ô và request, kể cả khi ô khác upload cùng hash rồi xoá cache chung.
|
|
924
|
+
return values
|
|
925
|
+
.filter(key => !!key)
|
|
926
|
+
.map(key => {
|
|
927
|
+
let file = null;
|
|
928
|
+
if (typeof key === 'string' && !this.#isCdn(key)) {
|
|
929
|
+
file = this.#localFiles.get(key) ?? this.#uploadFileService.get(key);
|
|
930
|
+
if (file)
|
|
931
|
+
this.#localFiles.set(key, file);
|
|
932
|
+
}
|
|
933
|
+
return { key, file };
|
|
934
|
+
});
|
|
849
935
|
};
|
|
850
936
|
#getExtension = (cdn) => {
|
|
851
937
|
if (!cdn)
|