hocmai-feedback-widget 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>Mobile Screenshot Test</title>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "react": "https://esm.sh/react@18.2.0",
11
+ "react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
12
+ "react-dom": "https://esm.sh/react-dom@18.2.0"
13
+ }
14
+ }
15
+ </script>
16
+ <style>
17
+ body {
18
+ margin: 0;
19
+ padding: 20px;
20
+ font-family: Arial, sans-serif;
21
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
22
+ min-height: 200vh;
23
+ color: white;
24
+ }
25
+
26
+ .info {
27
+ background: rgba(0,0,0,0.3);
28
+ padding: 15px;
29
+ border-radius: 8px;
30
+ margin-bottom: 20px;
31
+ font-size: 14px;
32
+ }
33
+
34
+ .viewport-info {
35
+ display: grid;
36
+ grid-template-columns: auto 1fr;
37
+ gap: 10px;
38
+ font-family: monospace;
39
+ font-size: 12px;
40
+ }
41
+
42
+ .content {
43
+ background: white;
44
+ color: #333;
45
+ padding: 20px;
46
+ border-radius: 12px;
47
+ margin: 20px 0;
48
+ }
49
+
50
+ h1 {
51
+ margin: 0 0 15px 0;
52
+ font-size: 24px;
53
+ }
54
+
55
+ p {
56
+ margin: 10px 0;
57
+ line-height: 1.6;
58
+ }
59
+
60
+ button {
61
+ display: block;
62
+ width: 100%;
63
+ padding: 15px;
64
+ margin: 10px 0;
65
+ font-size: 16px;
66
+ font-weight: bold;
67
+ background: #667eea;
68
+ color: white;
69
+ border: none;
70
+ border-radius: 8px;
71
+ cursor: pointer;
72
+ }
73
+
74
+ button:active {
75
+ background: #5568d3;
76
+ }
77
+
78
+ #screenshot {
79
+ max-width: 100%;
80
+ border: 2px solid #667eea;
81
+ border-radius: 8px;
82
+ margin-top: 10px;
83
+ }
84
+
85
+ .result {
86
+ background: rgba(255,255,255,0.1);
87
+ padding: 15px;
88
+ border-radius: 8px;
89
+ margin-top: 10px;
90
+ }
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <div class="info">
95
+ <h2 style="margin: 0 0 10px 0;">Mobile Viewport Screenshot Test</h2>
96
+ <p style="margin: 0 0 10px 0;">This tests the Visual Viewport API fix for mobile screenshots.</p>
97
+ <div id="viewportInfo" class="viewport-info"></div>
98
+ </div>
99
+
100
+ <div class="content">
101
+ <h1>Test Content</h1>
102
+ <p>This content should be captured correctly in the screenshot on mobile devices.</p>
103
+ <p>The screenshot should match exactly what you see in your viewport, accounting for:</p>
104
+ <ul>
105
+ <li>URL bar visibility</li>
106
+ <li>Keyboard (if shown)</li>
107
+ <li>Zoom level</li>
108
+ <li>Visual viewport dimensions</li>
109
+ </ul>
110
+
111
+ <button id="updateInfo">Update Viewport Info</button>
112
+ <button id="captureBtn">Capture Screenshot</button>
113
+
114
+ <div id="result"></div>
115
+ </div>
116
+
117
+ <script type="module">
118
+ // Force cache bust by loading with timestamp
119
+ const cacheBust = Date.now();
120
+ const { captureScreenshot } = await import(`../dist/support-feedback-lib.es.js?v=${cacheBust}`);
121
+
122
+ function updateViewportInfo() {
123
+ const info = {
124
+ 'window.innerWidth': window.innerWidth,
125
+ 'window.innerHeight': window.innerHeight,
126
+ 'visualViewport.width': window.visualViewport?.width || 'N/A',
127
+ 'visualViewport.height': window.visualViewport?.height || 'N/A',
128
+ 'documentElement.clientWidth': document.documentElement.clientWidth,
129
+ 'documentElement.clientHeight': document.documentElement.clientHeight,
130
+ 'devicePixelRatio': window.devicePixelRatio
131
+ };
132
+
133
+ const container = document.getElementById('viewportInfo');
134
+ container.innerHTML = '';
135
+
136
+ for (const [key, value] of Object.entries(info)) {
137
+ const keyDiv = document.createElement('div');
138
+ keyDiv.style.fontWeight = 'bold';
139
+ keyDiv.textContent = key + ':';
140
+
141
+ const valueDiv = document.createElement('div');
142
+ valueDiv.textContent = value;
143
+
144
+ container.appendChild(keyDiv);
145
+ container.appendChild(valueDiv);
146
+ }
147
+ }
148
+
149
+ // Update info on load and events
150
+ updateViewportInfo();
151
+ window.addEventListener('resize', updateViewportInfo);
152
+ window.addEventListener('scroll', updateViewportInfo);
153
+ if (window.visualViewport) {
154
+ window.visualViewport.addEventListener('resize', updateViewportInfo);
155
+ window.visualViewport.addEventListener('scroll', updateViewportInfo);
156
+ }
157
+
158
+ document.getElementById('updateInfo').addEventListener('click', updateViewportInfo);
159
+
160
+ document.getElementById('captureBtn').addEventListener('click', async () => {
161
+ const result = document.getElementById('result');
162
+ result.innerHTML = '<p>Capturing screenshot...</p>';
163
+
164
+ try {
165
+ console.log('=== CAPTURE START ===');
166
+ console.log('Visual Viewport:', window.visualViewport?.width, 'x', window.visualViewport?.height);
167
+ console.log('Window Inner:', window.innerWidth, 'x', window.innerHeight);
168
+ console.log('Client:', document.documentElement.clientWidth, 'x', document.documentElement.clientHeight);
169
+ console.log('Scroll Position:', window.scrollX, ',', window.scrollY);
170
+ console.log('DevicePixelRatio:', window.devicePixelRatio);
171
+
172
+ const dataUrl = await captureScreenshot();
173
+
174
+ if (dataUrl) {
175
+ console.log('=== CAPTURE SUCCESS ===');
176
+ console.log('Data URL length:', dataUrl.length);
177
+
178
+ // Create a test image to get actual dimensions
179
+ const testImg = new Image();
180
+ testImg.onload = () => {
181
+ console.log('Captured image dimensions:', testImg.width, 'x', testImg.height);
182
+ console.log('Expected viewport:', window.visualViewport?.width || document.documentElement.clientWidth, 'x',
183
+ window.visualViewport?.height || document.documentElement.clientHeight);
184
+ };
185
+ testImg.src = dataUrl;
186
+
187
+ result.innerHTML = `
188
+ <div class="result">
189
+ <p style="color: #4ade80; font-weight: bold; margin: 0 0 10px 0;">✓ Screenshot captured successfully!</p>
190
+ <p style="margin: 0 0 10px 0; font-size: 14px;">
191
+ <strong>VERIFICATION:</strong><br>
192
+ • Does the screenshot show the purple gradient background?<br>
193
+ • Does it include the white content box with text?<br>
194
+ • Does it match what you see in the viewport above?
195
+ </p>
196
+ <img id="screenshot" src="${dataUrl}" alt="Screenshot">
197
+ <p style="margin: 10px 0 0 0; font-size: 12px;">Data URL length: ${dataUrl.length} bytes<br>Check console for dimension details</p>
198
+ </div>
199
+ `;
200
+ } else {
201
+ console.log('=== CAPTURE FAILED ===');
202
+ result.innerHTML = '<p style="color: #f87171;">Failed to capture screenshot</p>';
203
+ }
204
+ } catch (error) {
205
+ console.error('=== CAPTURE ERROR ===', error);
206
+ result.innerHTML = `<p style="color: #f87171;">Error: ${error.message}</p>`;
207
+ }
208
+ });
209
+ </script>
210
+ </body>
211
+ </html>
@@ -0,0 +1,107 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Simple Capture Test</title>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "react": "https://esm.sh/react@18.2.0",
11
+ "react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
12
+ "react-dom": "https://esm.sh/react-dom@18.2.0"
13
+ }
14
+ }
15
+ </script>
16
+ <style>
17
+ body {
18
+ margin: 0;
19
+ padding: 0;
20
+ font-family: Arial, sans-serif;
21
+ }
22
+
23
+ .test-box {
24
+ width: 200px;
25
+ height: 100px;
26
+ background: red;
27
+ color: white;
28
+ padding: 20px;
29
+ margin: 20px;
30
+ font-size: 18px;
31
+ font-weight: bold;
32
+ }
33
+
34
+ button {
35
+ margin: 20px;
36
+ padding: 15px 30px;
37
+ font-size: 16px;
38
+ background: blue;
39
+ color: white;
40
+ border: none;
41
+ cursor: pointer;
42
+ }
43
+
44
+ #result {
45
+ margin: 20px;
46
+ }
47
+
48
+ #screenshot {
49
+ max-width: 100%;
50
+ border: 2px solid black;
51
+ margin-top: 10px;
52
+ }
53
+ </style>
54
+ </head>
55
+ <body>
56
+ <div class="test-box">RED BOX - Should be visible in screenshot</div>
57
+
58
+ <button id="captureBtn">Capture Screenshot</button>
59
+
60
+ <div id="result"></div>
61
+
62
+ <script type="module">
63
+ import { captureScreenshot } from '../dist/support-feedback-lib.es.js';
64
+
65
+ document.getElementById('captureBtn').addEventListener('click', async () => {
66
+ const result = document.getElementById('result');
67
+ result.innerHTML = '<p>Capturing...</p>';
68
+
69
+ console.log('=== STARTING SIMPLE CAPTURE TEST ===');
70
+ console.log('Viewport dimensions:', {
71
+ innerWidth: window.innerWidth,
72
+ innerHeight: window.innerHeight,
73
+ clientWidth: document.documentElement.clientWidth,
74
+ clientHeight: document.documentElement.clientHeight,
75
+ visualViewport: window.visualViewport ? {
76
+ width: window.visualViewport.width,
77
+ height: window.visualViewport.height
78
+ } : 'N/A'
79
+ });
80
+ console.log('Scroll position:', { x: window.scrollX, y: window.scrollY });
81
+ console.log('Number of elements in body:', document.body.getElementsByTagName('*').length);
82
+
83
+ try {
84
+ const dataUrl = await captureScreenshot();
85
+
86
+ if (dataUrl) {
87
+ console.log('=== CAPTURE SUCCESS ===');
88
+ console.log('Data URL length:', dataUrl.length);
89
+
90
+ result.innerHTML = `
91
+ <p style="color: green; font-weight: bold;">✓ Screenshot captured!</p>
92
+ <p>Data URL length: ${dataUrl.length} bytes</p>
93
+ <p><strong>Expected:</strong> You should see the RED BOX with white text in the screenshot below.</p>
94
+ <img id="screenshot" src="${dataUrl}" alt="Screenshot">
95
+ `;
96
+ } else {
97
+ console.log('=== CAPTURE RETURNED NULL ===');
98
+ result.innerHTML = '<p style="color: red;">Capture returned null</p>';
99
+ }
100
+ } catch (error) {
101
+ console.error('=== CAPTURE ERROR ===', error);
102
+ result.innerHTML = `<p style="color: red;">Error: ${error.message}</p>`;
103
+ }
104
+ });
105
+ </script>
106
+ </body>
107
+ </html>
@@ -1,2 +1,16 @@
1
1
  import { DeviceCapabilities } from './types';
