/** * Toolbar UI Module * * Provides a minimal, collapsible UI panel for annotation tools. * Features: * - Toggle annotation mode on/off * - Tool selector: pen / eraser * - Size and opacity sliders * - Color picker * - Undo, redo, clear buttons * * The toolbar is designed to be non-intrusive and can be collapsed * to minimize clutter in the node editor. */ // ============================================================================ // Constants // ============================================================================ const TOOLBAR_ID = 'annotation-toolbar'; const LOG_PREFIX = '[Toolbar]'; // Default colors for the color picker const PRESET_COLORS = [ '#FF5555', // Red (default) '#55FF55', // Green '#5555FF', // Blue '#FFFF55', // Yellow '#FF55FF', // Magenta '#55FFFF', // Cyan '#FFFFFF', // White '#000000' // Black ]; // ============================================================================ // State // ============================================================================ let toolbarElement = null; let isCollapsed = false; let callbacks = { onToggleMode: null, onSetTool: null, onSetColor: null, onSetSize: null, onSetOpacity: null, onSetEraserSize: null, onUndo: null, onRedo: null, onClear: null, getState: null // Function to get current state }; // ============================================================================ // Toolbar Creation // ============================================================================ /** * Creates the toolbar UI and appends it to the container. * @param {HTMLElement} container - Parent element for the toolbar * @param {Object} options - Callback functions and initial state */ export function createToolbar(container, options = {}) { if (toolbarElement) { console.warn(LOG_PREFIX, 'Toolbar already exists'); return toolbarElement; } // Store callbacks callbacks = { ...callbacks, ...options }; // Create main toolbar container toolbarElement = document.createElement('div'); toolbarElement.id = TOOLBAR_ID; toolbarElement.className = 'annotation-toolbar'; // Build toolbar HTML toolbarElement.innerHTML = buildToolbarHTML(); // Apply styles applyToolbarStyles(); // Append to container container.appendChild(toolbarElement); // Setup event listeners setupEventListeners(); // Initial state update updateToolbarState(); console.log(LOG_PREFIX, 'Toolbar created'); return toolbarElement; } /** * Builds the toolbar HTML structure. * @returns {string} HTML string */ function buildToolbarHTML() { return `
`; } /** * Applies CSS styles for the toolbar. */ function applyToolbarStyles() { const styleId = 'annotation-toolbar-styles'; if (document.getElementById(styleId)) return; const style = document.createElement('style'); style.id = styleId; style.textContent = ` .annotation-toolbar { position: absolute; top: 100px; right: 10px; width: 200px; background: rgba(30, 30, 30, 0.95); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 12px; color: #fff; z-index: 1000; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); user-select: none; transition: width 0.2s ease; } .annotation-toolbar.collapsed { width: auto; } .annotation-toolbar.collapsed .toolbar-content { display: none; } .annotation-toolbar.collapsed .toolbar-title { display: none; } .annotation-toolbar.collapsed .toggle-icon { transform: rotate(180deg); } .toolbar-header { display: flex; align-items: center; padding: 8px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); gap: 8px; } .annotation-toolbar.collapsed .toolbar-header { border-bottom: none; padding: 6px; } .toolbar-toggle { background: none; border: none; color: #888; cursor: pointer; padding: 4px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: background 0.15s; } .toolbar-toggle:hover { background: rgba(255, 255, 255, 0.1); color: #fff; } .toggle-icon { font-size: 10px; transition: transform 0.2s; } .toolbar-title { flex: 1; font-weight: 600; color: #ccc; } .mode-toggle { background: rgba(255, 85, 85, 0.2); border: 1px solid rgba(255, 85, 85, 0.5); color: #FF5555; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 10px; font-weight: 600; transition: all 0.15s; } .mode-toggle:hover { background: rgba(255, 85, 85, 0.3); } .mode-toggle.active { background: rgba(85, 255, 85, 0.2); border-color: rgba(85, 255, 85, 0.5); color: #55FF55; } .toolbar-content { padding: 8px; } .toolbar-section { margin-bottom: 12px; } .toolbar-section:last-child { margin-bottom: 0; } .section-label { font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; } .tool-buttons { display: flex; gap: 4px; } .tool-btn { flex: 1; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: #888; padding: 8px; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; } .tool-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; } .tool-btn.active { background: rgba(255, 85, 85, 0.2); border-color: rgba(255, 85, 85, 0.5); color: #FF5555; } .tool-btn.active[data-tool="eraser"] { background: rgba(85, 170, 255, 0.2); border-color: rgba(85, 170, 255, 0.5); color: #55AAFF; } .setting-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .setting-row:last-child { margin-bottom: 0; } .setting-row label { width: 50px; color: #888; font-size: 11px; } .setting-row input[type="range"] { flex: 1; height: 4px; -webkit-appearance: none; appearance: none; background: rgba(255, 255, 255, 0.1); border-radius: 2px; cursor: pointer; } .setting-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 12px; height: 12px; background: #fff; border-radius: 50%; cursor: pointer; } .setting-row input[type="range"]::-moz-range-thumb { width: 12px; height: 12px; background: #fff; border-radius: 50%; cursor: pointer; border: none; } .setting-row span { width: 35px; text-align: right; color: #888; font-size: 11px; } .color-row { flex-wrap: wrap; } .color-picker-container { flex: 1; display: flex; flex-direction: column; gap: 6px; } .color-input { width: 100%; height: 24px; padding: 0; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; cursor: pointer; background: transparent; } .color-input::-webkit-color-swatch-wrapper { padding: 2px; } .color-input::-webkit-color-swatch { border-radius: 2px; border: none; } .color-presets { display: flex; gap: 4px; flex-wrap: wrap; } .color-preset { width: 18px; height: 18px; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 3px; cursor: pointer; padding: 0; transition: transform 0.1s, border-color 0.1s; } .color-preset:hover { transform: scale(1.1); border-color: rgba(255, 255, 255, 0.5); } .color-preset.active { border-color: #fff; border-width: 2px; } .action-buttons { display: flex; flex-direction: column; gap: 4px; } .action-btn { display: flex; align-items: center; gap: 6px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: #888; padding: 6px 8px; border-radius: 4px; cursor: pointer; transition: all 0.15s; font-size: 11px; } .action-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); color: #fff; } .action-btn:disabled { opacity: 0.4; cursor: not-allowed; } .action-btn.clear-btn:hover:not(:disabled) { background: rgba(255, 85, 85, 0.2); border-color: rgba(255, 85, 85, 0.5); color: #FF5555; } /* Mode indicator in collapsed state */ .annotation-toolbar.collapsed .mode-toggle { padding: 6px 10px; } `; document.head.appendChild(style); } // ============================================================================ // Event Listeners // ============================================================================ /** * Sets up all event listeners for the toolbar. */ function setupEventListeners() { if (!toolbarElement) return; // Toggle collapse const toggleBtn = toolbarElement.querySelector('.toolbar-toggle'); toggleBtn?.addEventListener('click', toggleCollapse); // Mode toggle const modeToggle = toolbarElement.querySelector('.mode-toggle'); modeToggle?.addEventListener('click', () => { callbacks.onToggleMode?.(); }); // Tool buttons const toolButtons = toolbarElement.querySelectorAll('.tool-btn'); toolButtons.forEach(btn => { btn.addEventListener('click', () => { const tool = btn.dataset.tool; callbacks.onSetTool?.(tool); }); }); // Pen size slider const penSizeSlider = toolbarElement.querySelector('.pen-size-slider'); penSizeSlider?.addEventListener('input', (e) => { const value = parseInt(e.target.value); callbacks.onSetSize?.(value); updateSliderDisplay('.pen-size-value', value); }); // Pen opacity slider const penOpacitySlider = toolbarElement.querySelector('.pen-opacity-slider'); penOpacitySlider?.addEventListener('input', (e) => { const value = parseInt(e.target.value) / 100; callbacks.onSetOpacity?.(value); updateSliderDisplay('.pen-opacity-value', `${e.target.value}%`); }); // Eraser size slider const eraserSizeSlider = toolbarElement.querySelector('.eraser-size-slider'); eraserSizeSlider?.addEventListener('input', (e) => { const value = parseInt(e.target.value); callbacks.onSetEraserSize?.(value); updateSliderDisplay('.eraser-size-value', value); }); // Color picker const colorInput = toolbarElement.querySelector('.color-input'); colorInput?.addEventListener('input', (e) => { callbacks.onSetColor?.(e.target.value); updateColorPresetSelection(e.target.value); }); // Color presets const colorPresets = toolbarElement.querySelectorAll('.color-preset'); colorPresets.forEach(preset => { preset.addEventListener('click', () => { const color = preset.dataset.color; callbacks.onSetColor?.(color); if (colorInput) colorInput.value = color; updateColorPresetSelection(color); }); }); // Action buttons const undoBtn = toolbarElement.querySelector('.undo-btn'); undoBtn?.addEventListener('click', () => callbacks.onUndo?.()); const redoBtn = toolbarElement.querySelector('.redo-btn'); redoBtn?.addEventListener('click', () => callbacks.onRedo?.()); const clearBtn = toolbarElement.querySelector('.clear-btn'); clearBtn?.addEventListener('click', () => callbacks.onClear?.()); } /** * Updates a slider display value. */ function updateSliderDisplay(selector, value) { const display = toolbarElement?.querySelector(selector); if (display) display.textContent = value; } /** * Updates color preset selection. */ function updateColorPresetSelection(color) { const presets = toolbarElement?.querySelectorAll('.color-preset'); presets?.forEach(preset => { preset.classList.toggle('active', preset.dataset.color.toLowerCase() === color.toLowerCase()); }); } // ============================================================================ // State Updates // ============================================================================ /** * Updates the toolbar UI to reflect current state. * Call this after any state change. */ export function updateToolbarState() { if (!toolbarElement || !callbacks.getState) return; const state = callbacks.getState(); // Update mode toggle const modeToggle = toolbarElement.querySelector('.mode-toggle'); const modeIndicator = modeToggle?.querySelector('.mode-indicator'); if (modeToggle && modeIndicator) { modeToggle.classList.toggle('active', state.isActive); modeIndicator.textContent = state.isActive ? 'ON' : 'OFF'; } // Update tool buttons const toolButtons = toolbarElement.querySelectorAll('.tool-btn'); toolButtons.forEach(btn => { btn.classList.toggle('active', btn.dataset.tool === state.currentTool); }); // Show/hide tool-specific settings const penSettings = toolbarElement.querySelector('.pen-settings'); const eraserSettings = toolbarElement.querySelector('.eraser-settings'); if (penSettings && eraserSettings) { penSettings.style.display = state.currentTool === 'pen' ? 'block' : 'none'; eraserSettings.style.display = state.currentTool === 'eraser' ? 'block' : 'none'; } // Update pen settings const penSizeSlider = toolbarElement.querySelector('.pen-size-slider'); if (penSizeSlider && state.penSettings) { penSizeSlider.value = state.penSettings.size; updateSliderDisplay('.pen-size-value', state.penSettings.size); } const penOpacitySlider = toolbarElement.querySelector('.pen-opacity-slider'); if (penOpacitySlider && state.penSettings) { penOpacitySlider.value = state.penSettings.opacity * 100; updateSliderDisplay('.pen-opacity-value', `${Math.round(state.penSettings.opacity * 100)}%`); } const colorInput = toolbarElement.querySelector('.color-input'); if (colorInput && state.penSettings) { colorInput.value = state.penSettings.color; updateColorPresetSelection(state.penSettings.color); } // Update eraser settings const eraserSizeSlider = toolbarElement.querySelector('.eraser-size-slider'); if (eraserSizeSlider && state.eraserSettings) { eraserSizeSlider.value = state.eraserSettings.size; updateSliderDisplay('.eraser-size-value', state.eraserSettings.size); } // Update action buttons const undoBtn = toolbarElement.querySelector('.undo-btn'); const redoBtn = toolbarElement.querySelector('.redo-btn'); const clearBtn = toolbarElement.querySelector('.clear-btn'); if (undoBtn) undoBtn.disabled = !state.canUndo; if (redoBtn) redoBtn.disabled = !state.canRedo; if (clearBtn) clearBtn.disabled = state.strokeCount === 0; } // ============================================================================ // Collapse/Expand // ============================================================================ /** * Toggles the collapsed state of the toolbar. */ export function toggleCollapse() { if (!toolbarElement) return; isCollapsed = !isCollapsed; toolbarElement.classList.toggle('collapsed', isCollapsed); console.log(LOG_PREFIX, isCollapsed ? 'Collapsed' : 'Expanded'); } /** * Sets the collapsed state. * @param {boolean} collapsed */ export function setCollapsed(collapsed) { if (!toolbarElement) return; isCollapsed = collapsed; toolbarElement.classList.toggle('collapsed', isCollapsed); } /** * Returns whether the toolbar is collapsed. * @returns {boolean} */ export function isToolbarCollapsed() { return isCollapsed; } // ============================================================================ // Visibility // ============================================================================ /** * Shows the toolbar. */ export function showToolbar() { if (toolbarElement) { toolbarElement.style.display = 'block'; } } /** * Hides the toolbar. */ export function hideToolbar() { if (toolbarElement) { toolbarElement.style.display = 'none'; } } /** * Returns whether the toolbar is visible. * @returns {boolean} */ export function isToolbarVisible() { return toolbarElement?.style.display !== 'none'; } // ============================================================================ // Cleanup // ============================================================================ /** * Destroys the toolbar and cleans up resources. */ export function destroyToolbar() { if (toolbarElement && toolbarElement.parentElement) { toolbarElement.parentElement.removeChild(toolbarElement); } toolbarElement = null; isCollapsed = false; callbacks = { onToggleMode: null, onSetTool: null, onSetColor: null, onSetSize: null, onSetOpacity: null, onSetEraserSize: null, onUndo: null, onRedo: null, onClear: null, getState: null }; console.log(LOG_PREFIX, 'Toolbar destroyed'); } /** * Returns the toolbar element. * @returns {HTMLElement | null} */ export function getToolbarElement() { return toolbarElement; }