Files
2026-01-18 00:13:02 +09:00

1364 lines
40 KiB
JavaScript

/**
* 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`);