hocmai-feedback-widget 0.2.0 → 0.2.2

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.
Files changed (57) hide show
  1. package/README.md +132 -97
  2. package/dist/capture/device-detection.d.ts +13 -0
  3. package/dist/capture/fonts.d.ts +4 -0
  4. package/dist/capture/index.d.ts +14 -0
  5. package/dist/capture/mathjax-fonts.d.ts +5 -0
  6. package/dist/capture/types.d.ts +25 -0
  7. package/dist/ckeditor.mjs +113 -0
  8. package/dist/ckeditor.mjs.map +1 -0
  9. package/dist/components/CkEditor.d.ts +14 -0
  10. package/dist/components/FeedbackWidget.d.ts +11 -0
  11. package/dist/context/FeedbackContext.d.ts +5 -0
  12. package/dist/hocmai-feedback-widget.mjs +7544 -0
  13. package/dist/hocmai-feedback-widget.mjs.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/package.json +32 -28
  16. package/src/App.tsx +143 -58
  17. package/src/capture/device-detection.ts +52 -0
  18. package/src/capture/fonts.ts +34 -0
  19. package/src/capture/index.ts +362 -0
  20. package/src/capture/mathjax-fonts.ts +143 -0
  21. package/src/capture/types.ts +34 -0
  22. package/src/ckeditor.d.ts +14 -0
  23. package/src/components/CkEditor.tsx +130 -0
  24. package/src/components/CropOverlay.tsx +29 -47
  25. package/src/components/FeedbackWidget.tsx +96 -90
  26. package/src/context/FeedbackContext.tsx +11 -4
  27. package/src/index.ts +9 -0
  28. package/src/main.tsx +7 -8
  29. package/src/utils/device.ts +14 -14
  30. package/dist/support-feedback-lib.es.js +0 -3867
  31. package/dist/support-feedback-lib.umd.js +0 -84
  32. package/dist/utils/screenshot/background-renderer.d.ts +0 -3
  33. package/dist/utils/screenshot/canvas-renderer.d.ts +0 -9
  34. package/dist/utils/screenshot/cleanup.d.ts +0 -3
  35. package/dist/utils/screenshot/device-detection.d.ts +0 -2
  36. package/dist/utils/screenshot/element-collector.d.ts +0 -10
  37. package/dist/utils/screenshot/image-renderer.d.ts +0 -21
  38. package/dist/utils/screenshot/performance.d.ts +0 -3
  39. package/dist/utils/screenshot/svg-renderer.d.ts +0 -24
  40. package/dist/utils/screenshot/text-renderer.d.ts +0 -23
  41. package/dist/utils/screenshot/types.d.ts +0 -35
  42. package/dist/utils/screenshot.d.ts +0 -9
  43. package/dist/vite.svg +0 -1
  44. package/src/App.css +0 -42
  45. package/src/assets/react.svg +0 -1
  46. package/src/index.css +0 -68
  47. package/src/utils/screenshot/background-renderer.ts +0 -54
  48. package/src/utils/screenshot/canvas-renderer.ts +0 -217
  49. package/src/utils/screenshot/cleanup.ts +0 -32
  50. package/src/utils/screenshot/device-detection.ts +0 -38
  51. package/src/utils/screenshot/element-collector.ts +0 -83
  52. package/src/utils/screenshot/image-renderer.ts +0 -148
  53. package/src/utils/screenshot/performance.ts +0 -57
  54. package/src/utils/screenshot/svg-renderer.ts +0 -126
  55. package/src/utils/screenshot/text-renderer.ts +0 -212
  56. package/src/utils/screenshot/types.ts +0 -40
  57. package/src/utils/screenshot.ts +0 -68
