Spaces:
Running
Running
| {# | |
| Revision Notes Modal - Preact Overhaul | |
| ColorRM Pro Engine Edition (Dark Workspace Theme) | |
| #} | |
| <div id="notes-modal-root"></div> | |
| <!-- External Dependencies --> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> | |
| <script src="https://cdn.jsdelivr.net/npm/@jaames/iro@5.5.2/dist/iro.min.js"></script> | |
| <style> | |
| /* ColorRM Pro Scoped Variables & Reset */ | |
| #notes-modal-root { | |
| --bg-body: #0f1115; | |
| --bg-panel: #181b21; | |
| --bg-surface: #22262e; | |
| --primary: #3b82f6; | |
| --accent: #8b5cf6; | |
| --text-main: #e2e8f0; | |
| --text-muted: #94a3b8; | |
| --border: #2d3748; | |
| --success: #10b981; | |
| --danger: #ef4444; | |
| } | |
| .crm-modal-overlay { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(0,0,0,0.85); | |
| z-index: 9999; | |
| display: flex; | |
| flex-direction: column; | |
| color: var(--text-main); | |
| font-family: 'Inter', system-ui, sans-serif; | |
| user-select: none; | |
| } | |
| /* UI Components */ | |
| #notes-modal-root .crm-btn { | |
| background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-main); | |
| padding: 8px 12px; border-radius: 6px; font-size: 0.85rem; font-weight: 500; | |
| cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: 0.1s; | |
| } | |
| #notes-modal-root .crm-btn:hover:not(:disabled) { background: var(--border); } | |
| #notes-modal-root .crm-btn:disabled { opacity: 0.5; cursor: not-allowed; } | |
| #notes-modal-root .crm-btn.active { background: var(--primary); border-color: var(--primary); color: white; } | |
| #notes-modal-root .crm-btn-primary { background: var(--primary); border-color: var(--primary); color: white; } | |
| #notes-modal-root .crm-btn-primary:hover:not(:disabled) { background: #2563eb; border-color: #2563eb; } | |
| #notes-modal-root .crm-btn-danger { background: rgba(239, 68, 68, 0.1); border-color: var(--danger); color: var(--danger); } | |
| #notes-modal-root .crm-btn-danger:hover:not(:disabled) { background: var(--danger); color: white; } | |
| #notes-modal-root .crm-slider { | |
| -webkit-appearance: none; width: 100%; height: 4px; background: #333; border-radius: 2px; | |
| } | |
| #notes-modal-root .crm-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; height: 16px; width: 16px; border-radius: 50%; background: var(--primary); cursor: pointer; | |
| } | |
| .crm-iro-wrapper { | |
| display: flex; justify-content: center; margin-top: 10px; background: rgba(0,0,0,0.2); padding: 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.05); | |
| } | |
| /* Layout */ | |
| .crm-header { | |
| height: 50px; background: var(--bg-panel); border-bottom: 1px solid var(--border); | |
| display: flex; align-items: center; justify-content: space-between; padding: 0 16px; flex-shrink: 0; | |
| } | |
| .crm-workspace { | |
| display: flex; flex: 1; overflow: hidden; position: relative; background: var(--bg-body); | |
| } | |
| .crm-sidebar { | |
| width: 300px; background: var(--bg-panel); display: flex; flex-direction: column; height: 100%; flex-shrink: 0; | |
| } | |
| .crm-sidebar.left { border-right: 1px solid var(--border); } | |
| .crm-sidebar.right { border-left: 1px solid var(--border); width: 320px; } | |
| .crm-sidebar-content { padding: 16px; overflow-y: auto; flex: 1; display: flex; flex-direction: column; gap: 20px; } | |
| .crm-control-section { background: rgba(255,255,255,0.03); padding: 12px; border-radius: 8px; border: 1px solid var(--border); } | |
| .crm-control-section h4 { font-size: 0.7rem; text-transform: uppercase; color: var(--text-muted); margin: 0 0 10px 0; letter-spacing: 1px; } | |
| .crm-tool-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; } | |
| .crm-tool-btn { flex-direction: column; gap: 4px; padding: 10px; height: 60px; font-size: 0.75rem; } | |
| .crm-tool-btn i { font-size: 1.2rem; } | |
| .crm-swatch-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-bottom: 14px; } | |
| .crm-swatch { | |
| height: 30px; border-radius: 6px; border: 2px solid rgba(255,255,255,0.18); cursor: pointer; | |
| box-shadow: inset 0 0 0 1px rgba(0,0,0,0.25); | |
| } | |
| .crm-swatch.active { border-color: #fff; box-shadow: 0 0 0 2px var(--primary); } | |
| .crm-size-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 12px; } | |
| .crm-size-btn { height: 34px; padding: 0; } | |
| .crm-size-dot { display: block; border-radius: 999px; background: currentColor; } | |
| .crm-input-row { | |
| display: flex; align-items: center; justify-content: space-between; gap: 10px; | |
| margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); | |
| font-size: 0.8rem; color: var(--text-muted); | |
| } | |
| .crm-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; } | |
| .crm-switch input { accent-color: var(--primary); } | |
| .crm-badge { | |
| display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px; | |
| border-radius: 999px; background: rgba(16, 185, 129, 0.12); color: var(--success); | |
| font-size: 0.72rem; font-weight: 600; | |
| } | |
| /* Canvas Viewport */ | |
| .crm-viewport { | |
| flex: 1; background: #050505; position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; touch-action: none; | |
| } | |
| .crm-canvas-container { | |
| width: 100%; height: 100%; | |
| background-image: linear-gradient(45deg, #1a1a1a 25%, transparent 25%), linear-gradient(-45deg, #1a1a1a 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #1a1a1a 75%), linear-gradient(-45deg, transparent 75%, #1a1a1a 75%); | |
| background-size: 20px 20px; background-color: #111; | |
| position: relative; display: flex; align-items: center; justify-content: center; padding: 20px; | |
| } | |
| .crm-canvas-container canvas { | |
| box-shadow: 0 20px 50px rgba(0,0,0,0.5); touch-action: none; display: block; max-width: 100%; max-height: 100%; background: white; | |
| } | |
| .crm-canvas-container.tool-pen canvas, .crm-canvas-container.tool-marker canvas { cursor: crosshair; } | |
| .crm-canvas-container.tool-eraser canvas { cursor: cell; } | |
| .crm-canvas-container.tool-move canvas { cursor: default; } | |
| /* References & History */ | |
| .crm-import-btn { | |
| flex: 1; padding: 12px 8px; font-size: 0.85rem; border-radius: 8px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; | |
| background: rgba(59, 130, 246, 0.1); border: 1px dashed var(--primary); color: var(--primary); font-weight: 600; cursor: pointer; transition: 0.2s; | |
| } | |
| .crm-import-btn i { font-size: 1.2rem; } | |
| .crm-import-btn:hover { background: var(--primary); color: white; border-style: solid; } | |
| .crm-image-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 10px; } | |
| .crm-image-item { position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden; border: 2px solid transparent; cursor: pointer; background: #000; } | |
| .crm-image-item.active { border-color: var(--primary); } | |
| .crm-image-item img { width: 100%; height: 100%; object-fit: cover; opacity: 0.8; transition: 0.2s; } | |
| .crm-image-item:hover img, .crm-image-item.active img { opacity: 1; } | |
| .crm-image-remove { | |
| position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 4px; background: rgba(0,0,0,0.7); color: var(--danger); border: none; font-size: 0.7rem; display: flex; align-items: center; justify-content: center; cursor: pointer; opacity: 0; | |
| } | |
| .crm-image-item:hover .crm-image-remove { opacity: 1; } | |
| .crm-history-item { | |
| padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 0.85rem; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; border: 1px solid transparent; | |
| } | |
| .crm-history-item:hover { background: rgba(255,255,255,0.05); } | |
| .crm-history-item.active { background: rgba(59, 130, 246, 0.1); color: var(--primary); border-color: var(--border); } | |
| /* Mobile Restoration */ | |
| @media (max-width: 900px) { | |
| .crm-workspace { flex-direction: column; overflow-y: auto; } | |
| .crm-sidebar { width: 100%; height: auto; border: none; border-bottom: 1px solid var(--border); flex-shrink: 0; } | |
| .crm-viewport { min-height: 60vh; } | |
| } | |
| </style> | |
| <script type="module"> | |
| import { h, render } from 'https://esm.sh/preact@10.19.3'; | |
| import { useState, useEffect, useRef, useCallback } from 'https://esm.sh/preact@10.19.3/hooks'; | |
| import htm from 'https://esm.sh/htm@3.1.1'; | |
| const html = htm.bind(h); | |
| const QUICK_COLORS = ['#ef4444', '#f59e0b', '#22c55e', '#06b6d4', '#3b82f6', '#111827']; | |
| const QUICK_SIZES = [2, 4, 8, 14]; | |
| // === Core Math Utilities === | |
| function distSqToSegment(p, v, w) { | |
| let l2 = (v.x - w.x)**2 + (v.y - w.y)**2; | |
| if (l2 === 0) return (p.x - v.x)**2 + (p.y - v.y)**2; | |
| let t = ((p.x - v.x)*(w.x - v.x) + (p.y - v.y)*(w.y - v.y)) / l2; | |
| t = Math.max(0, Math.min(1, t)); | |
| return (p.x - (v.x + t*(w.x - v.x)))**2 + (p.y - (v.y + t*(w.y - v.y)))**2; | |
| } | |
| // === UI Components === | |
| function ToolButton({ icon, label, active, onClick, title }) { | |
| return html` | |
| <button class="crm-btn crm-tool-btn ${active ? 'active' : ''}" onClick=${onClick} title=${title}> | |
| <i class="${icon}"></i> | |
| <span>${label}</span> | |
| </button> | |
| `; | |
| } | |
| function IroColorPicker({ color, onChange }) { | |
| const elRef = useRef(null); | |
| const pickerRef = useRef(null); | |
| const isInternalChange = useRef(false); | |
| useEffect(() => { | |
| if (!window.iro || !elRef.current) return; | |
| pickerRef.current = new window.iro.ColorPicker(elRef.current, { | |
| width: 180, color: color, borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', | |
| layout: [ | |
| { component: window.iro.ui.Wheel }, | |
| { component: window.iro.ui.Slider, options: { sliderType: 'value', margin: 15 } } | |
| ] | |
| }); | |
| pickerRef.current.on('input:change', (c) => { | |
| isInternalChange.current = true; | |
| onChange(c.hexString); | |
| }); | |
| return () => { | |
| if (pickerRef.current) pickerRef.current.off('input:change'); | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| if (pickerRef.current && pickerRef.current.color.hexString !== color) { | |
| if (!isInternalChange.current) pickerRef.current.color.hexString = color; | |
| } | |
| isInternalChange.current = false; | |
| }, [color]); | |
| return html`<div class="crm-iro-wrapper" ref=${elRef}></div>`; | |
| } | |
| // === Right Sidebar (References & Pickers) === | |
| function RightSidebar({ | |
| refImage, uploadedImages, history, historyIndex, | |
| onImageUpload, onImageToCanvas, onRestore, onRemoveImage | |
| }) { | |
| const multiInputRef = useRef(null); | |
| const cameraInputRef = useRef(null); | |
| const handleFileSelect = useCallback((e) => { | |
| Array.from(e.target.files).forEach(file => { | |
| if (file.type.startsWith('image/')) { | |
| const reader = new FileReader(); | |
| reader.onload = (ev) => { | |
| const imgData = ev.target.result; | |
| onImageUpload({ name: file.name, data: imgData }); | |
| onImageToCanvas(imgData); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }); | |
| e.target.value = null; | |
| }, [onImageUpload, onImageToCanvas]); | |
| return html` | |
| <aside class="crm-sidebar right"> | |
| <div class="crm-sidebar-content"> | |
| <div class="crm-control-section"> | |
| <h4><i class="bi bi-images me-2"></i> Images & References</h4> | |
| ${refImage ? html` | |
| <div style="margin-bottom: 15px;"> | |
| <div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom:4px;">Original Subject</div> | |
| <img src=${refImage} alt="Reference" style="width: 100%; border-radius: 6px; border: 1px solid var(--border);" /> | |
| </div> | |
| ` : ''} | |
| <div style="display: flex; gap: 8px; margin-bottom: 5px;"> | |
| <button class="crm-import-btn" onClick=${() => multiInputRef.current?.click()} title="Select from device"> | |
| <i class="bi bi-folder2-open"></i> Import Image | |
| </button> | |
| <button class="crm-import-btn" onClick=${() => cameraInputRef.current?.click()} title="Take a photo"> | |
| <i class="bi bi-camera"></i> Camera | |
| </button> | |
| </div> | |
| <input ref=${multiInputRef} type="file" accept="image/*" id="filePicker" multiple style="display:none;" onChange=${handleFileSelect} /> | |
| <input ref=${cameraInputRef} type="file" accept="image/*" id="filePickerCamera" capture="camera" style="display:none;" onChange=${handleFileSelect} /> | |
| ${uploadedImages.length > 0 ? html` | |
| <div class="crm-image-grid"> | |
| ${uploadedImages.map((img, idx) => html` | |
| <div class="crm-image-item" onClick=${() => onImageToCanvas(img.data)} title="Click to add to canvas"> | |
| <img src=${img.data} alt=${img.name} /> | |
| <button class="crm-image-remove" onClick=${(e) => { e.stopPropagation(); onRemoveImage(idx); }}> | |
| <i class="bi bi-x-lg"></i> | |
| </button> | |
| </div> | |
| `)} | |
| </div> | |
| ` : ''} | |
| </div> | |
| ${history.length > 0 ? html` | |
| <div class="crm-control-section" style="flex:1; display:flex; flex-direction:column;"> | |
| <h4><i class="bi bi-clock-history me-2"></i> Timeline</h4> | |
| <div style="overflow-y: auto; flex:1;"> | |
| ${history.map((state, idx) => html` | |
| <div | |
| key=${idx} | |
| class="crm-history-item ${idx === historyIndex ? 'active' : ''}" | |
| onClick=${() => onRestore(idx)} | |
| > | |
| <i class="bi ${idx === 0 ? 'bi-file-earmark-plus' : 'bi-pencil-square'}"></i> | |
| <span>State ${idx + 1} ${idx === historyIndex ? '(Current)' : ''}</span> | |
| </div> | |
| `)} | |
| </div> | |
| </div> | |
| ` : ''} | |
| </div> | |
| </aside> | |
| `; | |
| } | |
| // === Main Notes Editor Engine === | |
| function NotesEditor({ imageId, refImage, sessionId, onClose }) { | |
| // DOM Refs | |
| const containerRef = useRef(null); | |
| const visibleCanvasRef = useRef(null); | |
| // Settings State | |
| const [tool, setTool] = useState('pen'); | |
| const [color, setColor] = useState('#ef4444'); | |
| const [size, setSize] = useState(3); | |
| const [uploadedImages, setUploadedImages] = useState([]); | |
| const [history, setHistory] = useState([]); | |
| const [historyIndex, setHistoryIndex] = useState(-1); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const [stylusDetected, setStylusDetected] = useState(false); | |
| const [allowTouchDrawing, setAllowTouchDrawing] = useState(false); | |
| // Core Engine Refs | |
| const bgImageRef = useRef(null); | |
| const pathsRef = useRef([]); | |
| const currentPathRef = useRef(null); | |
| const imagesRef = useRef([]); | |
| const selectedFloatingIdRef = useRef(null); | |
| // Hardware Acceleration Tracking | |
| const renderFrameRef = useRef(null); | |
| const activePointerId = useRef(null); | |
| const dragState = useRef({ type: 'none', id: null }); | |
| const isEraserActive = useRef(false); | |
| const stylusDetectedRef = useRef(false); | |
| const resizeCanvasToContainer = useCallback(() => { | |
| const canvas = visibleCanvasRef.current; | |
| const container = containerRef.current; | |
| if (!canvas || !container) return; | |
| const maxW = Math.max(320, container.clientWidth - 40); | |
| const maxH = Math.max(320, container.clientHeight - 40); | |
| const source = bgImageRef.current; | |
| const aspect = source ? source.width / source.height : 4 / 3; | |
| let width = maxW; | |
| let height = width / aspect; | |
| if (height > maxH) { | |
| height = maxH; | |
| width = height * aspect; | |
| } | |
| canvas.width = Math.round(width); | |
| canvas.height = Math.round(height); | |
| canvas.style.width = `${Math.round(width)}px`; | |
| canvas.style.height = `${Math.round(height)}px`; | |
| }, []); | |
| // --- Core Rendering Engine --- | |
| const renderCanvas = useCallback(() => { | |
| const canvas = visibleCanvasRef.current; | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| // 1. Base Layer | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| if (bgImageRef.current) ctx.drawImage(bgImageRef.current, 0, 0, canvas.width, canvas.height); | |
| // 2. Images Layer (MUST be below strokes) | |
| imagesRef.current.forEach(img => { | |
| ctx.drawImage(img.element, img.x, img.y, img.w, img.h); | |
| // Draw Selection Box UI | |
| if (img.id === selectedFloatingIdRef.current && tool === 'move') { | |
| ctx.strokeStyle = '#3b82f6'; | |
| ctx.lineWidth = 2; | |
| ctx.setLineDash([4, 4]); | |
| ctx.strokeRect(img.x, img.y, img.w, img.h); | |
| ctx.setLineDash([]); | |
| const hSize = 10; | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.strokeStyle = '#3b82f6'; | |
| ctx.lineWidth = 1; | |
| const drawHandle = (hx, hy) => { | |
| ctx.fillRect(hx - hSize/2, hy - hSize/2, hSize, hSize); | |
| ctx.strokeRect(hx - hSize/2, hy - hSize/2, hSize, hSize); | |
| }; | |
| drawHandle(img.x, img.y); | |
| drawHandle(img.x + img.w, img.y); | |
| drawHandle(img.x, img.y + img.h); | |
| drawHandle(img.x + img.w, img.y + img.h); | |
| } | |
| }); | |
| // 3. Vector Strokes Layer (ALWAYS on top) | |
| const drawVectorPath = (path) => { | |
| if (path.points.length === 0) return; | |
| ctx.beginPath(); | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| ctx.lineWidth = path.tool === 'marker' ? path.size * 6 : path.size; | |
| ctx.strokeStyle = path.color; | |
| ctx.globalAlpha = path.tool === 'marker' ? 0.35 : 1.0; | |
| ctx.moveTo(path.points[0].x, path.points[0].y); | |
| // Smoothing Optimization: Quadratic Curves | |
| if (path.points.length < 3) { | |
| for (let i = 1; i < path.points.length; i++) { | |
| ctx.lineTo(path.points[i].x, path.points[i].y); | |
| } | |
| } else { | |
| for (let i = 1; i < path.points.length - 1; i++) { | |
| const xc = (path.points[i].x + path.points[i + 1].x) / 2; | |
| const yc = (path.points[i].y + path.points[i + 1].y) / 2; | |
| ctx.quadraticCurveTo(path.points[i].x, path.points[i].y, xc, yc); | |
| } | |
| // Cap the end of the line | |
| const last = path.points.length - 1; | |
| ctx.lineTo(path.points[last].x, path.points[last].y); | |
| } | |
| ctx.stroke(); | |
| ctx.globalAlpha = 1.0; | |
| }; | |
| pathsRef.current.forEach(drawVectorPath); | |
| if (currentPathRef.current) drawVectorPath(currentPathRef.current); | |
| }, [tool]); | |
| // Hardware-accelerated queueing for smooth 60FPS dragging & drawing | |
| const queueRender = useCallback(() => { | |
| if (!renderFrameRef.current) { | |
| renderFrameRef.current = requestAnimationFrame(() => { | |
| renderCanvas(); | |
| renderFrameRef.current = null; | |
| }); | |
| } | |
| }, [renderCanvas]); | |
| // --- History / State Management --- | |
| const saveState = useCallback(() => { | |
| const state = { | |
| paths: JSON.parse(JSON.stringify(pathsRef.current)), | |
| images: imagesRef.current.map(img => ({ id: img.id, x: img.x, y: img.y, w: img.w, h: img.h, src: img.element.src })) | |
| }; | |
| setHistory(prev => { | |
| const newHistory = prev.slice(0, historyIndex + 1); | |
| newHistory.push(state); | |
| if (newHistory.length > 50) newHistory.shift(); | |
| return newHistory; | |
| }); | |
| setHistoryIndex(prev => (prev === -1 ? 0 : prev + 1)); | |
| }, [historyIndex]); | |
| const restoreState = useCallback((idx) => { | |
| if (!history[idx]) return; | |
| const state = history[idx]; | |
| pathsRef.current = JSON.parse(JSON.stringify(state.paths)); | |
| imagesRef.current = state.images.map(d => { | |
| const i = new Image(); i.src = d.src; i.crossOrigin = "anonymous"; | |
| return { ...d, element: i }; | |
| }); | |
| selectedFloatingIdRef.current = null; | |
| renderCanvas(); | |
| setHistoryIndex(idx); | |
| }, [history, renderCanvas]); | |
| // --- Initialization --- | |
| useEffect(() => { | |
| const canvas = visibleCanvasRef.current; | |
| const container = containerRef.current; | |
| if (!canvas || !container) return; | |
| resizeCanvasToContainer(); | |
| const loadBackground = (src, saveInitialState = true) => { | |
| const img = new Image(); | |
| img.crossOrigin = "anonymous"; | |
| img.onload = () => { | |
| bgImageRef.current = img; | |
| resizeCanvasToContainer(); | |
| renderCanvas(); | |
| if (saveInitialState) saveState(); | |
| }; | |
| img.src = src; | |
| }; | |
| const loadBlankCanvas = () => { | |
| bgImageRef.current = null; | |
| resizeCanvasToContainer(); | |
| renderCanvas(); | |
| saveState(); | |
| }; | |
| const fetchNote = async () => { | |
| try { | |
| const response = await fetch(`/get_note_json/${imageId}`); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| if (Array.isArray(data.linked_pages)) { | |
| setUploadedImages(data.linked_pages.map((p, idx) => ({ | |
| id: p.source_page_id || idx, name: `PDF Page ${p.source_page_number || idx + 1}`, | |
| data: p.note_filename ? `/image/processed/${p.note_filename}` : null | |
| })).filter(p => p.data)); | |
| } | |
| if (data.success && data.image_data) { | |
| loadBackground(data.image_data); | |
| } else loadBlankCanvas(); | |
| } else loadBlankCanvas(); | |
| } catch (e) { | |
| loadBlankCanvas(); | |
| } | |
| }; | |
| fetchNote(); | |
| const handleResize = () => { | |
| resizeCanvasToContainer(); | |
| renderCanvas(); | |
| }; | |
| const handleKeyDown = (e) => { | |
| if ((e.key === 'Delete' || e.key === 'Backspace') && tool === 'move' && selectedFloatingIdRef.current) { | |
| imagesRef.current = imagesRef.current.filter(i => i.id !== selectedFloatingIdRef.current); | |
| selectedFloatingIdRef.current = null; | |
| renderCanvas(); | |
| saveState(); | |
| } | |
| }; | |
| window.addEventListener('resize', handleResize); | |
| window.addEventListener('keydown', handleKeyDown); | |
| return () => { | |
| window.removeEventListener('resize', handleResize); | |
| window.removeEventListener('keydown', handleKeyDown); | |
| if (renderFrameRef.current) cancelAnimationFrame(renderFrameRef.current); | |
| }; | |
| }, []); | |
| // --- Stroke Eraser Logic --- | |
| const executeStrokeEraser = useCallback((x, y) => { | |
| const eRadius = size * 8 / 2; | |
| const eRadiusSq = eRadius * eRadius; | |
| let didErase = false; | |
| pathsRef.current = pathsRef.current.filter(path => { | |
| if (path.points.length === 1) { | |
| const pt = path.points[0]; | |
| if ((pt.x - x)**2 + (pt.y - y)**2 <= eRadiusSq) { didErase = true; return false; } | |
| } else { | |
| for (let i = 0; i < path.points.length - 1; i++) { | |
| const dSq = distSqToSegment({x,y}, path.points[i], path.points[i+1]); | |
| if (dSq <= eRadiusSq) { didErase = true; return false; } | |
| } | |
| } | |
| return true; | |
| }); | |
| if (didErase) queueRender(); | |
| return didErase; | |
| }, [size, queueRender]); | |
| // --- Interaction Handlers --- | |
| const handlePointerDown = useCallback((e) => { | |
| e.preventDefault(); | |
| if (e.button && e.button !== 0) return; | |
| if (e.pointerType === 'pen' && !stylusDetectedRef.current) { | |
| stylusDetectedRef.current = true; | |
| setStylusDetected(true); | |
| } | |
| if (tool !== 'move' && e.pointerType === 'touch' && stylusDetectedRef.current && !allowTouchDrawing) { | |
| return; | |
| } | |
| if (activePointerId.current !== null) return; | |
| activePointerId.current = e.pointerId; | |
| visibleCanvasRef.current?.setPointerCapture?.(e.pointerId); | |
| const rect = visibleCanvasRef.current.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| if (tool === 'move') { | |
| let hitFound = false; | |
| const hSize = 12; | |
| const isHit = (mx, my, rx, ry, rw, rh) => mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh; | |
| if (selectedFloatingIdRef.current) { | |
| const selImg = imagesRef.current.find(i => i.id === selectedFloatingIdRef.current); | |
| if (selImg) { | |
| if (isHit(x, y, selImg.x - hSize, selImg.y - hSize, hSize*2, hSize*2)) { dragState.current = { type: 'resize-tl', id: selImg.id }; hitFound = true; } | |
| else if (isHit(x, y, selImg.x + selImg.w - hSize, selImg.y - hSize, hSize*2, hSize*2)) { dragState.current = { type: 'resize-tr', id: selImg.id }; hitFound = true; } | |
| else if (isHit(x, y, selImg.x - hSize, selImg.y + selImg.h - hSize, hSize*2, hSize*2)) { dragState.current = { type: 'resize-bl', id: selImg.id }; hitFound = true; } | |
| else if (isHit(x, y, selImg.x + selImg.w - hSize, selImg.y + selImg.h - hSize, hSize*2, hSize*2)) { dragState.current = { type: 'resize-br', id: selImg.id }; hitFound = true; } | |
| } | |
| } | |
| if (!hitFound) { | |
| for (let i = imagesRef.current.length - 1; i >= 0; i--) { | |
| const img = imagesRef.current[i]; | |
| if (isHit(x, y, img.x, img.y, img.w, img.h)) { | |
| selectedFloatingIdRef.current = img.id; | |
| dragState.current = { type: 'move', id: img.id, offsetX: x - img.x, offsetY: y - img.y }; | |
| hitFound = true; | |
| imagesRef.current.splice(i, 1); | |
| imagesRef.current.push(img); | |
| break; | |
| } | |
| } | |
| } | |
| if (!hitFound) selectedFloatingIdRef.current = null; | |
| renderCanvas(); // Synchronous render for immediate tap feedback | |
| } else if (tool === 'eraser') { | |
| isEraserActive.current = true; | |
| executeStrokeEraser(x, y); | |
| } else { | |
| const pressureScale = e.pointerType === 'pen' && e.pressure | |
| ? Math.max(0.55, Math.min(1.5, e.pressure * 1.35)) | |
| : 1; | |
| currentPathRef.current = { id: Date.now(), tool: tool, color: color, size: size * pressureScale, points: [{x, y}] }; | |
| renderCanvas(); // Synchronous for immediate dot placement | |
| } | |
| }, [tool, color, size, renderCanvas, executeStrokeEraser, allowTouchDrawing]); | |
| const handlePointerMove = useCallback((e) => { | |
| if (activePointerId.current !== e.pointerId) return; | |
| e.preventDefault(); | |
| const rect = visibleCanvasRef.current.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| if (tool === 'move' && dragState.current.type !== 'none') { | |
| const img = imagesRef.current.find(i => i.id === dragState.current.id); | |
| if (!img) return; | |
| if (dragState.current.type === 'move') { | |
| img.x = x - dragState.current.offsetX; | |
| img.y = y - dragState.current.offsetY; | |
| } else { | |
| let newX = img.x, newY = img.y, newW = img.w, newH = img.h; | |
| if (dragState.current.type === 'resize-br') { newW = x - img.x; newH = y - img.y; } | |
| else if (dragState.current.type === 'resize-bl') { newW = (img.x + img.w) - x; newH = y - img.y; newX = x; } | |
| else if (dragState.current.type === 'resize-tr') { newW = x - img.x; newH = (img.y + img.h) - y; newY = y; } | |
| else if (dragState.current.type === 'resize-tl') { newW = (img.x + img.w) - x; newH = (img.y + img.h) - y; newX = x; newY = y; } | |
| if (newW > 30 && newH > 30) { img.x = newX; img.y = newY; img.w = newW; img.h = newH; } | |
| } | |
| queueRender(); // Optimized hardware paint | |
| } else if (tool === 'eraser' && isEraserActive.current) { | |
| executeStrokeEraser(x, y); | |
| } else if (currentPathRef.current) { | |
| const pts = currentPathRef.current.points; | |
| const lastPt = pts[pts.length - 1]; | |
| // Culling threshold to discard jitter | |
| if ((x - lastPt.x)**2 + (y - lastPt.y)**2 > 6) { | |
| pts.push({x, y}); | |
| queueRender(); // Optimized hardware paint | |
| } | |
| } | |
| }, [tool, queueRender, executeStrokeEraser]); | |
| const handlePointerUp = useCallback((e) => { | |
| if (activePointerId.current !== e.pointerId) return; | |
| const canvas = visibleCanvasRef.current; | |
| if (canvas?.hasPointerCapture?.(e.pointerId)) { | |
| canvas.releasePointerCapture(e.pointerId); | |
| } | |
| if (currentPathRef.current) { | |
| pathsRef.current.push(currentPathRef.current); | |
| currentPathRef.current = null; | |
| renderCanvas(); // Final render lock | |
| saveState(); | |
| } else if (tool === 'move' && dragState.current.type !== 'none') { | |
| dragState.current.type = 'none'; | |
| saveState(); | |
| } else if (tool === 'eraser' && isEraserActive.current) { | |
| isEraserActive.current = false; | |
| saveState(); | |
| } | |
| activePointerId.current = null; | |
| }, [tool, saveState, renderCanvas]); | |
| // --- Actions --- | |
| const handleClear = useCallback(() => { | |
| if (!confirm('Clear all drawings and floating images?')) return; | |
| pathsRef.current = []; | |
| imagesRef.current = []; | |
| selectedFloatingIdRef.current = null; | |
| renderCanvas(); | |
| saveState(); | |
| }, [renderCanvas, saveState]); | |
| const handleSave = useCallback(async () => { | |
| if (isSaving) return; | |
| selectedFloatingIdRef.current = null; | |
| renderCanvas(); // Strip selection UI | |
| const canvas = visibleCanvasRef.current; | |
| const imageData = canvas.toDataURL('image/png'); | |
| try { | |
| setIsSaving(true); | |
| const response = await fetch('/save_note_json', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ image_id: imageId, session_id: sessionId, json_data: '{}', image_data: imageData }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| if (window.showStatus) window.showStatus('Notes saved!', 'success'); | |
| if (window.markRevisionNoteSaved) window.markRevisionNoteSaved(imageId); | |
| onClose(); | |
| } else { | |
| if (window.showStatus) window.showStatus('Error: ' + result.error, 'danger'); | |
| } | |
| } catch (e) { | |
| if (window.showStatus) window.showStatus(e.message, 'danger'); | |
| } finally { | |
| setIsSaving(false); | |
| } | |
| }, [imageId, sessionId, onClose, renderCanvas, isSaving]); | |
| const handleImageToCanvas = useCallback((imgDataUrl) => { | |
| const img = new Image(); | |
| img.crossOrigin = "anonymous"; | |
| img.onload = () => { | |
| const canvasW = visibleCanvasRef.current.width; | |
| const canvasH = visibleCanvasRef.current.height; | |
| const padding = 50; | |
| const scale = Math.min(1, Math.min((canvasW - padding*2) / img.width, (canvasH - padding*2) / img.height)); | |
| const newWidth = img.width * scale; | |
| const newHeight = img.height * scale; | |
| const newImg = { | |
| id: Date.now(), element: img, | |
| x: (canvasW - newWidth) / 2, y: (canvasH - newHeight) / 2, | |
| w: newWidth, h: newHeight | |
| }; | |
| imagesRef.current.push(newImg); | |
| selectedFloatingIdRef.current = newImg.id; | |
| setTool('move'); | |
| renderCanvas(); | |
| saveState(); | |
| }; | |
| img.src = imgDataUrl; | |
| }, [renderCanvas, saveState]); | |
| useEffect(() => { | |
| if (containerRef.current) containerRef.current.className = `crm-canvas-container tool-${tool}`; | |
| }, [tool]); | |
| return html` | |
| <div class="crm-modal-overlay"> | |
| <header class="crm-header"> | |
| <div style="font-weight: 700; display: flex; align-items: center; gap: 10px;"> | |
| <i class="bi bi-pencil-square" style="color: var(--primary);"></i> Revision Notes | |
| </div> | |
| <div style="display: flex; gap: 8px;"> | |
| <button class="crm-btn" onClick=${() => restoreState(historyIndex - 1)} disabled=${historyIndex <= 0 || isSaving} title="Undo"> | |
| <i class="bi bi-arrow-counterclockwise"></i> | |
| </button> | |
| <button class="crm-btn crm-btn-primary" onClick=${handleSave} disabled=${isSaving}> | |
| <i class="bi ${isSaving ? 'bi-hourglass-split' : 'bi-check2'}"></i> ${isSaving ? 'Saving...' : 'Save'} | |
| </button> | |
| <button class="crm-btn" onClick=${onClose} disabled=${isSaving} title="Close"> | |
| <i class="bi bi-x-lg"></i> | |
| </button> | |
| </div> | |
| </header> | |
| <div class="crm-workspace"> | |
| <aside class="crm-sidebar left"> | |
| <div class="crm-sidebar-content"> | |
| <div class="crm-control-section"> | |
| <h4>Tools</h4> | |
| <div class="crm-tool-row"> | |
| <${ToolButton} icon="bi bi-pen" label="Pen" active=${tool === 'pen'} onClick=${() => setTool('pen')} /> | |
| <${ToolButton} icon="bi bi-highlighter" label="Highlight" active=${tool === 'marker'} onClick=${() => setTool('marker')} /> | |
| <${ToolButton} icon="bi bi-eraser" label="Erase" active=${tool === 'eraser'} onClick=${() => setTool('eraser')} title="Deletes entire lines on click/touch" /> | |
| <${ToolButton} icon="bi bi-arrows-move" label="Move" active=${tool === 'move'} onClick=${() => setTool('move')} title="Select, move, and resize images" /> | |
| </div> | |
| <div style="margin-top: 14px;"> | |
| <div class="crm-size-row"> | |
| ${QUICK_SIZES.map(s => html` | |
| <button class="crm-btn crm-size-btn ${size === s ? 'active' : ''}" onClick=${() => setSize(s)} disabled=${tool === 'move'} title=${`${s}px`}> | |
| <span class="crm-size-dot" style=${`width:${Math.min(18, s + 4)}px;height:${Math.min(18, s + 4)}px;`}></span> | |
| </button> | |
| `)} | |
| </div> | |
| <div style="display: flex; justify-content: space-between; margin-bottom: 8px;"> | |
| <span style="font-size: 0.8rem; color: var(--text-muted);">Brush Size</span> | |
| <span style="font-size: 0.8rem; color: var(--text-main); font-family: monospace;">${size}px</span> | |
| </div> | |
| <input | |
| type="range" class="crm-slider" | |
| min="1" max="50" value=${size} | |
| onInput=${(e) => setSize(parseInt(e.target.value))} | |
| disabled=${tool === 'move'} | |
| /> | |
| <div class="crm-input-row"> | |
| ${stylusDetected ? html` | |
| <span class="crm-badge"><i class="bi bi-pen"></i> Stylus</span> | |
| ` : html` | |
| <span>Input</span> | |
| `} | |
| <label class="crm-switch" title="Allow finger strokes even after stylus detection"> | |
| <input type="checkbox" checked=${allowTouchDrawing} onChange=${(e) => setAllowTouchDrawing(e.currentTarget.checked)} /> | |
| <span>Finger draw</span> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="crm-control-section" style="opacity: ${tool === 'move' ? 0.3 : 1}; pointer-events: ${tool === 'move' ? 'none' : 'auto'}"> | |
| <h4>Color</h4> | |
| <div class="crm-swatch-row"> | |
| ${QUICK_COLORS.map(c => html` | |
| <button class="crm-swatch ${color === c ? 'active' : ''}" style=${`background:${c}`} onClick=${() => setColor(c)} title=${c}></button> | |
| `)} | |
| </div> | |
| <${IroColorPicker} color=${color} onChange=${setColor} /> | |
| </div> | |
| <div class="crm-control-section"> | |
| <h4>Actions</h4> | |
| <div style="display: flex; flex-direction: column; gap: 8px;"> | |
| <button class="crm-btn crm-btn-danger" onClick=${handleClear} style="justify-content: flex-start;"> | |
| <i class="bi bi-trash"></i> Clear Canvas | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| <main class="crm-viewport"> | |
| <div ref=${containerRef} class="crm-canvas-container"> | |
| <canvas | |
| ref=${visibleCanvasRef} | |
| onPointerDown=${handlePointerDown} | |
| onPointerMove=${handlePointerMove} | |
| onPointerUp=${handlePointerUp} | |
| onPointerLeave=${handlePointerUp} | |
| onPointerCancel=${handlePointerUp} | |
| ></canvas> | |
| </div> | |
| </main> | |
| <${RightSidebar} | |
| refImage=${refImage} | |
| uploadedImages=${uploadedImages} | |
| history=${history} | |
| historyIndex=${historyIndex} | |
| onImageUpload=${(img) => setUploadedImages(prev => [...prev, img])} | |
| onImageToCanvas=${handleImageToCanvas} | |
| onRemoveImage=${(idx) => setUploadedImages(prev => prev.filter((_, i) => i !== idx))} | |
| onRestore=${restoreState} | |
| /> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| // === Global API Exports === | |
| window.openNotesModal = function(imageId, refImageUrl, sessionId) { | |
| const root = document.getElementById('notes-modal-root'); | |
| render( | |
| html`<${NotesEditor} | |
| imageId=${imageId} | |
| refImage=${refImageUrl} | |
| sessionId=${sessionId} | |
| onClose=${() => render(null, root)} | |
| />`, | |
| root | |
| ); | |
| }; | |
| window.closeNotesModal = function() { | |
| render(null, document.getElementById('notes-modal-root')); | |
| }; | |
| window.markRevisionNoteSaved = function(imageId) { | |
| const fieldset = document.getElementById(`question-fieldset-${imageId}`); | |
| if (!fieldset) return; | |
| const addButton = Array.from(fieldset.querySelectorAll('button[onclick^="openNotesModal"]')) | |
| .find(button => !button.closest('.note-card')); | |
| if (!addButton) return; | |
| const onclick = addButton.getAttribute('onclick'); | |
| addButton.outerHTML = ` | |
| <div class="note-card"> | |
| <div class="d-flex align-items-center justify-content-center gap-2 py-2 text-success"> | |
| <i class="bi bi-check-circle-fill"></i> | |
| <span class="small">Notes saved</span> | |
| </div> | |
| <div class="note-actions flex-wrap justify-content-center"> | |
| <div class="form-check form-switch include-pdf-toggle"> | |
| <input class="form-check-input" type="checkbox" id="include_note_${imageId}" checked | |
| onchange="toggleNoteInPdf('${imageId}', this.checked)"> | |
| <label class="form-check-label small" for="include_note_${imageId}">In PDF</label> | |
| </div> | |
| <button type="button" class="btn btn-sm btn-outline-info btn-pill" onclick="${onclick}" title="Edit Note"> | |
| <i class="bi bi-pencil"></i> | |
| </button> | |
| <button type="button" class="btn btn-sm btn-outline-danger btn-pill" onclick="deleteNote('${imageId}')" title="Delete Note"> | |
| <i class="bi bi-trash"></i> | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| }; | |
| window.deleteNote = async function(imageId) { | |
| if (!confirm('Delete this revision note?')) return; | |
| try { | |
| const response = await fetch('/delete_note', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ image_id: imageId }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| if (window.showStatus) window.showStatus('Note deleted', 'success'); | |
| setTimeout(() => location.reload(), 300); | |
| } else { | |
| if (window.showStatus) window.showStatus('Error: ' + result.error, 'danger'); | |
| } | |
| } catch (e) { | |
| if (window.showStatus) window.showStatus(e.message, 'danger'); | |
| } | |
| }; | |
| window.toggleNoteInPdf = async function(imageId, include) { | |
| try { | |
| await fetch('/toggle_note_in_pdf', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ image_id: imageId, include: include }) | |
| }); | |
| } catch (e) { | |
| console.error('Error toggling note in PDF:', e); | |
| } | |
| }; | |
| </script> | |