2
+ /**
3
+ * Detect if browser is Safari (including iOS Safari)
4
+ */
5
+ export declare function isSafari(): boolean;
6
+ /**
7
+ * Detect if browser is iOS Safari specifically
8
+ * Must exclude Chrome/Chromium on iOS (Chrome DevTools emulation, Chrome iOS app)
9
+ */
10
+ export declare function isIOSSafari(): boolean;
11
+ /**
12
+ * Check if browser supports SVG foreignObject reliably
13
+ * Safari iOS has known issues with foreignObject causing canvas tainting
14
+ */
15
+ export declare function supportsForeignObject(): boolean;
2
16
  export declare function getDeviceCapabilities(): DeviceCapabilities;
@@ -1,5 +1,5 @@
1
1
  import { Rect, CaptureBounds } from './types';
2
- export declare function calculateCaptureBounds(cropRect: Rect | undefined, buffer: number): CaptureBounds;
2
+ export declare function calculateCaptureBounds(cropRect: Rect | undefined, buffer: number, viewportWidth?: number, viewportHeight?: number): CaptureBounds;
3
3
  export declare function isElementInViewport(element: HTMLElement, bounds: CaptureBounds): boolean;
4
4
  export declare function collectVisibleElements(bounds: CaptureBounds): Element[];
