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.
- package/dist/index.d.ts +2 -0
- package/dist/support-feedback-lib.es.js +1474 -1395
- package/dist/support-feedback-lib.umd.js +10 -10
- package/dist/test-mobile-screenshot.html +211 -0
- package/dist/test-simple-capture.html +107 -0
- package/dist/utils/screenshot/device-detection.d.ts +14 -0
- package/dist/utils/screenshot/element-collector.d.ts +1 -1
- package/package.json +2 -2
- package/src/components/FeedbackWidget.tsx +1 -1
- package/src/index.ts +4 -0
- package/src/utils/screenshot/canvas-renderer.ts +143 -17
- package/src/utils/screenshot/device-detection.ts +52 -0
- package/src/utils/screenshot/element-collector.ts +60 -9
- package/src/utils/screenshot/svg-renderer.ts +23 -0
- package/src/utils/screenshot.ts +17 -2
|
@@ -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.
|
|
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-
|
|
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 :
|
|
54
|
-
const targetHeight = cropRect ? cropRect.height :
|
|
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.
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
progress: 100,
|
|
195
|
-
message: 'Capture complete'
|
|
196
|
-
});
|
|
273
|
+
// Canvas is clean - export
|
|
274
|
+
const dataUrl = canvas.toDataURL('image/png', finalQuality);
|
|
197
275
|
|
|
198
|
-
|
|
199
|
-
|
|
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);
|