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

779 lines
24 KiB
JavaScript

/**
* 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 `
<div class="toolbar-header">
<span class="toolbar-title">Annotations</span>
<button class="toolbar-toggle" title="Toggle toolbar (T)">
<span class="toggle-icon">▶</span>
</button>
<button class="mode-toggle" title="Toggle annotation mode (A)">
<span class="mode-indicator">OFF</span>
</button>
</div>
<div class="toolbar-content">
<div class="toolbar-section">
<div class="section-label">Tools</div>
<div class="tool-buttons">
<button class="tool-btn" data-tool="pen" title="Pen tool (P)">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z"/>
</svg>
</button>
<button class="tool-btn" data-tool="eraser" title="Eraser tool (E)">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M16.24,3.56L21.19,8.5C21.97,9.29 21.97,10.55 21.19,11.34L12,20.53C10.44,22.09 7.91,22.09 6.34,20.53L2.81,17C2.03,16.21 2.03,14.95 2.81,14.16L13.41,3.56C14.2,2.78 15.46,2.78 16.24,3.56M4.22,15.58L7.76,19.11C8.54,19.9 9.8,19.9 10.59,19.11L14.12,15.58L9.17,10.63L4.22,15.58Z"/>
</svg>
</button>
</div>
</div>
<div class="toolbar-section pen-settings">
<div class="section-label">Pen</div>
<div class="setting-row">
<label>Size</label>
<input type="range" class="pen-size-slider" min="1" max="50" value="4">
<span class="pen-size-value">4</span>
</div>
<div class="setting-row">
<label>Opacity</label>
<input type="range" class="pen-opacity-slider" min="10" max="100" value="100">
<span class="pen-opacity-value">100%</span>
</div>
<div class="setting-row color-row">
<label>Color</label>
<div class="color-picker-container">
<input type="color" class="color-input" value="#FF5555">
<div class="color-presets">
${PRESET_COLORS.map(color =>
`<button class="color-preset" data-color="${color}" style="background-color: ${color}" title="${color}"></button>`
).join('')}
</div>
</div>
</div>
</div>
<div class="toolbar-section eraser-settings" style="display: none;">
<div class="section-label">Eraser</div>
<div class="setting-row">
<label>Size</label>
<input type="range" class="eraser-size-slider" min="5" max="100" value="20">
<span class="eraser-size-value">20</span>
</div>
</div>
<div class="toolbar-section">
<div class="section-label">Actions</div>
<div class="action-buttons">
<button class="action-btn undo-btn" title="Undo (Ctrl+Z)">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M12.5,8C9.85,8 7.45,9 5.6,10.6L2,7V16H11L7.38,12.38C8.77,11.22 10.54,10.5 12.5,10.5C16.04,10.5 19.05,12.81 20.1,16L22.47,15.22C21.08,11.03 17.15,8 12.5,8Z"/>
</svg>
<span>Undo</span>
</button>
<button class="action-btn redo-btn" title="Redo (Ctrl+Shift+Z)">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M18.4,10.6C16.55,9 14.15,8 11.5,8C6.85,8 2.92,11.03 1.54,15.22L3.9,16C4.95,12.81 7.95,10.5 11.5,10.5C13.45,10.5 15.23,11.22 16.62,12.38L13,16H22V7L18.4,10.6Z"/>
</svg>
<span>Redo</span>
</button>
<button class="action-btn clear-btn" title="Clear all strokes">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"/>
</svg>
<span>Clear</span>
</button>
</div>
</div>
</div>
`;
}
/**
* 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;
}