5
5
  export declare function estimateDOMComplexity(element: HTMLElement): {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hocmai-feedback-widget",
3
3
  "private": false,
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "main": "./dist/support-feedback-lib.umd.js",
7
7
  "module": "./dist/support-feedback-lib.es.js",
@@ -53,4 +53,4 @@
53
53
  "vite": "^7.2.4",
54
54
  "vite-plugin-dts": "^4.5.4"
55
55
  }
56
- }
56
+ }
@@ -844,7 +844,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
844
844
  ) : 'Gửi báo lỗi'}
845
845
  </button>
846
846
  </div>
847
- {/* <p style={{ textAlign: 'center', marginTop: 'auto', color: '#9CA3AF' }}>v0.2.0-rc1</p> */}
847
+ {/* <p style={{ textAlign: 'center', marginTop: 'auto', color: '#9CA3AF' }}>v0.2.0-rc4</p> */}
848
848
  </div>
849
849
  )}
850
850
  </>
package/src/index.ts CHANGED
@@ -1,3 +1,7 @@
1
1
  export { default as FeedbackWidget } from './components/FeedbackWidget';
2
2
  export type { FeedbackData, FeedbackProps } from './components/FeedbackWidget';
3
3
  export { FeedbackProvider, useFeedback } from './context/FeedbackContext';
