@akinon/pz-similar-products 1.92.0-snapshot-ZERO-3457-20250627121541 → 1.93.0-rc.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.
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from 'react';
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
2
  import { Product } from '@akinon/next/types';
3
3
  import {
4
4
  useLazyGetSimilarProductsByUrlQuery,
@@ -13,17 +13,19 @@ import {
13
13
  validateImageFromDataUrl,
14
14
  type ImageValidationResult
15
15
  } from '../utils/image-validation';
16
+ import { debounce } from '../utils';
16
17
 
17
18
  type SearchResults = SimilarProductsListResponse;
18
19
 
19
20
  interface SearchParams {
20
- [key: string]: string;
21
+ [key: string]: string | string[];
21
22
  }
22
23
 
23
24
  export function useSimilarProducts(product: Product) {
24
25
  const { t } = useLocalization();
25
26
  const [currentImageUrl, setCurrentImageUrl] = useState('');
26
27
  const [fileError, setFileError] = useState('');
28
+ const [searchText, setSearchText] = useState('');
27
29
  const [searchResults, setSearchResults] = useState<SearchResults | null>(
28
30
  null
29
31
  );
@@ -33,6 +35,16 @@ export function useSimilarProducts(product: Product) {
33
35
  const [isCropProcessing, setIsCropProcessing] = useState(false);
34
36
  const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1]));
35
37
  const [allLoadedProducts, setAllLoadedProducts] = useState<Product[]>([]);
38
+ const [currentImageBase64, setCurrentImageBase64] = useState<string>('');
39
+ const [hasCroppedImage, setHasCroppedImage] = useState(false);
40
+
41
+ const searchTextRef = useRef<string>('');
42
+ const hasUploadedImageRef = useRef<boolean>(false);
43
+
44
+ const setSearchTextWithRef = useCallback((text: string) => {
45
+ setSearchText(text);
46
+ searchTextRef.current = text;
47
+ }, []);
36
48
 
37
49
  const [fetchSimilarProductsByUrl, { isLoading: isUrlSearchLoading }] =
38
50
  useLazyGetSimilarProductsByUrlQuery();
@@ -103,7 +115,7 @@ export function useSimilarProducts(product: Product) {
103
115
  String(choice.value)
104
116
  );
105
117
  const searchKey = String(facet.search_key || facet.key);
106
- searchParams[searchKey] = values.join(',');
118
+ searchParams[searchKey] = values.length === 1 ? values[0] : values;
107
119
  }
108
120
  });
109
121
  }
