Report-Generator / templates /_revision_notes_preact.html
Ubuntu
Fixed more bugs ; qol changes
1671832
{#
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>