/** * ComfyUI Annotations Extension * * A pure annotation/markup overlay system for ComfyUI's node editor. * Allows freehand drawing directly on the canvas without modifying workflow data. * * @version 1.0.0 * @license MIT * * Features: * - Canvas overlay that exactly matches the node editor viewport * - High-DPI crisp rendering support * - Toggle mode that doesn't block node interactions when OFF * - Freehand drawing with smooth strokes * - Pen tool with color, size, opacity settings * - Eraser tool with hit-testing * - Undo/redo with configurable history * - Persistence (save/load with workflow) * - Performance optimized (RAF batching, stroke caching) */ import { app } from "../../scripts/app.js"; import { createOverlay, destroyOverlay, getCanvas, getContext, getOverlay, setPointerEventsMode, setCursor, showOverlay, hideOverlay, isOverlayVisible, clearCanvas, isInitialized, onResize, getCanvasDimensions } from "./modules/annotationOverlay.js"; import { getTransform, screenToCanvas, canvasToScreen, applyTransformToContext, resetContextTransform, createTransformTracker, drawDebugOverlay, setDebugVisualizerEnabled, isDebugVisualizerEnabled, runCoordinateTests, screenDistanceToCanvas } from "./modules/coordinateUtils.js"; import { findHitStrokes, removeStrokesById } from "./modules/hitTesting.js"; import { initHistory, recordState, undo as historyUndo, redo as historyRedo, canUndo, canRedo, clearHistory, getHistoryState, setMaxHistory, getMaxHistory, recordInitialState } from "./modules/historyManager.js"; import { createToolbar, updateToolbarState, toggleCollapse as toggleToolbarCollapse, setCollapsed as setToolbarCollapsed, isToolbarCollapsed, showToolbar, hideToolbar, destroyToolbar } from "./modules/toolbarUI.js"; import { setupPersistence, serializeAnnotations, deserializeAnnotations, getExtensionKey } from "./modules/persistence.js"; import { scheduleRender, cancelPendingRender, forceRender, initStrokeCache, invalidateCache, drawStrokesWithCache, drawStrokeCached, invalidateStrokePath, startRenderTiming, endRenderTiming, getPerformanceMetrics, resetPerformanceMetrics, runBenchmark } from "./modules/renderOptimizer.js"; import { detectFeatures, checkRequiredFeatures, warnIfUnsupported, hasFeature, getFeatures, createOptimizedContext, createOffscreenCanvasWithFallback, getCoalescedEventsWithFallback, safariWorkarounds, timing, generateCompatibilityReport } from "./modules/browserCompat.js"; // ============================================================================ // Constants // ============================================================================ const VERSION = "1.0.0"; const EXTENSION_NAME = "comfyui.annotations"; const LOG_PREFIX = "[Annotations]"; // ============================================================================ // State // ============================================================================ let isAnnotationModeActive = false; let transformTrackerCleanup = null; // Tool state: 'pen' or 'eraser' let currentTool = 'pen'; // Strokes array - completed strokes let strokes = []; // Current drawing state let isDrawing = false; let currentStroke = null; let lastPoint = null; // Pen settings const penSettings = { color: '#FF5555', size: 4, opacity: 1.0 }; // Eraser settings const eraserSettings = { size: 20 // Eraser radius in canvas coordinates }; // Eraser cursor position (for visualization) let eraserCursorPos = null; // Minimum distance between points (in canvas coords) for performance const MIN_POINT_DISTANCE = 2; /** * Returns the current annotation state for the toolbar. * @returns {Object} Current state */ function getAnnotationState() { return { isActive: isAnnotationModeActive, currentTool, penSettings: { ...penSettings }, eraserSettings: { ...eraserSettings }, canUndo: canUndo(), canRedo: canRedo(), strokeCount: strokes.length }; } // ============================================================================ // Initialization // ============================================================================ /** * Waits for the canvas to be ready and initializes the overlay. */ function waitForCanvasAndInitialize() { // Prevent duplicate initialization if (isInitialized()) { console.warn(LOG_PREFIX, 'Already initialized'); return; } let attempts = 0; const maxAttempts = 300; // 30 seconds const pollInterval = 100; function tryInitialize() { try { const canvas = document.querySelector('canvas'); // Check all required conditions if (canvas && canvas.parentElement && app?.canvas?.ds) { initializeAnnotationSystem(canvas); return true; } return false; } catch (error) { console.error(LOG_PREFIX, 'Init check error:', error); return false; } } // Try immediately if (tryInitialize()) return; // Poll until ready const intervalId = setInterval(() => { attempts++; if (tryInitialize()) { clearInterval(intervalId); console.log(LOG_PREFIX, `Initialized after ${attempts * pollInterval}ms`); } else if (attempts >= maxAttempts) { clearInterval(intervalId); console.error(LOG_PREFIX, 'Initialization timeout - canvas not ready'); } }, pollInterval); } /** * Main initialization function. * @param {HTMLCanvasElement} litegraphCanvas */ function initializeAnnotationSystem(litegraphCanvas) { const container = litegraphCanvas.parentElement; // Ensure container supports absolute positioning container.style.position = 'relative'; // Create overlay and canvas createOverlay(container); // Setup resize callback to re-render on size change onResize((width, height, dpr) => { renderAnnotations(); }); // Start transform tracking (re-render when zoom/pan changes) // Only re-render when transform actually changes (not on every frame) let lastTransformStr = ''; transformTrackerCleanup = createTransformTracker((transform) => { const transformStr = `${transform.scale},${transform.offsetX},${transform.offsetY}`; if (transformStr !== lastTransformStr) { lastTransformStr = transformStr; renderAnnotations(); } }); // Setup keyboard shortcut for toggle setupKeyboardShortcut(); // Setup pointer events for drawing setupPointerEvents(); // Initialize history manager initHistory({ maxHistory: 50, onRestore: (restoredStrokes) => { strokes = restoredStrokes; invalidateCache(); // Strokes changed, invalidate cache renderAnnotations(); updateToolbarState(); } }); recordInitialState(); // Record empty state as starting point // Create toolbar UI createToolbar(container, { onToggleMode: toggleAnnotationMode, onSetTool: setTool, onSetColor: (color) => { penSettings.color = color; }, onSetSize: (size) => { penSettings.size = Math.max(1, Math.min(50, size)); }, onSetOpacity: (opacity) => { penSettings.opacity = Math.max(0.1, Math.min(1, opacity)); }, onSetEraserSize: (size) => { eraserSettings.size = Math.max(5, Math.min(100, size)); renderAnnotations(); }, onUndo: performUndo, onRedo: performRedo, onClear: clearAllStrokes, getState: getAnnotationState }); // Setup persistence (save/load annotations with workflow) setupPersistence({ getStrokes: () => strokes, setStrokes: (newStrokes) => { strokes = newStrokes; invalidateCache(); // New strokes loaded, invalidate cache // Reset history when loading new strokes clearHistory(); recordInitialState(); if (newStrokes.length > 0) { recordState(strokes, 'load'); } renderAnnotations(); updateToolbarState(); }, onLoad: () => { console.log(LOG_PREFIX, 'Annotations loaded from workflow'); } }); // Initial state: overlay visible but pass-through (annotations OFF) showOverlay(); setPointerEventsMode('none'); renderAnnotations(); updateToolbarState(); // Debug: log canvas info const { width, height, dpr } = getCanvasDimensions(); console.log(LOG_PREFIX, `Canvas dimensions: ${width}x${height}, DPR: ${dpr}`); console.log(LOG_PREFIX, 'Annotation system initialized'); console.log(LOG_PREFIX, 'Press "A" to toggle annotation mode'); } // ============================================================================ // Rendering // ============================================================================ // Performance optimization: use stroke cache for large drawings let useStrokeCache = true; // Enable by default /** * Renders all annotations on the overlay canvas. * Applies LiteGraph transform so strokes follow zoom/pan. * Uses stroke caching for better performance with many strokes. */ function renderAnnotations() { const ctx = getContext(); const canvas = getCanvas(); if (!ctx || !canvas) { console.warn(LOG_PREFIX, 'No context or canvas available'); return; } const startTime = startRenderTiming(); const dpr = window.devicePixelRatio || 1; const { width, height } = getCanvasDimensions(); // Initialize stroke cache if needed if (useStrokeCache) { initStrokeCache(canvas.width, canvas.height); } // Clear canvas clearCanvas(); // Get current transform const transform = getTransform(); // === Draw strokes (canvas coordinates with LiteGraph transform) === // LiteGraph formula: screenPos = (canvasPos + offset) * scale ctx.setTransform( transform.scale * dpr, 0, 0, transform.scale * dpr, transform.offsetX * transform.scale * dpr, transform.offsetY * transform.scale * dpr ); // Draw all strokes using cache for performance if (useStrokeCache && strokes.length > 10) { // Use cached layer for many strokes drawStrokesWithCache(ctx, strokes, transform, dpr); // Restore transform after cache composite ctx.setTransform( transform.scale * dpr, 0, 0, transform.scale * dpr, transform.offsetX * transform.scale * dpr, transform.offsetY * transform.scale * dpr ); } else { // Direct rendering for few strokes for (const stroke of strokes) { drawStrokeCached(ctx, stroke, false); } } // === Draw eraser cursor (in canvas coordinates, under transform) === if (isAnnotationModeActive && currentTool === 'eraser' && eraserCursorPos) { drawEraserCursor(ctx, eraserCursorPos.x, eraserCursorPos.y, eraserSettings.size); } // === Draw UI elements (screen coordinates, always visible) === ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Draw mode indicator only when annotation mode is active if (isAnnotationModeActive) { drawModeIndicator(ctx, width, height); } // Draw debug overlay if enabled drawDebugOverlay(ctx, width, height, dpr); endRenderTiming(startTime); } /** * Draws the eraser cursor circle at the given canvas position. * @param {CanvasRenderingContext2D} ctx * @param {number} x - Canvas X coordinate * @param {number} y - Canvas Y coordinate * @param {number} radius - Eraser radius in canvas units */ function drawEraserCursor(ctx, x, y, radius) { // Draw outer circle (eraser boundary) ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(85, 170, 255, 0.8)'; ctx.lineWidth = 2 / getTransform().scale; // Keep line width consistent on screen ctx.stroke(); // Draw inner fill (semi-transparent) ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = 'rgba(85, 170, 255, 0.15)'; ctx.fill(); // Draw center crosshair const crossSize = radius * 0.3; ctx.beginPath(); ctx.moveTo(x - crossSize, y); ctx.lineTo(x + crossSize, y); ctx.moveTo(x, y - crossSize); ctx.lineTo(x, y + crossSize); ctx.strokeStyle = 'rgba(85, 170, 255, 0.6)'; ctx.lineWidth = 1 / getTransform().scale; ctx.stroke(); } /** * Draws a single stroke with straight line segments. * @param {CanvasRenderingContext2D} ctx * @param {Object} stroke */ function drawStroke(ctx, stroke) { if (!stroke.points || stroke.points.length < 2) return; ctx.beginPath(); ctx.strokeStyle = stroke.color; ctx.lineWidth = stroke.size; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.globalAlpha = stroke.opacity; ctx.moveTo(stroke.points[0].x, stroke.points[0].y); for (let i = 1; i < stroke.points.length; i++) { ctx.lineTo(stroke.points[i].x, stroke.points[i].y); } ctx.stroke(); ctx.globalAlpha = 1; } /** * Draws a stroke with quadratic curve smoothing for a smoother appearance. * Uses midpoints between consecutive points as control points. * @param {CanvasRenderingContext2D} ctx * @param {Object} stroke */ function drawStrokeSmooth(ctx, stroke) { if (!stroke.points || stroke.points.length < 2) return; ctx.beginPath(); ctx.strokeStyle = stroke.color; ctx.lineWidth = stroke.size; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.globalAlpha = stroke.opacity; const points = stroke.points; // Start at first point ctx.moveTo(points[0].x, points[0].y); // If only 2 points, draw a line if (points.length === 2) { ctx.lineTo(points[1].x, points[1].y); ctx.stroke(); ctx.globalAlpha = 1; return; } // For 3+ points, use quadratic curves through midpoints // This creates smooth curves that pass through all midpoints for (let i = 1; i < points.length - 1; i++) { const current = points[i]; const next = points[i + 1]; // Calculate midpoint to next point const midX = (current.x + next.x) / 2; const midY = (current.y + next.y) / 2; // Draw quadratic curve to midpoint, using current point as control ctx.quadraticCurveTo(current.x, current.y, midX, midY); } // Draw final segment to last point const lastPoint = points[points.length - 1]; const secondLast = points[points.length - 2]; ctx.quadraticCurveTo(secondLast.x, secondLast.y, lastPoint.x, lastPoint.y); ctx.stroke(); ctx.globalAlpha = 1; } /** * Draws a status indicator in the corner showing mode and current tool. * @param {CanvasRenderingContext2D} ctx * @param {number} width * @param {number} height */ function drawModeIndicator(ctx, width, height) { const padding = 12; const toolName = currentTool === 'pen' ? 'PEN' : 'ERASER'; const text = `ANNOTATION: ${toolName}`; const bgColor = currentTool === 'pen' ? 'rgba(255, 85, 85, 0.9)' : 'rgba(85, 170, 255, 0.9)'; ctx.font = 'bold 13px -apple-system, BlinkMacSystemFont, sans-serif'; const textWidth = ctx.measureText(text).width; const boxWidth = textWidth + padding * 2; const boxHeight = 28; const x = width - boxWidth - 10; const y = 10; // Background with rounded corners ctx.fillStyle = bgColor; ctx.beginPath(); ctx.roundRect(x, y, boxWidth, boxHeight, 6); ctx.fill(); // Text ctx.fillStyle = '#FFFFFF'; ctx.fillText(text, x + padding, y + 19); // Show shortcut hints below ctx.font = '11px -apple-system, BlinkMacSystemFont, sans-serif'; ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; const hintText1 = 'P: Pen E: Eraser Esc: Exit'; const hintWidth1 = ctx.measureText(hintText1).width; ctx.fillText(hintText1, width - hintWidth1 - 10 - padding, y + 45); // Show undo/redo hint with status const undoStatus = canUndo() ? '●' : '○'; const redoStatus = canRedo() ? '●' : '○'; const hintText2 = `${undoStatus} Ctrl+Z: Undo ${redoStatus} Ctrl+Shift+Z: Redo`; const hintWidth2 = ctx.measureText(hintText2).width; ctx.fillText(hintText2, width - hintWidth2 - 10 - padding, y + 60); } // ============================================================================ // Pointer Events (Drawing) // ============================================================================ /** * Sets up pointer event handlers for drawing. */ function setupPointerEvents() { const canvas = getCanvas(); if (!canvas) return; canvas.addEventListener('pointerdown', handlePointerDown); canvas.addEventListener('pointermove', handlePointerMove); canvas.addEventListener('pointerup', handlePointerUp); canvas.addEventListener('pointerleave', handlePointerLeave); canvas.addEventListener('pointercancel', handlePointerCancel); // Handle lost pointer capture (e.g., when another element captures) canvas.addEventListener('lostpointercapture', handleLostPointerCapture); // Prevent default touch behaviors (scrolling, zooming while drawing) canvas.addEventListener('touchstart', (e) => { if (isAnnotationModeActive) { e.preventDefault(); } }, { passive: false }); canvas.addEventListener('touchmove', (e) => { if (isAnnotationModeActive && isDrawing) { e.preventDefault(); } }, { passive: false }); // Handle window blur - abort any in-progress stroke window.addEventListener('blur', handleWindowBlur); // Handle visibility change (tab switch) document.addEventListener('visibilitychange', handleVisibilityChange); } /** * Handles window blur - abort in-progress operations. */ function handleWindowBlur() { if (isDrawing) { abortCurrentStroke(); } } /** * Handles visibility change (tab switch). */ function handleVisibilityChange() { if (document.hidden && isDrawing) { abortCurrentStroke(); } } /** * Handles lost pointer capture event. * @param {PointerEvent} e */ function handleLostPointerCapture(e) { if (isDrawing) { console.log(LOG_PREFIX, 'Pointer capture lost, completing stroke'); // Complete the stroke gracefully instead of aborting finalizeCurrentStroke(); } } /** * Handles pointer cancel event (system interruption). * @param {PointerEvent} e */ function handlePointerCancel(e) { console.log(LOG_PREFIX, 'Pointer cancelled'); abortCurrentStroke(); eraserCursorPos = null; scheduleRender(renderAnnotations); } /** * Aborts the current stroke without saving. */ function abortCurrentStroke() { if (!isDrawing) return; isDrawing = false; currentStroke = null; lastPoint = null; eraserActionPending = false; console.log(LOG_PREFIX, 'Stroke aborted'); scheduleRender(renderAnnotations); } /** * Finalizes the current stroke (complete gracefully). */ function finalizeCurrentStroke() { if (!isDrawing) return; isDrawing = false; if (currentTool === 'pen' && currentStroke && currentStroke.points.length >= 2) { strokes.push(currentStroke); invalidateCache(); recordState(strokes, 'draw'); updateToolbarState(); console.log(LOG_PREFIX, `Stroke finalized: ${currentStroke.points.length} points`); } else if (currentTool === 'eraser') { commitEraserAction(); } currentStroke = null; lastPoint = null; scheduleRender(renderAnnotations); } /** * Handles pointer leaving the canvas. * @param {PointerEvent} e */ function handlePointerLeave(e) { handlePointerUp(e); eraserCursorPos = null; renderAnnotations(); } /** * Handles pointer down - start drawing or erasing. * @param {PointerEvent} e */ function handlePointerDown(e) { // Only act with primary button (left click) in annotation mode if (!isAnnotationModeActive || e.button !== 0) return; isDrawing = true; // Get position relative to canvas const rect = e.target.getBoundingClientRect(); const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; // Convert to canvas coordinates const canvasPos = screenToCanvas(screenX, screenY); if (currentTool === 'pen') { // Create new stroke currentStroke = { id: generateStrokeId(), points: [{ x: canvasPos.x, y: canvasPos.y }], color: penSettings.color, size: penSettings.size, opacity: penSettings.opacity }; lastPoint = canvasPos; } else if (currentTool === 'eraser') { // Perform eraser action at this point performErase(canvasPos.x, canvasPos.y); lastPoint = canvasPos; } // Capture pointer for reliable tracking e.target.setPointerCapture(e.pointerId); e.preventDefault(); } // RAF batching state for pointer move let pendingPointerRender = false; /** * Processes a single pointer position for drawing. * @param {number} screenX * @param {number} screenY * @param {DOMRect} rect * @returns {boolean} Whether a point was added */ function processPointerPosition(screenX, screenY, rect) { const canvasPos = screenToCanvas(screenX, screenY); if (currentTool === 'pen') { if (!currentStroke || !lastPoint) return false; const dx = canvasPos.x - lastPoint.x; const dy = canvasPos.y - lastPoint.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance >= MIN_POINT_DISTANCE) { currentStroke.points.push({ x: canvasPos.x, y: canvasPos.y }); lastPoint = canvasPos; return true; } } else if (currentTool === 'eraser') { if (!lastPoint) return false; const dx = canvasPos.x - lastPoint.x; const dy = canvasPos.y - lastPoint.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance >= MIN_POINT_DISTANCE) { performErase(canvasPos.x, canvasPos.y); lastPoint = canvasPos; return true; } } return false; } /** * Handles pointer move - continue drawing or erasing, and update eraser cursor. * Uses RAF batching to coalesce multiple pointer events per frame. * Supports getCoalescedEvents() for smoother fast strokes. * @param {PointerEvent} e */ function handlePointerMove(e) { const rect = e.target.getBoundingClientRect(); // Get the final position for eraser cursor const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; const canvasPos = screenToCanvas(screenX, screenY); // Update eraser cursor position for visualization (even when not drawing) if (isAnnotationModeActive && currentTool === 'eraser') { eraserCursorPos = { x: canvasPos.x, y: canvasPos.y }; if (!isDrawing) { scheduleRender(renderAnnotations); } } if (!isDrawing) return; // Use coalesced events for smoother fast strokes (with browser fallback) // This captures all intermediate points that were batched by the browser let pointsAdded = false; if (currentTool === 'pen') { // Use browser-compatible helper that falls back gracefully const coalescedEvents = getCoalescedEventsWithFallback(e); if (coalescedEvents.length > 1) { // Process all coalesced events for smooth curves for (const ce of coalescedEvents) { const ceX = ce.clientX - rect.left; const ceY = ce.clientY - rect.top; if (processPointerPosition(ceX, ceY, rect)) { pointsAdded = true; } } } } // If no coalesced events (or eraser tool), process the main event if (!pointsAdded) { pointsAdded = processPointerPosition(screenX, screenY, rect); } // Update eraser cursor and request render if (pointsAdded) { if (currentTool === 'pen') { scheduleRender(renderWithCurrentStroke); } // Eraser already schedules render in performErase } e.preventDefault(); } /** * Handles pointer up - finish drawing or erasing. * @param {PointerEvent} e */ function handlePointerUp(e) { if (!isDrawing) return; isDrawing = false; if (currentTool === 'pen' && currentStroke && currentStroke.points.length >= 2) { // Add completed stroke to array strokes.push(currentStroke); invalidateCache(); // New stroke added, invalidate cache console.log(LOG_PREFIX, `Stroke added: ${currentStroke.points.length} points`); // Record state for undo recordState(strokes, 'draw'); updateToolbarState(); } else if (currentTool === 'eraser') { // Commit all eraser changes as one undo state commitEraserAction(); } currentStroke = null; lastPoint = null; // Release pointer capture if (e.target.hasPointerCapture?.(e.pointerId)) { e.target.releasePointerCapture(e.pointerId); } // Final render renderAnnotations(); } /** * Performs eraser action at the given canvas coordinates. * Removes any strokes that intersect with the eraser circle. * @param {number} canvasX - X position in canvas coordinates * @param {number} canvasY - Y position in canvas coordinates */ // Track eraser state for batching multiple erases into one undo state let eraserActionPending = false; /** * Performs eraser action at the given canvas coordinates. * Removes any strokes that intersect with the eraser circle. * @param {number} canvasX - X position in canvas coordinates * @param {number} canvasY - Y position in canvas coordinates */ function performErase(canvasX, canvasY) { const hitIds = findHitStrokes(canvasX, canvasY, eraserSettings.size, strokes); if (hitIds.length > 0) { // Invalidate path cache for removed strokes for (const stroke of strokes) { if (hitIds.includes(stroke.id)) { invalidateStrokePath(stroke); } } strokes = removeStrokesById(strokes, hitIds); eraserActionPending = true; // Mark that we have pending changes invalidateCache(); // Invalidate stroke layer cache console.log(LOG_PREFIX, `Erased ${hitIds.length} stroke(s)`); scheduleRender(renderAnnotations); } } /** * Commits eraser action to history. * Called when eraser pointer up to batch all erases in one undo state. */ function commitEraserAction() { if (eraserActionPending) { recordState(strokes, 'erase'); eraserActionPending = false; updateToolbarState(); } } /** * Generates a unique stroke ID. * @returns {string} */ function generateStrokeId() { return `stroke_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Renders all strokes plus the current in-progress stroke. * Optimized for real-time drawing feedback using stroke cache. */ function renderWithCurrentStroke() { const ctx = getContext(); const canvas = getCanvas(); if (!ctx || !canvas) return; const startTime = startRenderTiming(); const dpr = window.devicePixelRatio || 1; const { width, height } = getCanvasDimensions(); // Initialize stroke cache if needed if (useStrokeCache) { initStrokeCache(canvas.width, canvas.height); } // Clear canvas clearCanvas(); // Get current transform const transform = getTransform(); // Apply LiteGraph transform ctx.setTransform( transform.scale * dpr, 0, 0, transform.scale * dpr, transform.offsetX * transform.scale * dpr, transform.offsetY * transform.scale * dpr ); // Draw all completed strokes using cache for performance if (useStrokeCache && strokes.length > 10) { drawStrokesWithCache(ctx, strokes, transform, dpr); // Restore transform after cache composite ctx.setTransform( transform.scale * dpr, 0, 0, transform.scale * dpr, transform.offsetX * transform.scale * dpr, transform.offsetY * transform.scale * dpr ); } else { for (const stroke of strokes) { drawStrokeCached(ctx, stroke, false); } } // Draw current stroke in progress (always direct, not cached) if (currentStroke && currentStroke.points.length >= 2) { drawStrokeSmooth(ctx, currentStroke); } // Draw UI elements ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (isAnnotationModeActive) { drawModeIndicator(ctx, width, height); } endRenderTiming(startTime); } // ============================================================================ // Mode Toggle // ============================================================================ /** * Toggles annotation mode on/off. */ function toggleAnnotationMode() { isAnnotationModeActive = !isAnnotationModeActive; if (isAnnotationModeActive) { // Enable annotation mode showOverlay(); setPointerEventsMode('auto'); updateCursor(); console.log(LOG_PREFIX, `Annotation mode ON - ${currentTool} tool active`); } else { // Disable annotation mode - pass through to canvas showOverlay(); // Keep visible to show strokes setPointerEventsMode('none'); setCursor('default'); console.log(LOG_PREFIX, 'Annotation mode OFF - node interactions enabled'); } renderAnnotations(); updateToolbarState(); } /** * Performs undo action. * @returns {boolean} True if undo was performed */ function performUndo() { if (!canUndo()) { console.log(LOG_PREFIX, 'Nothing to undo'); return false; } historyUndo(); // This will trigger onRestore callback console.log(LOG_PREFIX, 'Undo performed'); return true; } /** * Performs redo action. * @returns {boolean} True if redo was performed */ function performRedo() { if (!canRedo()) { console.log(LOG_PREFIX, 'Nothing to redo'); return false; } historyRedo(); // This will trigger onRestore callback console.log(LOG_PREFIX, 'Redo performed'); return true; } /** * Clears all strokes with undo support. */ function clearAllStrokes() { if (strokes.length === 0) { console.log(LOG_PREFIX, 'No strokes to clear'); return; } strokes = []; invalidateCache(); // All strokes cleared, invalidate cache recordState(strokes, 'clear'); renderAnnotations(); updateToolbarState(); console.log(LOG_PREFIX, 'All strokes cleared'); } /** * Sets up the keyboard shortcut for toggling annotation mode and tools. */ function setupKeyboardShortcut() { document.addEventListener('keydown', (e) => { // Don't intercept when typing in inputs const activeEl = document.activeElement; const isTyping = activeEl && ( activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable ); if (isTyping) return; // 'A' key toggles annotation mode if (e.key === 'a' || e.key === 'A') { if (!e.ctrlKey && !e.metaKey && !e.altKey) { toggleAnnotationMode(); e.preventDefault(); e.stopPropagation(); } } // 'T' key toggles toolbar collapse if (e.key === 't' || e.key === 'T') { if (!e.ctrlKey && !e.metaKey && !e.altKey) { toggleToolbarCollapse(); e.preventDefault(); e.stopPropagation(); } } // Tool switching (only when annotation mode is active) if (isAnnotationModeActive) { // 'P' key switches to pen tool if (e.key === 'p' || e.key === 'P') { if (!e.ctrlKey && !e.metaKey && !e.altKey) { setTool('pen'); e.preventDefault(); e.stopPropagation(); } } // 'E' key switches to eraser tool if (e.key === 'e' || e.key === 'E') { if (!e.ctrlKey && !e.metaKey && !e.altKey) { setTool('eraser'); e.preventDefault(); e.stopPropagation(); } } // Ctrl+Z / Cmd+Z for undo if (e.key === 'z' || e.key === 'Z') { if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { performUndo(); e.preventDefault(); e.stopPropagation(); } } // Ctrl+Shift+Z / Cmd+Shift+Z for redo if (e.key === 'z' || e.key === 'Z') { if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey) { performRedo(); e.preventDefault(); e.stopPropagation(); } } // Ctrl+Y / Cmd+Y for redo (alternative) if (e.key === 'y' || e.key === 'Y') { if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { performRedo(); e.preventDefault(); e.stopPropagation(); } } } // Escape exits annotation mode if (e.key === 'Escape' && isAnnotationModeActive) { toggleAnnotationMode(); e.preventDefault(); e.stopPropagation(); } }, true); // Capture phase for priority } /** * Sets the current tool and updates cursor. * @param {'pen' | 'eraser'} tool */ function setTool(tool) { if (currentTool === tool) return; currentTool = tool; updateCursor(); renderAnnotations(); updateToolbarState(); console.log(LOG_PREFIX, `Tool switched to: ${tool}`); } /** * Updates the cursor based on current tool. */ function updateCursor() { if (!isAnnotationModeActive) { setCursor('default'); return; } if (currentTool === 'pen') { setCursor('crosshair'); } else if (currentTool === 'eraser') { // Use a custom cursor or a circle cursor setCursor('cell'); } } // ============================================================================ // Cleanup // ============================================================================ /** * Stops the transform tracker (for debugging). */ function stopTracker() { if (transformTrackerCleanup) { transformTrackerCleanup(); transformTrackerCleanup = null; console.log(LOG_PREFIX, 'Transform tracker stopped'); } } /** * Cleans up resources when extension is unloaded. */ function cleanup() { stopTracker(); destroyToolbar(); destroyOverlay(); } // ============================================================================ // CSS Loading // ============================================================================ /** * Loads the extension's stylesheet. */ function loadStyles() { const link = document.createElement('link'); link.rel = 'stylesheet'; link.type = 'text/css'; // Get base path from current script let basePath = '/extensions/comfyui-annotations'; const currentScript = document.currentScript; if (currentScript && currentScript.src) { basePath = currentScript.src.substring(0, currentScript.src.lastIndexOf('/')); } link.href = `${basePath}/style.css`; document.head.appendChild(link); console.log(LOG_PREFIX, 'Styles loaded from', link.href); } // ============================================================================ // Extension Registration // ============================================================================ app.registerExtension({ name: EXTENSION_NAME, async setup() { console.log(LOG_PREFIX, 'Setting up extension...'); // Check browser compatibility first const { supported, missing } = checkRequiredFeatures(); if (!supported) { console.error(LOG_PREFIX, 'Browser not supported. Missing features:', missing.join(', ')); return; } // Log browser compatibility status and any warnings warnIfUnsupported(); // Load CSS loadStyles(); // Initialize overlay when canvas is ready waitForCanvasAndInitialize(); } }); // ============================================================================ // Debug API (exposed on window for testing) // ============================================================================ window.comfyAnnotations = { // Version info version: VERSION, // Mode & rendering toggle: toggleAnnotationMode, isActive: () => isAnnotationModeActive, getTransform, screenToCanvas, canvasToScreen, render: renderAnnotations, stopTracker, cleanup, getCanvas, getContext, // Tool management getTool: () => currentTool, setTool, // Stroke management getStrokes: () => strokes, clearStrokes: clearAllStrokes, // Now with undo support // Pen settings getPenSettings: () => ({ ...penSettings }), setColor: (color) => { penSettings.color = color; }, setSize: (size) => { penSettings.size = Math.max(1, Math.min(50, size)); }, setOpacity: (opacity) => { penSettings.opacity = Math.max(0.1, Math.min(1, opacity)); }, // Eraser settings getEraserSettings: () => ({ ...eraserSettings }), setEraserSize: (size) => { eraserSettings.size = Math.max(5, Math.min(100, size)); renderAnnotations(); }, // History (undo/redo) undo: performUndo, redo: performRedo, canUndo, canRedo, getHistoryState, setMaxHistory: (max) => { setMaxHistory(max); console.log(LOG_PREFIX, `Max history set to: ${getMaxHistory()}`); }, getMaxHistory, // Toolbar toolbar: { toggle: toggleToolbarCollapse, collapse: () => setToolbarCollapsed(true), expand: () => setToolbarCollapsed(false), isCollapsed: isToolbarCollapsed, show: showToolbar, hide: hideToolbar }, // Debug & coordinate testing debug: { enable: () => { setDebugVisualizerEnabled(true, renderAnnotations); }, disable: () => { setDebugVisualizerEnabled(false, renderAnnotations); }, toggle: () => { setDebugVisualizerEnabled(!isDebugVisualizerEnabled(), renderAnnotations); }, isEnabled: isDebugVisualizerEnabled, runTests: runCoordinateTests }, // Persistence persistence: { getSerializedData: () => serializeAnnotations(strokes), getExtensionKey }, // Performance performance: { getMetrics: getPerformanceMetrics, resetMetrics: resetPerformanceMetrics, runBenchmark: (options) => { const ctx = getContext(); const transform = getTransform(); const dpr = window.devicePixelRatio || 1; return runBenchmark(ctx, transform, dpr, options); }, setUseCache: (enabled) => { useStrokeCache = enabled; invalidateCache(); renderAnnotations(); console.log(LOG_PREFIX, `Stroke cache ${enabled ? 'enabled' : 'disabled'}`); }, isUsingCache: () => useStrokeCache, invalidateCache }, // Browser compatibility browser: { getFeatures, hasFeature, checkSupport: checkRequiredFeatures, generateReport: generateCompatibilityReport, printReport: () => console.log(generateCompatibilityReport()) } }; console.log(LOG_PREFIX, `Extension v${VERSION} loaded. Debug API: window.comfyAnnotations`);