@@ -338,7 +350,9 @@ export function useSimilarProducts(product: Product) {
338
350
  const productIds = extractProductIds(data);
339
351
 
340
352
  if (productIds.length > 0) {
341
- const result = await executeProductSearch(productIds);
353
+ const searchParams = buildSearchParams(searchResults?.facets);
354
+
355
+ const result = await executeProductSearch(productIds, searchParams);
342
356
  if (result) {
343
357
  const resultWithCorrectSorterState = {
344
358
  ...result,
@@ -357,7 +371,11 @@ export function useSimilarProducts(product: Product) {
357
371
  executeProductSearch,
358
372
  updateSorterState,
359
373
  updateResultsAndKey,
360
- createEmptySearchResults
374
+ createEmptySearchResults,
375
+ searchText,
376
+ searchTextRef,
377
+ buildSearchParams,
378
+ searchResults
361
379
  ]
362
380
  );
363
381
 
@@ -426,17 +444,24 @@ export function useSimilarProducts(product: Product) {
426
444
  );
427
445
 
428
446
  const fetchSimilarProductsByImageUrl = useCallback(
429
- async (imageUrl: string) => {
447
+ async (imageUrl: string, overrideText?: string) => {
430
448
  setFileError('');
449
+
431
450
  try {
432
451
  const productPk = product?.pk;
433
452
  const excludedIds = productPk ? [productPk] : undefined;
434
453
 
435
- const result = await fetchSimilarProductsByUrl({
454
+ const textToUse =
455
+ overrideText !== undefined ? overrideText : searchTextRef.current;
456
+
457
+ const requestParams = {
436
458
  url: imageUrl,
437
459
  limit: 20,
438
- excluded_product_ids: excludedIds
439
- }).unwrap();
460
+ excluded_product_ids: excludedIds,
461
+ text: textToUse || undefined
462
+ };
463
+
464
+ const result = await fetchSimilarProductsByUrl(requestParams).unwrap();
440
465
 
441
466
  await handleSearchResults(result);
442
467
  return result;
@@ -450,6 +475,8 @@ export function useSimilarProducts(product: Product) {
450
475
  [
451
476
  fetchSimilarProductsByUrl,
452
477
  product?.pk,
478
+ searchText,
479
+ searchTextRef,
453
480
  handleSearchResults,
454
481
  updateResultsAndKey,
455
482
  createEmptySearchResults,
@@ -462,31 +489,62 @@ export function useSimilarProducts(product: Product) {
462
489
  if (product?.productimage_set?.length > 0) {
463
490
  const initialImageUrl = product.productimage_set[0].image;
464
491
  setCurrentImageUrl(initialImageUrl);
492
+ setHasCroppedImage(false);
493
+ setCurrentImageBase64('');
494
+ setHasUploadedImage(false);
495
+ hasUploadedImageRef.current = false;
465
496
  }
466
497
  }, [product]);
467
498
 
468
499
  const fetchSimilarProductsByBase64 = useCallback(
469
- async (base64Image: string) => {
500
+ async (
501
+ base64Image: string,
502
+ overrideText?: string,
503
+ forceExclude?: boolean
504
+ ) => {
470
505
  const base64Data = base64Image.startsWith('data:')
471
506
  ? base64Image.split(',')[1]
472
507
  : base64Image;
473
508
 
509
+ const textToUse =
510
+ overrideText !== undefined ? overrideText : searchTextRef.current;
511
+
512
+ const shouldExclude =
513
+ forceExclude !== undefined ? forceExclude : !hasUploadedImage;
514
+
515
+ const requestData = {
516
+ image: base64Data,
517
+ limit: 20,
518
+ excluded_product_ids: shouldExclude
519
+ ? product?.pk
520
+ ? [product.pk]
521
+ : undefined
522
+ : undefined,
523
+ text: textToUse || undefined
524
+ };
525
+
474
526
  return handleImageSearch(
475
- async () =>
476
- getSimilarProductsByImage({
477
- image: base64Data,
478
- limit: 20
479
- }).unwrap(),
527
+ async () => getSimilarProductsByImage(requestData).unwrap(),
480
528
  base64Image,
481
529
  'Image search'
482
530
  );
483
531
  },
484
- [getSimilarProductsByImage, handleImageSearch]
532
+ [
533
+ getSimilarProductsByImage,
534
+ searchText,
535
+ searchTextRef,
536
+ handleImageSearch,
537
+ hasUploadedImage,
538
+ product?.pk
539
+ ]
485
540
  );
486
541
 
487
542
  const fetchSimilarProductsByImageCrop = useCallback(
488
- async (dataString: string) => {
543
+ async (dataString: string, excludeCurrentProduct: boolean = true) => {
489
544
  setFileError('');
545
+
546
+ setCurrentImageBase64(dataString);
547
+ setHasCroppedImage(true);
490
548
  if (dataString.startsWith('data:application/x-cors-fallback;base64,')) {
491
549
  try {
492
550
  const fallbackDataEncoded = dataString.replace(
@@ -549,9 +607,12 @@ export function useSimilarProducts(product: Product) {
549
607
  getSimilarProductsByImage({
550
608
  image: base64Data,
551
609
  limit: 20,
552
- excluded_product_ids: product?.pk
553
- ? [product.pk]
554
- : undefined
610
+ excluded_product_ids: excludeCurrentProduct
611
+ ? product?.pk
612
+ ? [product.pk]
613
+ : undefined
614
+ : undefined,
615
+ text: searchTextRef.current || undefined
555
616
  }).unwrap(),
556
617
  croppedBase64,
557
618
  'Proxy crop search'
@@ -601,7 +662,12 @@ export function useSimilarProducts(product: Product) {
601
662
  getSimilarProductsByImage({
602
663
  image: base64Data,
603
664
  limit: 20,
604
- excluded_product_ids: product?.pk ? [product.pk] : undefined
665
+ excluded_product_ids: excludeCurrentProduct
666
+ ? product?.pk
667
+ ? [product.pk]
668
+ : undefined
669
+ : undefined,
670
+ text: searchTextRef.current || undefined
605
671
  }).unwrap(),
606
672
  dataString,
607
673
  'Image crop search'
@@ -609,11 +675,14 @@ export function useSimilarProducts(product: Product) {
609
675
  },
610
676
  [
611
677
  getSimilarProductsByImage,
678
+ searchText,
679
+ searchTextRef,
612
680
  handleImageSearch,
613
681
  product,
614
682
  fetchSimilarProductsByImageUrl,
615
683
  setFileError,
616
- t
684
+ t,
685
+ hasUploadedImage
617
686
  ]
618
687
  );
619
688
 
@@ -624,6 +693,8 @@ export function useSimilarProducts(product: Product) {
624
693
  const file = event.target.files?.[0];
625
694
  if (!file) return;
626
695
 
696
+ setSearchTextWithRef('');
697
+
627
698
  try {
628
699
  let processedFile = file;
629
700
 
@@ -650,6 +721,9 @@ export function useSimilarProducts(product: Product) {
650
721
  const dataUrl = e.target?.result as string;
651
722
  setCurrentImageUrl(dataUrl);
652
723
  setHasUploadedImage(true);
724
+ hasUploadedImageRef.current = true;
725
+ setHasCroppedImage(false);
726
+ setCurrentImageBase64('');
653
727
 
654
728
  const metadataValidation = await validateImageFromDataUrl(dataUrl);
655
729
  if (!metadataValidation.isValid) {
@@ -658,7 +732,7 @@ export function useSimilarProducts(product: Product) {
658
732
  );
659
733
  return;
660
734
  }
661
- fetchSimilarProductsByBase64(dataUrl);
735
+ fetchSimilarProductsByBase64(dataUrl, undefined, false);
662
736
  };
663
737
 
664
738
  reader.onerror = () =>
@@ -679,6 +753,8 @@ export function useSimilarProducts(product: Product) {
679
753
 
680
754
  try {
681
755
  setCurrentImageUrl(base64Image);
756
+ setCurrentImageBase64(base64Image);
757
+ setHasCroppedImage(true);
682
758
 
683
759
  const metadataValidation = await validateImageFromDataUrl(base64Image);
684
760
  if (!metadataValidation.isValid) {
@@ -688,10 +764,14 @@ export function useSimilarProducts(product: Product) {
688
764
  return;
689
765
  }
690
766
 
691
- if (hasUploadedImage) {
692
- await fetchSimilarProductsByBase64(base64Image);
767
+ if (hasUploadedImageRef.current) {
768
+ await fetchSimilarProductsByBase64(
769
+ base64Image,
770
+ searchTextRef.current,
771
+ false
772
+ );
693
773
  } else {
694
- await fetchSimilarProductsByImageCrop(base64Image);
774
+ await fetchSimilarProductsByImageCrop(base64Image, true);
695
775
  }
696
776
  } catch (error) {
697
777
  setFileError(t('common.similar_products.errors.crop_processing_error'));
@@ -874,6 +954,19 @@ export function useSimilarProducts(product: Product) {
874
954
  setFileError('');
875
955
  }, []);
876
956
 
957
+ const clearResults = useCallback(() => {
958
+ setSearchResults(null);
959
+ setResultsKey(0);
960
+ setLastProductIds([]);
961
+ setAllLoadedProducts([]);
962
+ setLoadedPages(new Set([1]));
963
+ }, []);
964
+
965
+ const resetCropState = useCallback(() => {
966
+ setHasCroppedImage(false);
967
+ setCurrentImageBase64('');
968
+ }, []);
969
+
877
970
  const clearFileInput = useCallback(
878
971
  (fileInputRef: React.RefObject<HTMLInputElement>) => {
879
972
  if (fileInputRef.current) {
@@ -883,6 +976,55 @@ export function useSimilarProducts(product: Product) {
883
976
  []
884
977
  );
885
978
 
979
+ const handleTextSearch = useCallback(async () => {
980
+ const textToUse = searchTextRef.current || searchText;
981
+
982
+ if (hasCroppedImage && currentImageBase64) {
983
+ await fetchSimilarProductsByBase64(
984
+ currentImageBase64,
985
+ textToUse,
986
+ !hasUploadedImage
987
+ );
988
+ } else if (hasUploadedImage && currentImageUrl) {
989
+ await fetchSimilarProductsByBase64(currentImageUrl, textToUse, false);
990
+ } else if (currentImageUrl) {
991
+ await fetchSimilarProductsByImageUrl(currentImageUrl, textToUse);
992
+ }
993
+ }, [
994
+ searchText,
995
+ searchTextRef,
996
+ currentImageUrl,
997
+ currentImageBase64,
998
+ hasCroppedImage,
999
+ hasUploadedImage,
1000
+ fetchSimilarProductsByImageUrl,
1001
+ fetchSimilarProductsByBase64
1002
+ ]);
1003
+
1004
+ const handleClearText = useCallback(async () => {
1005
+ setSearchTextWithRef('');
1006
+
1007
+ if (hasCroppedImage && currentImageBase64) {
1008
+ await fetchSimilarProductsByBase64(
1009
+ currentImageBase64,
1010
+ '',
1011
+ !hasUploadedImage
1012
+ );
1013
+ } else if (hasUploadedImage && currentImageUrl) {
1014
+ await fetchSimilarProductsByBase64(currentImageUrl, '', false);
1015
+ } else if (currentImageUrl) {
1016
+ await fetchSimilarProductsByImageUrl(currentImageUrl, '');
1017
+ }
1018
+ }, [
1019
+ hasCroppedImage,
1020
+ currentImageBase64,
1021
+ currentImageUrl,
1022
+ hasUploadedImage,
1023
+ fetchSimilarProductsByBase64,
1024
+ fetchSimilarProductsByImageUrl,
1025
+ setSearchTextWithRef
1026
+ ]);
1027
+
886
1028
  const handleLoadMore = async () => {
887
1029
  if (!searchResults?.pagination || isLoading) return;
888
1030
 
@@ -923,6 +1065,8 @@ export function useSimilarProducts(product: Product) {
923
1065
  setCurrentImageUrl,
924
1066
  isLoading,
925
1067
  fileError,
1068
+ searchText,
1069
+ setSearchText: setSearchTextWithRef,
926
1070
  searchResults,
927
1071
  resultsKey,
928
1072
  hasUploadedImage,
@@ -940,6 +1084,10 @@ export function useSimilarProducts(product: Product) {
940
1084
  loadedPages: Array.from(loadedPages),
941
1085
  allLoadedProducts,
942
1086
  clearError,
943
- clearFileInput
1087
+ clearResults,
1088
+ clearFileInput,
1089
+ handleTextSearch,
1090
+ handleClearText,
1091
+ resetCropState
944
1092
  };
945
1093
  }
@@ -0,0 +1,41 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ const TEXT_SEARCH_STORAGE_KEY = 'enable_image_search_text';
4
+
5
+ interface UseTextSearchFeatureProps {
6
+ enableTextSearch?: boolean;
7
+ }
8
+
9
+ export function useTextSearchFeature(props?: UseTextSearchFeatureProps) {
10
+ const [isEnabled, setIsEnabled] = useState(false);
11
+ const [isLoading, setIsLoading] = useState(true);
12
+
13
+ useEffect(() => {
14
+ const envEnabled = process.env.NEXT_PUBLIC_ENABLE_TEXT_SEARCH === 'true';
15
+
16
+ if (envEnabled) {
17
+ setIsEnabled(true);
18
+ setIsLoading(false);
19
+ return;
20
+ }
21
+
22
+ try {
23
+ const localStorageValue = localStorage.getItem(TEXT_SEARCH_STORAGE_KEY);
24
+
25
+ if (localStorageValue === 'true') {
26
+ setIsEnabled(true);
27
+ } else {
28
+ setIsEnabled(!!props?.enableTextSearch);
29
+ }
30
+ } catch (error) {
31
+ setIsEnabled(false);
32
+ }
33
+
34
+ setIsLoading(false);
35
+ }, [props?.enableTextSearch]);
36
+
37
+ return {
38
+ isEnabled,
39
+ isLoading
40
+ };
41
+ }
@@ -58,6 +58,10 @@ export interface SimilarProductsModalProps {
58
58
  showResetButton?: boolean;
59
59
  settings?: any;
60
60
  className?: string;
61
+ searchText?: string;
62
+ setSearchText?: (text: string) => void;
63
+ handleTextSearch?: () => void;
64
+ handleClearText?: () => void;
61
65
  }
62
66
 
63
67
  export interface FilterSidebarProps {
@@ -67,6 +71,8 @@ export interface FilterSidebarProps {
67
71
  isLoading: boolean;
68
72
  handleFacetChange: (facetKey: string, choiceValue: string | number) => void;
69
73
  removeFacetFilter: (facetKey: string, choiceValue: string | number) => void;
74
+ searchText?: string;
75
+ setSearchText?: (text: string) => void;
70
76
  currentImageUrl: string;
71
77
  isCropping: boolean;
72
78
  imageRef: React.RefObject<HTMLImageElement>;
@@ -126,6 +132,8 @@ export interface CustomRendererProps {
126
132
  onSortChange: (value: string) => void;
127
133
  onFilterMenuToggle: () => void;
128
134
  isLoading: boolean;
135
+ searchText?: string;
136
+ setSearchText?: (text: string) => void;
129
137
  }) => React.ReactNode;
130
138
  renderSortDropdown?: (props: {
131
139
  sorters: SortOption[];
@@ -141,6 +149,17 @@ export interface CustomRendererProps {
141
149
  onClick: () => void;
142
150
  isLoading: boolean;
143
151
  }) => React.ReactNode;
152
+ renderModalSearchInput?: (props: {
153
+ searchText: string;
154
+ setSearchText: (text: string) => void;
155
+ isLoading: boolean;
156
+ placeholder: string;
157
+ onSearch?: () => void;
158
+ }) => React.ReactNode;
159
+ renderSearchIcon?: (props: {
160
+ disabled: boolean;
161
+ onClick?: () => void;
162
+ }) => React.ReactNode;
144
163
  renderEmptyState?: () => React.ReactNode;
145
164
  };
146
165
  filterSidebar?: {
@@ -219,6 +238,12 @@ export interface CustomRendererProps {
219
238
  onClearAll: () => void;
220
239
  isLoading: boolean;
221
240
  }) => React.ReactNode;
241
+ renderTextSearch?: (props: {
242
+ searchText: string;
243
+ setSearchText: (text: string) => void;
244
+ isLoading: boolean;
245
+ placeholder: string;
246
+ }) => React.ReactNode;
222
247
  };
223
248
  resultsGrid?: {
224
249
  renderGrid?: (props: ResultsGridProps) => React.ReactNode;
@@ -309,6 +334,7 @@ export interface SimilarProductsSettings {
309
334
  resultsPerPage?: number;
310
335
  enableCropping?: boolean;
311
336
  enableFileUpload?: boolean;
337
+ enableTextSearch?: boolean;
312
338
  paginationType?: 'pagination' | 'load-more' | 'infinite-scroll';
313
339
  loadMoreText?: string;
314
340
  loadMoreStyle?: 'button' | 'auto';
@@ -329,10 +355,22 @@ export interface SimilarProductsSettings {
329
355
  controlsContainer?: string;
330
356
  controlsInner?: string;
331
357
  controlsLeft?: string;
358
+ controlsCenter?: string;
332
359
  controlsRight?: string;
333
360
  itemCount?: string;
334
361
  sortDropdown?: string;
335
362
  filterToggleButton?: string;
363
+ modalSearchInput?: string;
364
+ modalSearchContainer?: string;
365
+ modalSearchButton?: string;
366
+ modalSearchIcon?: string;
367
+ modalSearchClearButton?: string;
368
+ modalSearchClearIcon?: string;
369
+ searchButtonIcon?: string;
370
+ modalCloseIcon?: string;
371
+ filterIcon?: string;
372
+ filterRemoveIcon?: string;
373
+ imageSearchButtonIcon?: string;
336
374
 
337
375
  filterSidebarMobileHeader?: string;
338
376
  filterSidebarMobileTitle?: string;
@@ -357,6 +395,19 @@ export interface SimilarProductsSettings {
357
395
  errorMessage?: string;
358
396
  cropControls?: string;
359
397
 
398
+ // Detailed crop styling
399
+ cropComponent?: string;
400
+ cropImage?: string;
401
+ cropImageActive?: string;
402
+ cropOverlay?: string;
403
+ cropSelection?: string;
404
+ cropSelectionBorder?: string;
405
+ cropImageNonCropping?: string;
406
+ cropImageContainer?: string;
407
+ cropImageWrapper?: string;
408
+ cropOverlayBackground?: string;
409
+ cropSelectionHighlight?: string;
410
+
360
411
  resultsContainer?: string;
361
412
  gridContainer?: string;
362
413
  productItem?: string;
@@ -401,6 +452,19 @@ export interface SimilarProductsSettings {
401
452
  mobileActiveFilterTag?: string;
402
453
  mobileClearAllButton?: string;
403
454
  filterSidebarMobileOverlay?: string;
455
+
456
+ textSearchContainer?: string;
457
+ textSearchLabel?: string;
458
+ textSearchInput?: string;
459
+ };
460
+ iconNames?: {
461
+ searchButton?: string;
462
+ modalClose?: string;
463
+ filter?: string;
464
+ filterRemove?: string;
465
+ modalSearch?: string;
466
+ modalSearchClear?: string;
467
+ imageSearchButton?: string;
404
468
  };
405
469
  customRenderers?: {
406
470
  Modal?: React.ComponentType<SimilarProductsModalProps>;
@@ -7,6 +7,7 @@ export const defaultSettings: Required<SimilarProductsSettings> = {
7
7
  resultsPerPage: 20,
8
8
  enableCropping: true,
9
9
  enableFileUpload: true,
10
+ enableTextSearch: false,
10
11
  customStyles: {
11
12
  modal: '',
12
13
  filterSidebar: '',
@@ -15,7 +16,31 @@ export const defaultSettings: Required<SimilarProductsSettings> = {
15
16
  productItem: '',
16
17
  pagination: '',
17
18
  filterGroup: '',
18
- imageSection: ''
19
+ imageSection: '',
20
+ textSearchContainer: '',
21
+ textSearchLabel: '',
22
+ textSearchInput: '',
23
+ controlsCenter: '',
24
+ modalSearchInput: '',
25
+ modalSearchContainer: '',
26
+ modalSearchButton: '',
27
+ modalSearchIcon: '',
28
+ modalSearchClearButton: '',
29
+ modalSearchClearIcon: '',
30
+ searchButtonIcon: '',
31
+ modalCloseIcon: '',
32
+ filterIcon: '',
33
+ filterRemoveIcon: '',
34
+ imageSearchButtonIcon: ''
35
+ },
36
+ iconNames: {
37
+ searchButton: 'search',
38
+ modalClose: 'close',
39
+ filter: 'filter',
40
+ filterRemove: 'close',
41
+ modalSearch: 'search',
42
+ modalSearchClear: 'close',
43
+ imageSearchButton: 'search'
19
44
  },
20
45
  customRenderers: {
21
46
  render: {}
@@ -43,6 +68,10 @@ export function mergeSettings(
43
68
  ...defaultSettings.customStyles,
44
69
  ...userSettings.customStyles
45
70
  },
71
+ iconNames: {
72
+ ...defaultSettings.iconNames,
73
+ ...userSettings.iconNames
74
+ },
46
75
  customRenderers: {
47
76
  ...defaultSettings.customRenderers,
48
77
  ...userSettings.customRenderers,
@@ -100,13 +129,19 @@ export function validateImageFile(
100
129
  export function debounce<T extends (...args: any[]) => any>(
101
130
  func: T,
102
131
  wait: number
103
- ): (...args: Parameters<T>) => void {
132
+ ): ((...args: Parameters<T>) => void) & { cancel: () => void } {
104
133
  let timeout: NodeJS.Timeout;
105
134
 
106
- return (...args: Parameters<T>) => {
135
+ const debounced = (...args: Parameters<T>) => {
107
136
  clearTimeout(timeout);
108
137
  timeout = setTimeout(() => func(...args), wait);
109
138
  };
139
+
140
+ debounced.cancel = () => {
141
+ clearTimeout(timeout);
142
+ };
143
+
144
+ return debounced as ((...args: Parameters<T>) => void) & { cancel: () => void };
110
145
  }
111
146
 
112
147
  export function dataURLToBlob(dataURL: string): Blob {