4
+
5
+ // Export screenshot utilities
6
+ export { captureScreenshot } from './utils/screenshot';
7
+ export type { Rect, CaptureProgress } from './utils/screenshot/types';
@@ -48,10 +48,26 @@ export async function captureWithCanvas(
48
48
  }
49
49
 
50
50
  // 3. Calculate capture dimensions
51
+ // Use Visual Viewport API on mobile for accurate dimensions
52
+ // Visual viewport accounts for browser UI (URL bar, keyboard) and zoom level
53
+ const getViewportWidth = () => {
54
+ if (window.visualViewport) {
55
+ return window.visualViewport.width;
56
+ }
57
+ return document.documentElement.clientWidth || window.innerWidth;
58
+ };
59
+
60
+ const getViewportHeight = () => {
61
+ if (window.visualViewport) {
62
+ return window.visualViewport.height;
63
+ }
64
+ return document.documentElement.clientHeight || window.innerHeight;
65
+ };
66
+
51
67
  const targetX = cropRect ? cropRect.x : 0;
52
68
  const targetY = cropRect ? cropRect.y : 0;
53
- const targetWidth = cropRect ? cropRect.width : window.innerWidth;
54
- const targetHeight = cropRect ? cropRect.height : window.innerHeight;
69
+ const targetWidth = cropRect ? cropRect.width : getViewportWidth();
70
+ const targetHeight = cropRect ? cropRect.height : getViewportHeight();
55
71
 
56
72
  // 4. Check canvas size limits
57
73
  let finalScale = scale;
@@ -96,9 +112,12 @@ export async function captureWithCanvas(
96
112
  });
97
113
 
98
114
  // 6. Calculate capture bounds and collect elements
115
+ // Pass viewport dimensions to ensure consistent bounds calculation
99
116
  const bounds: CaptureBounds = calculateCaptureBounds(
100
117
  cropRect,
101
- capabilities.maxCanvasSize > 8192 ? 500 : 300 // Buffer based on device
118
+ capabilities.maxCanvasSize > 8192 ? 500 : 300, // Buffer based on device
119
+ targetWidth,
120
+ targetHeight
102
121
  );
103
122
 
104
123
  const visibleElements = collectVisibleElements(bounds);
@@ -121,11 +140,26 @@ export async function captureWithCanvas(
121
140
  // When cropRect is provided, translate canvas to show only the cropped area
122
141
  ctx.translate(-targetX, -targetY);
123
142
 
143
+ // Helper function to test if canvas is tainted
144
+ const isCanvasTainted = (): boolean => {
145
+ try {
146
+ ctx.getImageData(0, 0, 1, 1);
147
+ return false;
148
+ } catch {
149
+ return true;
150
+ }
151
+ };
152
+
124
153
  // 9. Layer 1: Render backgrounds
125
154
  await renderBackgrounds(visibleElements, ctx);
126
-
127
155
  await yieldToMainThread();
128
156
 
157
+ // Test canvas after backgrounds
158
+ if (isCanvasTainted()) {
159
+ console.warn('[Canvas] Canvas tainted after background rendering');
160
+ return null;
161
+ }
162
+
129
163
  onProgress?.({
130
164
  phase: 'rendering',
131
165
  progress: 50,
@@ -141,9 +175,32 @@ export async function captureWithCanvas(
141
175
  message: `Rendering images (${current}/${total})...`
142
176
  });
143
177
  });