@@ -1,54 +0,0 @@
1
- export function getComputedBackgroundColor(element: HTMLElement): string | null {
2
- const styles = window.getComputedStyle(element);
3
- const bgColor = styles.backgroundColor;
4
-
5
- // Check if transparent
6
- if (bgColor === 'transparent' || bgColor === 'rgba(0, 0, 0, 0)') {
7
- return null;
8
- }
9
-
10
- return bgColor;
11
- }
12
-
13
- export async function renderBackground(
14
- element: HTMLElement,
15
- ctx: CanvasRenderingContext2D
16
- ): Promise<void> {
17
- const rect = element.getBoundingClientRect();
18
- const styles = window.getComputedStyle(element);
19
-
20
- // Use viewport coordinates directly from getBoundingClientRect
21
- const x = rect.left;
22
- const y = rect.top;
23
- const width = rect.width;
24
- const height = rect.height;
25
-
26
- // Render background color
27
- const bgColor = styles.backgroundColor;
28
- if (bgColor && bgColor !== 'transparent' && bgColor !== 'rgba(0, 0, 0, 0)') {
29
- ctx.fillStyle = bgColor;
30
- ctx.fillRect(x, y, width, height);
31
- }
32
-
33
- // Render border
34
- const borderWidth = parseFloat(styles.borderWidth) || 0;
35
- if (borderWidth > 0) {
36
- const borderColor = styles.borderColor;
37
- if (borderColor && borderColor !== 'transparent') {
38
- ctx.strokeStyle = borderColor;
39
- ctx.lineWidth = borderWidth;
40
- ctx.strokeRect(x, y, width, height);
41
- }
42
- }
43
- }
44
-
45
- export async function renderBackgrounds(
46
- elements: Element[],
47
- ctx: CanvasRenderingContext2D
48
- ): Promise<void> {
49
- for (const element of elements) {
50
- if (element instanceof HTMLElement) {
51
- await renderBackground(element, ctx);
52
- }
53
- }
54
- }
@@ -1,217 +0,0 @@
1
- import type { Rect, CaptureOptions, CaptureProgress, CaptureBounds } from './types';
2
- import { getDeviceCapabilities } from './device-detection';
3
- import { createCleanupTracker, cleanup } from './cleanup';
4
- import {
5
- calculateCaptureBounds,
6
- collectVisibleElements,
7
- estimateDOMComplexity
8
- } from './element-collector';
9
- import { yieldToMainThread } from './performance';
10
- import { renderBackgrounds } from './background-renderer';
11
- import { getImageElements, renderImages } from './image-renderer';
12
- import { getSVGElements, renderSVGs, getMathJaxElements } from './svg-renderer';
13
- import { renderTextContent } from './text-renderer';
14
-
15
- /**
16
- * Main canvas-based capture with layered rendering
17
- */
18
- export async function captureWithCanvas(
19
- cropRect: Rect | undefined,
20
- options: Partial<CaptureOptions>,
21
- onProgress?: (progress: CaptureProgress) => void
22
- ): Promise<string | null> {
23
- const cleanupTracker = createCleanupTracker();
24
-
25
- try {
26
- // 1. Get device capabilities and adjust options
27
- const capabilities = getDeviceCapabilities();
28
- const quality = options.quality ?? capabilities.recommendedQuality;
29
- const scale = options.scale ?? capabilities.recommendedScale;
30
- const includeMathJax = options.includeMathJax ?? true;
31
-
32
- onProgress?.({
33
- phase: 'analyzing',
34
- progress: 10,
35
- message: 'Analyzing DOM structure...'
36
- });
37
-
38
- // 2. Analyze DOM complexity
39
- const element = document.documentElement;
40
- const complexity = estimateDOMComplexity(element);
41
- console.log('[Canvas] DOM complexity:', complexity);
42
-
43
- // Adjust quality based on complexity
44
- let finalQuality = quality;
45
- if (complexity.isComplex) {
46
- finalQuality = Math.max(0.6, quality - 0.1);
47
- console.log('[Canvas] Complex DOM detected, reducing quality to', finalQuality);
48
- }
49
-
50
- // 3. Calculate capture dimensions
51
- const targetX = cropRect ? cropRect.x : 0;
52
- const targetY = cropRect ? cropRect.y : 0;
53
- const targetWidth = cropRect ? cropRect.width : window.innerWidth;
54
- const targetHeight = cropRect ? cropRect.height : window.innerHeight;
55
-
56
- // 4. Check canvas size limits
57
- let finalScale = scale;
58
- if (
59
- targetWidth * finalScale > capabilities.maxCanvasSize ||
60
- targetHeight * finalScale > capabilities.maxCanvasSize
61
- ) {
62
- console.warn('[Canvas] Size exceeds device limits, reducing scale');
63
- finalScale = Math.min(
64
- capabilities.maxCanvasSize / targetWidth,
65
- capabilities.maxCanvasSize / targetHeight,
66
- finalScale
67
- );
68
- }
69
-
70
- // 5. Create canvas
71
- const canvas = document.createElement('canvas');
72
- canvas.width = targetWidth * finalScale;
73
- canvas.height = targetHeight * finalScale;
74
- cleanupTracker.tempCanvases.push(canvas);
75
-
76
- const ctx = canvas.getContext('2d', {
77
- alpha: false,
78
- willReadFrequently: false
79
- });
80
-
81
- if (!ctx) {
82
- throw new Error('Failed to get canvas context');
83
- }
84
-
85
- // Apply scale
86
- ctx.scale(finalScale, finalScale);
87
-
88
- // Fill with white background
89
- ctx.fillStyle = '#ffffff';
90
- ctx.fillRect(0, 0, targetWidth, targetHeight);
91
-
92
- onProgress?.({
93
- phase: 'analyzing',
94
- progress: 20,
95
- message: 'Collecting visible elements...'
96
- });
97
-
98
- // 6. Calculate capture bounds and collect elements
99
- const bounds: CaptureBounds = calculateCaptureBounds(
100
- cropRect,
101
- capabilities.maxCanvasSize > 8192 ? 500 : 300 // Buffer based on device
102
- );
103
-
104
- const visibleElements = collectVisibleElements(bounds);
105
- console.log('[Canvas] Visible elements:', visibleElements.length);
106
-
107
- // 7. Separate elements by type
108
- const images = getImageElements(visibleElements);
109
- const svgs = getSVGElements(visibleElements);
110
- const mathJaxElements = includeMathJax ? getMathJaxElements(visibleElements) : [];
111
-
112
- console.log('[Canvas] Images:', images.length, 'SVGs:', svgs.length, 'MathJax:', mathJaxElements.length);
113
-
114
- onProgress?.({
115
- phase: 'rendering',
116
- progress: 30,
117
- message: 'Rendering backgrounds...'
118
- });
119
-
120
- // 8. Apply translation for crop offset
121
- // When cropRect is provided, translate canvas to show only the cropped area
122
- ctx.translate(-targetX, -targetY);
123
-
124
- // 9. Layer 1: Render backgrounds
125
- await renderBackgrounds(visibleElements, ctx);
126
-
127
- await yieldToMainThread();
128
-
129
- onProgress?.({
130
- phase: 'rendering',
131
- progress: 50,
132
- message: `Rendering ${images.length} images...`
133
- });
134
-
135
- // 10. Layer 2: Render images
136
- await renderImages(images, ctx, (current, total) => {
137
- const progress = 50 + Math.round((current / total) * 30);
138
- onProgress?.({
139
- phase: 'rendering',
140
- progress,
141
- message: `Rendering images (${current}/${total})...`
142
- });
143
- });
144
-
145
- await yieldToMainThread();
146
-
147
- onProgress?.({
148
- phase: 'rendering',
149
- progress: 80,
150
- message: `Rendering ${svgs.length} SVG elements...`
151
- });
152
-
153
- // 11. Layer 3: Render SVG elements (including MathJax)
154
- await renderSVGs(svgs, ctx, (current, total) => {
155
- const progress = 80 + Math.round((current / total) * 10);
156
- onProgress?.({
157
- phase: 'rendering',
158
- progress,
159
- message: `Rendering SVG (${current}/${total})...`
160
- });
161
- });
162
-
163
- await yieldToMainThread();
164
-
165
- onProgress?.({
166
- phase: 'rendering',
167
- progress: 90,
168
- message: 'Rendering text content...'
169
- });
170
-
171
- // 12. Layer 4: Render text content
172
- await renderTextContent(visibleElements, ctx, (current, total) => {
173
- const progress = 90 + Math.round((current / total) * 5);
174
- onProgress?.({
175
- phase: 'rendering',
176
- progress,
177
- message: `Rendering text (${current}/${total})...`
178
- });
179
- });
180
-
181
- await yieldToMainThread();
182
-
183
- onProgress?.({
184
- phase: 'exporting',
185
- progress: 95,
186
- message: 'Exporting canvas to image...'
187
- });
188
-
189
- // 13. Export canvas to data URL
190
- const dataUrl = canvas.toDataURL('image/png', finalQuality);
191
-
192
- onProgress?.({
193
- phase: 'exporting',
194
- progress: 100,
195
- message: 'Capture complete'
196
- });
197
-
198
- console.log('[Canvas] Capture successful');
199
- return dataUrl;
200
-
201
- } catch (error) {
202
- console.error('[Canvas] Capture failed:', error);
203
- return null;
204
- } finally {
205
- // 14. Cleanup resources
206
- cleanup(cleanupTracker);
207
- }
208
- }
209
-
210
- /**
211
- * Basic canvas capture without progress reporting
212
- */
213
- export async function basicCanvasCapture(
214
- cropRect?: Rect
215
- ): Promise<string | null> {
216
- return captureWithCanvas(cropRect, {}, undefined);
217
- }
@@ -1,32 +0,0 @@
1
- import type { CleanupTracker } from './types';
2
-
3
- export function createCleanupTracker(): CleanupTracker {
4
- return {
5
- objectUrls: [],
6
- tempCanvases: []
7
- };
8
- }
9
-
10
- export function cleanup(tracker: CleanupTracker): void {
11
- // Revoke all object URLs
12
- tracker.objectUrls.forEach(url => {
13
- try {
14
- URL.revokeObjectURL(url);
15
- } catch {
16
- console.warn('[Screenshot] Failed to revoke URL:', url);
17
- }
18
- });
19
-
20
- // Clear temp canvases
21
- tracker.tempCanvases.forEach(canvas => {
22
- const ctx = canvas.getContext('2d');
23
- if (ctx) {
24
- ctx.clearRect(0, 0, canvas.width, canvas.height);
25
- }
26
- canvas.width = 0;
27
- canvas.height = 0;
28
- });
29
-
30
- tracker.objectUrls.length = 0;
31
- tracker.tempCanvases.length = 0;
32
- }
@@ -1,38 +0,0 @@
1
- import type { DeviceCapabilities } from './types';
2
-
3
- export function getDeviceCapabilities(): DeviceCapabilities {
4
- const userAgent = navigator.userAgent.toLowerCase();
5
- const isMobile = /iphone|ipod|android.*mobile/.test(userAgent);
6
- const isTablet = /ipad|android(?!.*mobile)/.test(userAgent);
7
- const deviceMemory = (navigator as { deviceMemory?: number }).deviceMemory || 4;
8
- const isLowMemory = deviceMemory < 4;
9
-
10
- if (isMobile) {
11
- return {
12
- maxCanvasSize: 4096,
13
- recommendedScale: isLowMemory ? 0.8 : 1,
14
- recommendedQuality: isLowMemory ? 0.7 : 0.8,
15
- chunkSize: 30,
16
- yieldInterval: 16, // ~60fps
17
- maxTimeout: 5000
18
- };
19
- } else if (isTablet) {
20
- return {
21
- maxCanvasSize: 8192,
22
- recommendedScale: 1,
23
- recommendedQuality: 0.85,
24
- chunkSize: 50,
25
- yieldInterval: 16,
26
- maxTimeout: 4000
27
- };
28
- } else {
29
- return {
30
- maxCanvasSize: 16384,
31
- recommendedScale: 1,
32
- recommendedQuality: 0.9,
33
- chunkSize: 100,
34
- yieldInterval: 8,
35
- maxTimeout: 3000
36
- };
37
- }
38
- }
@@ -1,83 +0,0 @@
1
- import type { Rect, CaptureBounds } from './types';
2
-
3
- export function calculateCaptureBounds(
4
- cropRect: Rect | undefined,
5
- buffer: number
6
- ): CaptureBounds {
7
- const targetX = cropRect ? cropRect.x : 0;
8
- const targetY = cropRect ? cropRect.y : 0;
9
- const targetWidth = cropRect ? cropRect.width : window.innerWidth;
10
- const targetHeight = cropRect ? cropRect.height : window.innerHeight;
11
-
12
- return {
13
- left: targetX - buffer,
14
- top: targetY - buffer,
15
- right: targetX + targetWidth + buffer,
16
- bottom: targetY + targetHeight + buffer
17
- };
18
- }
19
-
20
- export function isElementInViewport(
21
- element: HTMLElement,
22
- bounds: CaptureBounds
23
- ): boolean {
24
- const rect = element.getBoundingClientRect();
25
-
26
- // Outside viewport
27
- if (
28
- rect.bottom < bounds.top ||
29
- rect.top > bounds.bottom ||
30
- rect.right < bounds.left ||
31
- rect.left > bounds.right
32
- ) {
33
- return false;
34
- }
35
-
36
- // Hidden elements
37
- if (rect.width === 0 || rect.height === 0) {
38
- return false;
39
- }
40
-
41
- return true;
42
- }
43
-
44
- export function collectVisibleElements(bounds: CaptureBounds): Element[] {
45
- const all = Array.from(document.body.getElementsByTagName('*'));
46
-
47
- return all.filter(element => {
48
- // Skip non-HTML elements
49
- if (!(element instanceof HTMLElement)) return false;
50
-
51
- // Skip excluded tags
52
- const tag = element.tagName;
53
- if (['SCRIPT', 'STYLE', 'LINK', 'IFRAME', 'NOSCRIPT'].includes(tag)) {
54
- return false;
55
- }
56
-
57
- // Check viewport visibility (but keep HTML and BODY)
58
- if (tag === 'HTML' || tag === 'BODY') {
59
- return true;
60
- }
61
-
62
- return isElementInViewport(element as HTMLElement, bounds);
63
- });
64
- }
65
-
66
- export function estimateDOMComplexity(element: HTMLElement): {
67
- nodeCount: number;
68
- imageCount: number;
69
- svgCount: number;
70
- isComplex: boolean;
71
- } {
72
- const allNodes = element.getElementsByTagName('*');
73
- const images = element.getElementsByTagName('img');
74
- const svgs = element.getElementsByTagName('svg');
75
- const nodeCount = allNodes.length;
76
- const imageCount = images.length;
77
- const svgCount = svgs.length;
78
-
79
- // Consider DOM complex if it has many nodes, images, or SVGs
80
- const isComplex = nodeCount > 1000 || imageCount > 50 || svgCount > 10;
81
-
82
- return { nodeCount, imageCount, svgCount, isComplex };
83
- }
@@ -1,148 +0,0 @@
1
- import { loadImageWithTimeout } from './performance';
2
-
3
- export interface ImageRenderResult {
4
- success: boolean;
5
- method?: 'canvas' | 'placeholder' | 'skip';
6
- error?: string;
7
- }
8
-
9
- /**
10
- * Strategy 1: Try canvas rendering with crossOrigin
11
- */
12
- async function tryCanvasRender(
13
- img: HTMLImageElement,
14
- ctx: CanvasRenderingContext2D,
15
- x: number,
16
- y: number,
17
- width: number,
18
- height: number
19
- ): Promise<ImageRenderResult> {
20
- try {
21
- // Create a test image with crossOrigin
22
- const testImg = new Image();
23
- testImg.crossOrigin = 'anonymous';
24
-
25
- await loadImageWithTimeout(testImg, img.src, 3000);
26
-
27
- // Draw to canvas
28
- ctx.drawImage(testImg, x, y, width, height);
29
-
30
- return { success: true, method: 'canvas' };
31
- } catch (error) {
32
- return {
33
- success: false,
34
- error: error instanceof Error ? error.message : 'Canvas render failed'
35
- };
36
- }
37
- }
38
-
39
- /**
40
- * Strategy 2: Use placeholder for CORS-blocked images
41
- */
42
- function renderPlaceholder(
43
- ctx: CanvasRenderingContext2D,
44
- x: number,
45
- y: number,
46
- width: number,
47
- height: number
48
- ): ImageRenderResult {
49
- // Draw gray background
50
- ctx.fillStyle = '#f0f0f0';
51
- ctx.fillRect(x, y, width, height);
52
-
53
- // Draw border
54
- ctx.strokeStyle = '#ccc';
55
- ctx.lineWidth = 1;
56
- ctx.strokeRect(x, y, width, height);
57
-
58
- // Draw "Image" text
59
- ctx.fillStyle = '#999';
60
- ctx.font = '12px sans-serif';
61
- ctx.textAlign = 'center';
62
- ctx.textBaseline = 'middle';
63
- ctx.fillText('Image', x + width / 2, y + height / 2);
64
-
65
- return { success: true, method: 'placeholder' };
66
- }
67
-
68
- /**
69
- * Strategy 3: Skip if very small or hidden
70
- */
71
- function shouldSkipImage(width: number, height: number): boolean {
72
- return width < 5 || height < 5;
73
- }
74
-
75
- /**
76
- * Main image rendering function with 3-tier strategy
77
- */
78
- export async function renderImage(
79
- img: HTMLImageElement,
80
- ctx: CanvasRenderingContext2D
81
- ): Promise<ImageRenderResult> {
82
- const rect = img.getBoundingClientRect();
83
-
84
- // Use viewport coordinates directly
85
- const x = rect.left;
86
- const y = rect.top;
87
- const width = rect.width;
88
- const height = rect.height;
89
-
90
- // Strategy 3: Skip very small images
91
- if (shouldSkipImage(width, height)) {
92
- return { success: true, method: 'skip' };
93
- }
94
-
95
- // Strategy 1: Try canvas rendering
96
- const canvasResult = await tryCanvasRender(img, ctx, x, y, width, height);
97
- if (canvasResult.success) {
98
- return canvasResult;
99
- }
100
-
101
- // Strategy 2: Use placeholder
102
- return renderPlaceholder(ctx, x, y, width, height);
103
- }
104
-
105
- /**
106
- * Render all images in the document
107
- */
108
- export async function renderImages(
109
- images: HTMLImageElement[],
110
- ctx: CanvasRenderingContext2D,
111
- onProgress?: (current: number, total: number) => void
112
- ): Promise<void> {
113
- for (let i = 0; i < images.length; i++) {
114
- await renderImage(images[i], ctx);
115
-
116
- if (onProgress) {
117
- onProgress(i + 1, images.length);
118
- }
119
- }
120
- }
121
-
122
- /**
123
- * Check if image is CORS-safe
124
- */
125
- export function isImageCORSSafe(img: HTMLImageElement): boolean {
126
- // Same origin images are always safe
127
- try {
128
- const imgUrl = new URL(img.src, window.location.href);
129
- return imgUrl.origin === window.location.origin;
130
- } catch {
131
- return false;
132
- }
133
- }
134
-
135
- /**
136
- * Get all renderable images from elements
137
- */
138
- export function getImageElements(elements: Element[]): HTMLImageElement[] {
139
- const images: HTMLImageElement[] = [];
140
-
141
- for (const element of elements) {
142
- if (element instanceof HTMLImageElement && element.src) {
143
- images.push(element);
144
- }
145
- }
146
-
147
- return images;
148
- }
@@ -1,57 +0,0 @@
1
- export function yieldToMainThread(): Promise<void> {
2
- return new Promise(resolve => {
3
- // Use Scheduler API if available (Chrome 94+)
4
- if ('scheduler' in window && 'yield' in (window as { scheduler?: { yield?: () => Promise<void> } }).scheduler!) {
5
- (window as { scheduler: { yield: () => Promise<void> } }).scheduler.yield().then(resolve);
6
- } else {
7
- // Fallback: setTimeout
8
- setTimeout(resolve, 0);
9
- }
10
- });
11
- }
12
-
13
- export async function processInChunks<T>(
14
- items: T[],
15
- chunkSize: number,
16
- processor: (chunk: T[]) => Promise<void>,
17
- onProgress?: (progress: number) => void
18
- ): Promise<void> {
19
- for (let i = 0; i < items.length; i += chunkSize) {
20
- const chunk = items.slice(i, i + chunkSize);
21
-
22
- // Process chunk
23
- await processor(chunk);
24
-
25
- // Report progress
26
- if (onProgress) {
27
- onProgress(Math.round(((i + chunk.length) / items.length) * 100));
28
- }
29
-
30
- // Yield to main thread
31
- await yieldToMainThread();
32
- }
33
- }
34
-
35
- export function loadImageWithTimeout(
36
- img: HTMLImageElement,
37
- url: string,
38
- timeout: number
39
- ): Promise<void> {
40
- return new Promise((resolve, reject) => {
41
- const timer = setTimeout(() => {
42
- reject(new Error('Image load timeout'));
43
- }, timeout);
44
-
45
- img.onload = () => {
46
- clearTimeout(timer);
47
- resolve();
48
- };
49
-
50
- img.onerror = () => {
51
- clearTimeout(timer);
52
- reject(new Error('Image load error'));
53
- };
54
-
55
- img.src = url;
56
- });
57
- }