| 1 |
import { create } from 'zustand'; |
| 2 |
// html2canvas-pro fork: drop-in replacement for html2canvas with modern-CSS |
| 3 |
// support (oklch, lab, lch, color() function). Stock html2canvas@1.4.1 fails |
| 4 |
// on WP themes using CSS Color Module Level 4 syntax. |
| 5 |
import html2canvas from 'html2canvas-pro'; |
| 6 |
|
| 7 |
// Annotation state lives outside Zustand — canvas refs must not trigger renders |
| 8 |
let annotationOverlay = null; |
| 9 |
const annotationStrokes = []; |
| 10 |
let annotationDrawing = false; |
| 11 |
|
| 12 |
const captureVisibleViewport = async () => { |
| 13 |
const editorIframe = document.querySelector('iframe[name="editor-canvas"]'); |
| 14 |
let target, opts; |
| 15 |
|
| 16 |
if (editorIframe?.contentDocument?.body && editorIframe?.contentWindow) { |
| 17 |
const iDoc = editorIframe.contentDocument; |
| 18 |
const scrollTop = iDoc.documentElement.scrollTop || 0; |
| 19 |
const scrollLeft = iDoc.documentElement.scrollLeft || 0; |
| 20 |
target = iDoc.documentElement; |
| 21 |
opts = { |
| 22 |
useCORS: true, allowTaint: true, scale: 1, logging: false, |
| 23 |
x: scrollLeft, y: scrollTop, |
| 24 |
width: editorIframe.clientWidth, height: editorIframe.clientHeight, |
| 25 |
windowWidth: editorIframe.clientWidth, windowHeight: editorIframe.clientHeight, |
| 26 |
}; |
| 27 |
} else { |
| 28 |
target = document.querySelector('#wpwrap') || document.body; |
| 29 |
opts = { |
| 30 |
useCORS: true, allowTaint: true, scale: 1, logging: false, |
| 31 |
x: window.scrollX, y: window.scrollY, |
| 32 |
width: window.innerWidth, height: window.innerHeight, |
| 33 |
windowWidth: window.innerWidth, windowHeight: window.innerHeight, |
| 34 |
}; |
| 35 |
} |
| 36 |
|
| 37 |
const canvas = await html2canvas(target, opts); |
| 38 |
let finalCanvas = canvas; |
| 39 |
const maxWidth = 1280; |
| 40 |
if (canvas.width > maxWidth) { |
| 41 |
finalCanvas = document.createElement('canvas'); |
| 42 |
const ratio = maxWidth / canvas.width; |
| 43 |
finalCanvas.width = maxWidth; |
| 44 |
finalCanvas.height = Math.round(canvas.height * ratio); |
| 45 |
finalCanvas.getContext('2d').drawImage(canvas, 0, 0, finalCanvas.width, finalCanvas.height); |
| 46 |
} |
| 47 |
return finalCanvas.toDataURL('image/jpeg', 0.75); |
| 48 |
}; |
| 49 |
|
| 50 |
const removeAnnotationOverlay = () => { |
| 51 |
const existing = |
| 52 |
annotationOverlay || |
| 53 |
document.getElementById('zip-ai-annotation-overlay') || |
| 54 |
document.querySelector('iframe[name="editor-canvas"]')?.contentDocument?.getElementById('zip-ai-annotation-overlay'); |
| 55 |
if (existing) existing.remove(); |
| 56 |
annotationOverlay = null; |
| 57 |
annotationStrokes.length = 0; |
| 58 |
}; |
| 59 |
|
| 60 |
export const useScreenshotStore = create((set, get) => ({ |
| 61 |
showScreenshotPanel: false, |
| 62 |
isCapturing: false, |
| 63 |
isAnnotating: false, |
| 64 |
capturedImage: null, |
| 65 |
|
| 66 |
openScreenshotPanel: () => set({ showScreenshotPanel: true }), |
| 67 |
|
| 68 |
closeScreenshotPanel() { |
| 69 |
set({ showScreenshotPanel: false, isCapturing: false, isAnnotating: false }); |
| 70 |
}, |
| 71 |
|
| 72 |
async captureScreenshot() { |
| 73 |
set({ isCapturing: true }); |
| 74 |
try { |
| 75 |
const dataUrl = await captureVisibleViewport(); |
| 76 |
set({ capturedImage: { dataUrl, isAnnotated: false }, showScreenshotPanel: false }); |
| 77 |
} catch (err) { |
| 78 |
} finally { |
| 79 |
set({ isCapturing: false }); |
| 80 |
} |
| 81 |
}, |
| 82 |
|
| 83 |
startAnnotation() { |
| 84 |
// Remove any existing overlay |
| 85 |
removeAnnotationOverlay(); |
| 86 |
annotationStrokes.length = 0; |
| 87 |
|
| 88 |
const startOverlay = () => { |
| 89 |
const editorIframe = document.querySelector('iframe[name="editor-canvas"]'); |
| 90 |
const overlay = document.createElement('canvas'); |
| 91 |
overlay.id = 'zip-ai-annotation-overlay'; |
| 92 |
|
| 93 |
const sidebarWidth = |
| 94 |
getComputedStyle(document.documentElement).getPropertyValue('--zip-ai-sidebar-width')?.trim() || '420px'; |
| 95 |
const sidebarOpen = document.body.classList.contains('zip-ai-assistant-open'); |
| 96 |
|
| 97 |
if (editorIframe) { |
| 98 |
const rect = editorIframe.getBoundingClientRect(); |
| 99 |
overlay.width = Math.round(rect.width); |
| 100 |
overlay.height = Math.round(rect.height); |
| 101 |
Object.assign(overlay.style, { |
| 102 |
position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', |
| 103 |
width: rect.width + 'px', height: rect.height + 'px', |
| 104 |
zIndex: '99997', cursor: 'crosshair', touchAction: 'none', pointerEvents: 'auto', |
| 105 |
}); |
| 106 |
} else { |
| 107 |
const rightOffset = sidebarOpen ? parseInt(sidebarWidth) : 0; |
| 108 |
overlay.width = window.innerWidth - rightOffset; |
| 109 |
overlay.height = window.innerHeight; |
| 110 |
Object.assign(overlay.style, { |
| 111 |
position: 'fixed', top: '0', left: '0', |
| 112 |
right: sidebarOpen ? sidebarWidth : '0px', |
| 113 |
height: '100vh', zIndex: '99997', cursor: 'crosshair', touchAction: 'none', |
| 114 |
}); |
| 115 |
} |
| 116 |
|
| 117 |
document.body.appendChild(overlay); |
| 118 |
annotationOverlay = overlay; |
| 119 |
|
| 120 |
const ctx = overlay.getContext('2d'); |
| 121 |
const STROKE_COLOR = '#ef4444'; |
| 122 |
const STROKE_WIDTH = 3; |
| 123 |
let currentPoints = []; |
| 124 |
|
| 125 |
const getPos = (e) => { |
| 126 |
const rect = overlay.getBoundingClientRect(); |
| 127 |
const clientX = e.touches ? e.touches[0].clientX : e.clientX; |
| 128 |
const clientY = e.touches ? e.touches[0].clientY : e.clientY; |
| 129 |
return { x: clientX - rect.left, y: clientY - rect.top }; |
| 130 |
}; |
| 131 |
|
| 132 |
const redraw = () => { |
| 133 |
ctx.clearRect(0, 0, overlay.width, overlay.height); |
| 134 |
const all = [...annotationStrokes]; |
| 135 |
if (currentPoints.length > 1) all.push(currentPoints); |
| 136 |
for (const stroke of all) { |
| 137 |
if (stroke.length < 2) continue; |
| 138 |
ctx.strokeStyle = STROKE_COLOR; |
| 139 |
ctx.lineWidth = STROKE_WIDTH; |
| 140 |
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; |
| 141 |
ctx.beginPath(); |
| 142 |
ctx.moveTo(stroke[0].x, stroke[0].y); |
| 143 |
for (let i = 1; i < stroke.length; i++) ctx.lineTo(stroke[i].x, stroke[i].y); |
| 144 |
ctx.stroke(); |
| 145 |
} |
| 146 |
}; |
| 147 |
|
| 148 |
overlay.addEventListener('pointerdown', (e) => { |
| 149 |
e.preventDefault(); |
| 150 |
annotationDrawing = true; |
| 151 |
currentPoints = [getPos(e)]; |
| 152 |
overlay.setPointerCapture(e.pointerId); |
| 153 |
}); |
| 154 |
overlay.addEventListener('pointermove', (e) => { |
| 155 |
if (!annotationDrawing) return; |
| 156 |
e.preventDefault(); |
| 157 |
currentPoints.push(getPos(e)); |
| 158 |
redraw(); |
| 159 |
}); |
| 160 |
overlay.addEventListener('pointerup', (e) => { |
| 161 |
if (!annotationDrawing) return; |
| 162 |
e.preventDefault(); |
| 163 |
annotationDrawing = false; |
| 164 |
if (currentPoints.length > 1) annotationStrokes.push([...currentPoints]); |
| 165 |
currentPoints = []; |
| 166 |
redraw(); |
| 167 |
}); |
| 168 |
|
| 169 |
set({ isAnnotating: true }); |
| 170 |
}; |
| 171 |
|
| 172 |
startOverlay(); |
| 173 |
}, |
| 174 |
|
| 175 |
annotationUndo() { |
| 176 |
if (annotationStrokes.length > 0) { |
| 177 |
annotationStrokes.pop(); |
| 178 |
const overlay = annotationOverlay; |
| 179 |
if (overlay) { |
| 180 |
const ctx = overlay.getContext('2d'); |
| 181 |
ctx.clearRect(0, 0, overlay.width, overlay.height); |
| 182 |
for (const stroke of annotationStrokes) { |
| 183 |
if (stroke.length < 2) continue; |
| 184 |
ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 3; |
| 185 |
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; |
| 186 |
ctx.beginPath(); |
| 187 |
ctx.moveTo(stroke[0].x, stroke[0].y); |
| 188 |
for (let i = 1; i < stroke.length; i++) ctx.lineTo(stroke[i].x, stroke[i].y); |
| 189 |
ctx.stroke(); |
| 190 |
} |
| 191 |
} |
| 192 |
} |
| 193 |
}, |
| 194 |
|
| 195 |
annotationClear() { |
| 196 |
annotationStrokes.length = 0; |
| 197 |
if (annotationOverlay) { |
| 198 |
annotationOverlay.getContext('2d').clearRect(0, 0, annotationOverlay.width, annotationOverlay.height); |
| 199 |
} |
| 200 |
}, |
| 201 |
|
| 202 |
annotationCancel() { |
| 203 |
removeAnnotationOverlay(); |
| 204 |
set({ isAnnotating: false, showScreenshotPanel: false }); |
| 205 |
}, |
| 206 |
|
| 207 |
async annotationDone() { |
| 208 |
set({ isCapturing: true }); |
| 209 |
try { |
| 210 |
const strokes = [...annotationStrokes]; |
| 211 |
const overlay = annotationOverlay; |
| 212 |
const overlayWidth = overlay?.width || window.innerWidth; |
| 213 |
const overlayHeight = overlay?.height || window.innerHeight; |
| 214 |
|
| 215 |
removeAnnotationOverlay(); |
| 216 |
|
| 217 |
const dataUrl = await captureVisibleViewport(); |
| 218 |
const img = new Image(); |
| 219 |
await new Promise((resolve, reject) => { |
| 220 |
img.onload = resolve; img.onerror = reject; img.src = dataUrl; |
| 221 |
}); |
| 222 |
|
| 223 |
const finalCanvas = document.createElement('canvas'); |
| 224 |
finalCanvas.width = img.width; |
| 225 |
finalCanvas.height = img.height; |
| 226 |
const ctx = finalCanvas.getContext('2d'); |
| 227 |
ctx.drawImage(img, 0, 0); |
| 228 |
|
| 229 |
if (strokes.length > 0) { |
| 230 |
const scaleX = finalCanvas.width / overlayWidth; |
| 231 |
const scaleY = finalCanvas.height / overlayHeight; |
| 232 |
for (const stroke of strokes) { |
| 233 |
if (stroke.length < 2) continue; |
| 234 |
ctx.strokeStyle = '#ef4444'; |
| 235 |
ctx.lineWidth = 3 * Math.max(scaleX, scaleY); |
| 236 |
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; |
| 237 |
ctx.beginPath(); |
| 238 |
ctx.moveTo(stroke[0].x * scaleX, stroke[0].y * scaleY); |
| 239 |
for (let i = 1; i < stroke.length; i++) { |
| 240 |
ctx.lineTo(stroke[i].x * scaleX, stroke[i].y * scaleY); |
| 241 |
} |
| 242 |
ctx.stroke(); |
| 243 |
} |
| 244 |
} |
| 245 |
|
| 246 |
const annotatedDataUrl = finalCanvas.toDataURL('image/jpeg', 0.75); |
| 247 |
set({ capturedImage: { dataUrl: annotatedDataUrl, isAnnotated: true }, isAnnotating: false, showScreenshotPanel: false }); |
| 248 |
} catch (err) { |
| 249 |
} finally { |
| 250 |
set({ isCapturing: false }); |
| 251 |
} |
| 252 |
}, |
| 253 |
|
| 254 |
setCapturedImageData(dataUrl, isAnnotated = false) { |
| 255 |
set({ capturedImage: { dataUrl, isAnnotated }, isAnnotating: false, showScreenshotPanel: false }); |
| 256 |
}, |
| 257 |
|
| 258 |
clearCapturedImage: () => set({ capturedImage: null }), |
| 259 |
|
| 260 |
/** Full reset — clears captured image, annotation overlay, and all state. */ |
| 261 |
resetAll() { |
| 262 |
removeAnnotationOverlay(); |
| 263 |
set({ showScreenshotPanel: false, isCapturing: false, isAnnotating: false, capturedImage: null }); |
| 264 |
}, |
| 265 |
})); |
| 266 |
|