144
-
145
178
  await yieldToMainThread();
146
179
 
180
+ // Test canvas after images - if tainted, continue without SVG/text layers
181
+ if (isCanvasTainted()) {
182
+ console.warn('[Canvas] Canvas tainted after image rendering, skipping SVG and text layers');
183
+
184
+ onProgress?.({
185
+ phase: 'exporting',
186
+ progress: 95,
187
+ message: 'Exporting canvas (images tainted, skipping remaining layers)...'
188
+ });
189
+
190
+ try {
191
+ const dataUrl = canvas.toDataURL('image/png', finalQuality);
192
+ if (dataUrl.length < 5000) {
193
+ console.warn('[Canvas] Canvas appears blank');
194
+ return null;
195
+ }
196
+ console.log('[Canvas] Capture successful (backgrounds + images only, skipped SVG/text due to taint)');
197
+ return dataUrl;
198
+ } catch (error) {
199
+ console.error('[Canvas] Export failed after image layer:', error);
200
+ return null;
201
+ }
202
+ }
203
+
147
204
  onProgress?.({
148
205
  phase: 'rendering',
149
206
  progress: 80,
@@ -159,16 +216,39 @@ export async function captureWithCanvas(
159
216
  message: `Rendering SVG (${current}/${total})...`
160
217
  });
161
218
  });
162
-
163
219
  await yieldToMainThread();
164
220
 
221
+ // Test canvas after SVGs - if tainted, skip text layer
222
+ if (isCanvasTainted()) {
223
+ console.warn('[Canvas] Canvas tainted after SVG rendering, skipping text layer');
224
+
225
+ onProgress?.({
226
+ phase: 'exporting',
227
+ progress: 95,
228
+ message: 'Exporting canvas (SVG tainted, skipping text)...'
229
+ });
230
+
231
+ try {
232
+ const dataUrl = canvas.toDataURL('image/png', finalQuality);
233
+ if (dataUrl.length < 5000) {
234
+ console.warn('[Canvas] Canvas appears blank');
235
+ return null;
236
+ }
237
+ console.log('[Canvas] Capture successful (backgrounds + images + SVG, skipped text due to taint)');
238
+ return dataUrl;
239
+ } catch (error) {
240
+ console.error('[Canvas] Export failed after SVG layer:', error);
241
+ return null;
242
+ }
243
+ }
244
+
245
+ // 12. Layer 4: Render text content
165
246
  onProgress?.({
166
247
  phase: 'rendering',
167
248
  progress: 90,
168
249
  message: 'Rendering text content...'
169
250
  });
170
251
 
171
- // 12. Layer 4: Render text content
172
252
  await renderTextContent(visibleElements, ctx, (current, total) => {
173
253
  const progress = 90 + Math.round((current / total) * 5);
174
254
  onProgress?.({
@@ -177,7 +257,6 @@ export async function captureWithCanvas(
177
257
  message: `Rendering text (${current}/${total})...`
178
258
  });
179
259
  });
180
-
181
260
  await yieldToMainThread();
182
261
 
183
262
  onProgress?.({
@@ -186,17 +265,64 @@ export async function captureWithCanvas(
186
265
  message: 'Exporting canvas to image...'
187
266
  });
188
267
 
189
- // 13. Export canvas to data URL
190
- const dataUrl = canvas.toDataURL('image/png', finalQuality);
268
+ // 13. Final export with taint detection
269
+ try {
270
+ // Test if canvas is tainted
271
+ ctx.getImageData(0, 0, 1, 1);
191
272
 
192
- onProgress?.({
193
- phase: 'exporting',
194
- progress: 100,
195
- message: 'Capture complete'
196
- });
273
+ // Canvas is clean - export
274
+ const dataUrl = canvas.toDataURL('image/png', finalQuality);
197
275
 
198
- console.log('[Canvas] Capture successful');
199
- return dataUrl;
276
+ // Validate that canvas is not blank
277
+ const minExpectedSize = 5000;
278
+ if (dataUrl.length < minExpectedSize) {
279
+ console.warn('[Canvas] Canvas export appears to be blank or too small');
280
+ return null;
281
+ }
282
+
283
+ onProgress?.({
284
+ phase: 'exporting',
285
+ progress: 100,
286
+ message: 'Capture complete'
287
+ });
288
+
289
+ console.log('[Canvas] Capture successful (all layers)');
290
+ return dataUrl;
291
+
292
+ } catch (taintError) {
293
+ // Canvas tainted by text layer - retry without it
294
+ console.warn('[Canvas] Canvas tainted after text rendering, retrying without text layer');
295
+
296
+ // Clear and recreate canvas
297
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
298
+ ctx.fillStyle = '#ffffff';
299
+ ctx.fillRect(0, 0, targetWidth, targetHeight);
300
+ ctx.translate(-targetX, -targetY);
301
+
302
+ // Re-render without text
303
+ await renderBackgrounds(visibleElements, ctx);
304
+ await yieldToMainThread();
305
+ await renderImages(images, ctx, () => { });
306
+ await yieldToMainThread();
307
+ await renderSVGs(svgs, ctx, () => { });
308
+ await yieldToMainThread();
309
+
310
+ // Try export again
311
+ try {
312
+ const dataUrl = canvas.toDataURL('image/png', finalQuality);
313
+
314
+ if (dataUrl.length < 5000) {
315
+ console.warn('[Canvas] Canvas still appears blank after retry');
316
+ return null;
317
+ }
318
+
319
+ console.log('[Canvas] Capture successful (without text layer)');
320
+ return dataUrl;
321
+ } catch (retryError) {
322
+ console.error('[Canvas] Export failed even without text layer:', retryError);
323
+ return null;
324
+ }
325
+ }
200
326
 
201
327
  } catch (error) {
202
328
  console.error('[Canvas] Capture failed:', error);
@@ -1,5 +1,57 @@
1
1
  import type { DeviceCapabilities } from './types';
2
2
 
3
+ /**
4
+ * Detect if browser is Safari (including iOS Safari)
5
+ */
6
+ export function isSafari(): boolean {
7
+ const userAgent = navigator.userAgent.toLowerCase();
8
+ return /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
9
+ /iphone|ipad|ipod/.test(userAgent);
10
+ }
11
+
12
+ /**
13
+ * Detect if browser is iOS Safari specifically
14
+ * Must exclude Chrome/Chromium on iOS (Chrome DevTools emulation, Chrome iOS app)
15
+ */
16
+ export function isIOSSafari(): boolean {
17
+ const userAgent = navigator.userAgent.toLowerCase();
18
+ console.log('[DeviceDetection] User Agent:', userAgent);
19
+
20
+ // Check for iOS device
21
+ const isIOS = /iphone|ipad|ipod/.test(userAgent);
22
+ console.log('[DeviceDetection] isIOS:', isIOS);
23
+
24
+ // Check it's Safari (has 'safari' but NOT 'chrome' or 'crios' or 'crmo')
25
+ const isSafariBrowser = /safari/.test(userAgent) &&
26
+ !/chrome|crios|crmo/.test(userAgent);
27
+ console.log('[DeviceDetection] isSafariBrowser:', isSafariBrowser);
28
+ console.log('[DeviceDetection] Final isIOSSafari:', isIOS && isSafariBrowser);
29
+
30
+ return isIOS && isSafariBrowser;
31
+ }
32
+
33
+ /**
34
+ * Check if browser supports SVG foreignObject reliably
35
+ * Safari iOS has known issues with foreignObject causing canvas tainting
36
+ */
37
+ export function supportsForeignObject(): boolean {
38
+ // Safari iOS < 13 has poor foreignObject support
39
+ // Even newer versions can cause canvas tainting issues
40
+ if (isIOSSafari()) {
41
+ console.warn('[DeviceDetection] Safari iOS detected - foreignObject may cause issues');
42
+ return false;
43
+ }
44
+
45
+ // Desktop Safari also has some limitations
46
+ if (isSafari() && !isIOSSafari()) {
47
+ console.warn('[DeviceDetection] Desktop Safari detected - foreignObject may have limitations');
48
+ // Desktop Safari works better, so we allow it
49
+ return true;
50
+ }
51
+
52
+ return true;
53
+ }
54
+
3
55
  export function getDeviceCapabilities(): DeviceCapabilities {
4
56
  const userAgent = navigator.userAgent.toLowerCase();
5
57
  const isMobile = /iphone|ipod|android.*mobile/.test(userAgent);