From a73a3dcf961080c272af677f8126651350aedcdb Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Wed, 2 Jul 2025 08:09:49 +0200 Subject: [PATCH] Add layers panel UI and improve layer management Introduces a new CanvasLayersPanel component for managing layers visually, including selection, renaming, reordering via drag-and-drop, and deletion. Integrates the panel into the main Canvas and CanvasView, synchronizes selection and state changes, and adds logic for duplicating layers and debounced state saving. Moves IndexedDB state saving to a Web Worker for better performance. Also sets default logger level to DEBUG for improved diagnostics. --- js/Canvas.js | 123 ++++++- js/CanvasInteractions.js | 278 +++++----------- js/CanvasLayersPanel.js | 699 +++++++++++++++++++++++++++++++++++++++ js/CanvasState.js | 84 +++-- js/CanvasView.js | 24 +- js/state-saver.worker.js | 93 ++++++ js/utils/LoggerUtils.js | 2 +- 7 files changed, 1065 insertions(+), 238 deletions(-) create mode 100644 js/CanvasLayersPanel.js create mode 100644 js/state-saver.worker.js diff --git a/js/Canvas.js b/js/Canvas.js index b282b90..c14af8b 100644 --- a/js/Canvas.js +++ b/js/Canvas.js @@ -5,11 +5,13 @@ import {MaskTool} from "./MaskTool.js"; import {CanvasState} from "./CanvasState.js"; import {CanvasInteractions} from "./CanvasInteractions.js"; import {CanvasLayers} from "./CanvasLayers.js"; +import {CanvasLayersPanel} from "./CanvasLayersPanel.js"; import {CanvasRenderer} from "./CanvasRenderer.js"; import {CanvasIO} from "./CanvasIO.js"; import {ImageReferenceManager} from "./ImageReferenceManager.js"; import {createModuleLogger} from "./utils/LoggerUtils.js"; import {mask_editor_showing, mask_editor_listen_for_cancel} from "./utils/mask_utils.js"; +import { debounce } from "./utils/CommonUtils.js"; const log = createModuleLogger('Canvas'); @@ -141,10 +143,14 @@ export class Canvas { _initializeModules(callbacks) { log.debug('Initializing Canvas modules...'); + // Stwórz opóźnioną wersję funkcji zapisu stanu + this.requestSaveState = debounce(this.saveState.bind(this), 500); + this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange}); this.canvasState = new CanvasState(this); this.canvasInteractions = new CanvasInteractions(this); this.canvasLayers = new CanvasLayers(this); + this.canvasLayersPanel = new CanvasLayersPanel(this); this.canvasRenderer = new CanvasRenderer(this); this.canvasIO = new CanvasIO(this); this.imageReferenceManager = new ImageReferenceManager(this); @@ -180,6 +186,11 @@ export class Canvas { } this.saveState(); this.render(); + + // Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + } } /** @@ -205,6 +216,12 @@ export class Canvas { this.incrementOperationCount(); this._notifyStateChange(); + // Powiadom panel warstw o zmianie stanu warstw + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + this.canvasLayersPanel.onSelectionChanged(); + } + log.debug('Undo completed, layers count:', this.layers.length); } @@ -221,6 +238,12 @@ export class Canvas { this.incrementOperationCount(); this._notifyStateChange(); + // Powiadom panel warstw o zmianie stanu warstw + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + this.canvasLayersPanel.onSelectionChanged(); + } + log.debug('Redo completed, layers count:', this.layers.length); } @@ -238,7 +261,14 @@ export class Canvas { * @param {string} addMode - Tryb dodawania */ async addLayer(image, layerProps = {}, addMode = 'default') { - return this.canvasLayers.addLayerWithImage(image, layerProps, addMode); + const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode); + + // Powiadom panel warstw o dodaniu nowej warstwy + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + } + + return result; } /** @@ -253,10 +283,16 @@ export class Canvas { this.saveState(); this.layers = this.layers.filter(l => !this.selectedLayers.includes(l)); - this.updateSelection([]); + + this.updateSelection([]); + this.render(); this.saveState(); + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + } + log.debug('Layers removed successfully, remaining layers:', this.layers.length); } else { log.debug('No layers selected for removal'); @@ -264,23 +300,104 @@ export class Canvas { } /** - * Aktualizuje zaznaczenie warstw + * Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu) + */ + duplicateSelectedLayers() { + if (this.selectedLayers.length === 0) return []; + + const newLayers = []; + const sortedLayers = [...this.selectedLayers].sort((a,b) => a.zIndex - b.zIndex); + + sortedLayers.forEach(layer => { + const newLayer = { + ...layer, + id: `layer_${+new Date()}_${Math.random().toString(36).substr(2, 9)}`, + zIndex: this.layers.length, // Nowa warstwa zawsze na wierzchu + }; + this.layers.push(newLayer); + newLayers.push(newLayer); + }); + + // Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego) + this.updateSelection(newLayers); + + // Powiadom panel o zmianie struktury, aby się przerysował + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + } + + log.info(`Duplicated ${newLayers.length} layers (in-memory).`); + return newLayers; + } + + /** + * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. + * To jest "jedyne źródło prawdy" o zmianie zaznaczenia. * @param {Array} newSelection - Nowa lista zaznaczonych warstw */ updateSelection(newSelection) { const previousSelection = this.selectedLayers.length; this.selectedLayers = newSelection || []; this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; + + // Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli + const hasChanged = previousSelection !== this.selectedLayers.length || + this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]); + + if (!hasChanged && previousSelection > 0) { + // return; // Zablokowane na razie, może powodować problemy + } log.debug('Selection updated', { previousCount: previousSelection, newCount: this.selectedLayers.length, selectedLayerIds: this.selectedLayers.map(l => l.id || 'unknown') }); + + // 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji + this.render(); + // 2. Powiadom inne części aplikacji (jeśli są) if (this.onSelectionChange) { this.onSelectionChange(); } + + // 3. Powiadom panel warstw, aby zaktualizował swój wygląd + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onSelectionChanged(); + } + } + + /** + * Logika aktualizacji zaznaczenia, wywoływana przez panel warstw. + */ + updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) { + let newSelection = [...this.selectedLayers]; + + if (isShiftPressed && this.canvasLayersPanel.lastSelectedIndex !== -1) { + const sortedLayers = [...this.layers].sort((a, b) => b.zIndex - a.zIndex); + const startIndex = Math.min(this.canvasLayersPanel.lastSelectedIndex, index); + const endIndex = Math.max(this.canvasLayersPanel.lastSelectedIndex, index); + + newSelection = []; + for (let i = startIndex; i <= endIndex; i++) { + if (sortedLayers[i]) { + newSelection.push(sortedLayers[i]); + } + } + } else if (isCtrlPressed) { + const layerIndex = newSelection.indexOf(layer); + if (layerIndex === -1) { + newSelection.push(layer); + } else { + newSelection.splice(layerIndex, 1); + } + } else { + newSelection = [layer]; + this.canvasLayersPanel.lastSelectedIndex = index; + } + + this.updateSelection(newSelection); } /** diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index f45c0c4..23b8ac0 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -19,6 +19,7 @@ export class CanvasInteractions { hasClonedInDrag: false, lastClickTime: 0, transformingLayer: null, + keyMovementInProgress: false, // Flaga do śledzenia ruchu klawiszami }; this.originalLayerPositions = new Map(); this.interaction.canvasResizeRect = null; @@ -69,44 +70,17 @@ export class CanvasInteractions { const worldCoords = this.canvas.getMouseWorldCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e); - if (this.canvas.maskTool.isActive) { - if (e.button === 1) { - this.startPanning(e); - } else { - this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords); - } - this.canvas.render(); - return; - } - - const currentTime = Date.now(); - if (e.shiftKey && e.ctrlKey) { - this.startCanvasMove(worldCoords); - this.canvas.render(); - return; - } - - if (currentTime - this.interaction.lastClickTime < 300) { - this.canvas.updateSelection([]); - this.canvas.selectedLayer = null; - this.resetInteractionState(); - this.canvas.render(); - return; - } - this.interaction.lastClickTime = currentTime; - - if (e.button === 2) { + if (e.button === 2) { // Obsługa prawego przycisku myszy const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); if (clickedLayerResult && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) { - e.preventDefault(); // Prevent context menu - this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x ,viewCoords.y); + e.preventDefault(); + this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y); return; } } - - if (e.shiftKey) { - this.startCanvasResize(worldCoords); - this.canvas.render(); + + if (e.button !== 0) { // Ignoruj inne przyciski niż lewy i prawy + this.startPanning(e); return; } @@ -118,32 +92,31 @@ export class CanvasInteractions { const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); if (clickedLayerResult) { - this.startLayerDrag(clickedLayerResult.layer, worldCoords); + // Zaznacz warstwę i przygotuj się do potencjalnego przeciągania + this.prepareForDrag(clickedLayerResult.layer, worldCoords); return; } - this.startPanning(e); - - this.canvas.render(); + // Jeśli nie kliknięto na nic, rozpocznij panoramowanie lub wyczyść zaznaczenie + this.startPanningOrClearSelection(e); } handleMouseMove(e) { const worldCoords = this.canvas.getMouseWorldCoordinates(e); - const viewCoords = this.canvas.getMouseViewCoordinates(e); - this.canvas.lastMousePosition = worldCoords; - - if (this.canvas.maskTool.isActive) { - if (this.interaction.mode === 'panning') { - this.panViewport(e); - return; + + // Sprawdź, czy rozpocząć przeciąganie + if (this.interaction.mode === 'potential-drag') { + const dx = worldCoords.x - this.interaction.dragStart.x; + const dy = worldCoords.y - this.interaction.dragStart.y; + if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli + this.interaction.mode = 'dragging'; + this.originalLayerPositions.clear(); + this.canvas.selectedLayers.forEach(l => { + this.originalLayerPositions.set(l, {x: l.x, y: l.y}); + }); } - this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords); - if (this.canvas.maskTool.isDrawing) { - this.canvas.render(); - } - return; } - + switch (this.interaction.mode) { case 'panning': this.panViewport(e); @@ -157,12 +130,7 @@ export class CanvasInteractions { case 'rotating': this.rotateLayerFromHandle(worldCoords, e.shiftKey); break; - case 'resizingCanvas': - this.updateCanvasResize(worldCoords); - break; - case 'movingCanvas': - this.updateCanvasMove(worldCoords); - break; + // ... inne tryby default: this.updateCursor(worldCoords); break; @@ -170,31 +138,17 @@ export class CanvasInteractions { } handleMouseUp(e) { - const viewCoords = this.canvas.getMouseViewCoordinates(e); - if (this.canvas.maskTool.isActive) { - if (this.interaction.mode === 'panning') { - this.resetInteractionState(); - } else { - this.canvas.maskTool.handleMouseUp(viewCoords); - } - this.canvas.render(); - return; - } + // Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja) + const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode); + const duplicatedInDrag = this.interaction.hasClonedInDrag; - const interactionEnded = this.interaction.mode !== 'none' && this.interaction.mode !== 'panning'; - - if (this.interaction.mode === 'resizingCanvas') { - this.finalizeCanvasResize(); - } else if (this.interaction.mode === 'movingCanvas') { - this.finalizeCanvasMove(); - } - this.resetInteractionState(); - this.canvas.render(); - - if (interactionEnded) { + if (stateChangingInteraction || duplicatedInDrag) { this.canvas.saveState(); this.canvas.canvasState.saveStateToDB(true); } + + this.resetInteractionState(); + this.canvas.render(); } handleMouseLeave(e) { @@ -307,112 +261,46 @@ export class CanvasInteractions { } this.canvas.render(); if (!this.canvas.maskTool.isActive) { - this.canvas.saveState(true); + this.canvas.requestSaveState(true); // Użyj opóźnionego zapisu } } handleKeyDown(e) { - if (this.canvas.maskTool.isActive) { - if (e.key === 'Control') this.interaction.isCtrlPressed = true; - if (e.key === 'Alt') { - this.interaction.isAltPressed = true; - e.preventDefault(); - } - - if (e.ctrlKey) { - if (e.key.toLowerCase() === 'z') { - e.preventDefault(); - e.stopPropagation(); - if (e.shiftKey) { - this.canvas.canvasState.redo(); - } else { - this.canvas.canvasState.undo(); - } - return; - } - if (e.key.toLowerCase() === 'y') { - e.preventDefault(); - e.stopPropagation(); - this.canvas.canvasState.redo(); - return; - } - } - return; - } - if (e.key === 'Control') this.interaction.isCtrlPressed = true; if (e.key === 'Alt') { this.interaction.isAltPressed = true; e.preventDefault(); } - if (e.ctrlKey) { - if (e.key.toLowerCase() === 'z') { - e.preventDefault(); - e.stopPropagation(); - if (e.shiftKey) { - this.canvas.canvasState.redo(); - } else { - this.canvas.canvasState.undo(); - } - return; - } - if (e.key.toLowerCase() === 'y') { - e.preventDefault(); - e.stopPropagation(); - this.canvas.canvasState.redo(); - return; - } - if (e.key.toLowerCase() === 'c') { - if (this.canvas.selectedLayers.length > 0) { - this.canvas.canvasLayers.copySelectedLayers(); - } - return; - } - if (e.key.toLowerCase() === 'v') { - - - return; - } - } - if (this.canvas.selectedLayer) { + const step = e.shiftKey ? 10 : 1; + let needsRender = false; + + const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; + if (movementKeys.includes(e.code)) { + e.preventDefault(); + e.stopPropagation(); + this.interaction.keyMovementInProgress = true; + + if (e.code === 'ArrowLeft') this.canvas.selectedLayers.forEach(l => l.x -= step); + if (e.code === 'ArrowRight') this.canvas.selectedLayers.forEach(l => l.x += step); + if (e.code === 'ArrowUp') this.canvas.selectedLayers.forEach(l => l.y -= step); + if (e.code === 'ArrowDown') this.canvas.selectedLayers.forEach(l => l.y += step); + if (e.code === 'BracketLeft') this.canvas.selectedLayers.forEach(l => l.rotation -= step); + if (e.code === 'BracketRight') this.canvas.selectedLayers.forEach(l => l.rotation += step); + + needsRender = true; + } + if (e.key === 'Delete') { e.preventDefault(); e.stopPropagation(); - this.canvas.saveState(); - this.canvas.layers = this.canvas.layers.filter(l => !this.canvas.selectedLayers.includes(l)); - this.canvas.updateSelection([]); - this.canvas.render(); + this.canvas.removeSelectedLayers(); return; } - - const step = e.shiftKey ? 10 : 1; - let needsRender = false; - switch (e.code) { - case 'ArrowLeft': - case 'ArrowRight': - case 'ArrowUp': - case 'ArrowDown': - case 'BracketLeft': - case 'BracketRight': - e.preventDefault(); - e.stopPropagation(); - - if (e.code === 'ArrowLeft') this.canvas.selectedLayers.forEach(l => l.x -= step); - if (e.code === 'ArrowRight') this.canvas.selectedLayers.forEach(l => l.x += step); - if (e.code === 'ArrowUp') this.canvas.selectedLayers.forEach(l => l.y -= step); - if (e.code === 'ArrowDown') this.canvas.selectedLayers.forEach(l => l.y += step); - if (e.code === 'BracketLeft') this.canvas.selectedLayers.forEach(l => l.rotation -= step); - if (e.code === 'BracketRight') this.canvas.selectedLayers.forEach(l => l.rotation += step); - - needsRender = true; - break; - } - + if (needsRender) { - this.canvas.render(); - this.canvas.saveState(); + this.canvas.render(); // Tylko renderuj, nie zapisuj stanu } } } @@ -420,6 +308,12 @@ export class CanvasInteractions { handleKeyUp(e) { if (e.key === 'Control') this.interaction.isCtrlPressed = false; if (e.key === 'Alt') this.interaction.isAltPressed = false; + + const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; + if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) { + this.canvas.requestSaveState(); // Użyj opóźnionego zapisu + this.interaction.keyMovementInProgress = false; + } } updateCursor(worldCoords) { @@ -466,31 +360,32 @@ export class CanvasInteractions { this.canvas.render(); } - startLayerDrag(layer, worldCoords) { - this.interaction.mode = 'dragging'; - this.interaction.dragStart = {...worldCoords}; - - let currentSelection = [...this.canvas.selectedLayers]; - + prepareForDrag(layer, worldCoords) { + // Zaktualizuj zaznaczenie, ale nie zapisuj stanu if (this.interaction.isCtrlPressed) { - const index = currentSelection.indexOf(layer); + const index = this.canvas.selectedLayers.indexOf(layer); if (index === -1) { - currentSelection.push(layer); + this.canvas.updateSelection([...this.canvas.selectedLayers, layer]); } else { - currentSelection.splice(index, 1); + const newSelection = this.canvas.selectedLayers.filter(l => l !== layer); + this.canvas.updateSelection(newSelection); } } else { - if (!currentSelection.includes(layer)) { - currentSelection = [layer]; + if (!this.canvas.selectedLayers.includes(layer)) { + this.canvas.updateSelection([layer]); } } + + this.interaction.mode = 'potential-drag'; + this.interaction.dragStart = {...worldCoords}; + } - this.canvas.updateSelection(currentSelection); - - this.originalLayerPositions.clear(); - this.canvas.selectedLayers.forEach(l => { - this.originalLayerPositions.set(l, {x: l.x, y: l.y}); - }); + startPanningOrClearSelection(e) { + if (!this.interaction.isCtrlPressed) { + this.canvas.updateSelection([]); + } + this.interaction.mode = 'panning'; + this.interaction.panStart = {x: e.clientX, y: e.clientY}; } startCanvasResize(worldCoords) { @@ -570,19 +465,12 @@ export class CanvasInteractions { dragLayers(worldCoords) { if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.selectedLayers.length > 0) { - const newLayers = []; - this.canvas.selectedLayers.forEach(layer => { - const newLayer = { - ...layer, - zIndex: this.canvas.layers.length, - }; - this.canvas.layers.push(newLayer); - newLayers.push(newLayer); - }); - this.canvas.updateSelection(newLayers); - this.canvas.selectedLayer = newLayers.length > 0 ? newLayers[newLayers.length - 1] : null; + // Scentralizowana logika duplikowania + const newLayers = this.canvas.duplicateSelectedLayers(); + + // Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw this.originalLayerPositions.clear(); - this.canvas.selectedLayers.forEach(l => { + newLayers.forEach(l => { this.originalLayerPositions.set(l, {x: l.x, y: l.y}); }); this.interaction.hasClonedInDrag = true; diff --git a/js/CanvasLayersPanel.js b/js/CanvasLayersPanel.js new file mode 100644 index 0000000..41fe843 --- /dev/null +++ b/js/CanvasLayersPanel.js @@ -0,0 +1,699 @@ +import {createModuleLogger} from "./utils/LoggerUtils.js"; + +const log = createModuleLogger('CanvasLayersPanel'); + +export class CanvasLayersPanel { + constructor(canvas) { + this.canvas = canvas; + this.container = null; + this.layersContainer = null; + this.draggedElements = []; + this.dragInsertionLine = null; + this.isMultiSelecting = false; + this.lastSelectedIndex = -1; + + // Binding metod dla event handlerów + this.handleLayerClick = this.handleLayerClick.bind(this); + this.handleDragStart = this.handleDragStart.bind(this); + this.handleDragOver = this.handleDragOver.bind(this); + this.handleDragEnd = this.handleDragEnd.bind(this); + this.handleDrop = this.handleDrop.bind(this); + + log.info('CanvasLayersPanel initialized'); + } + + /** + * Tworzy strukturê HTML panelu warstw + */ + createPanelStructure() { + // Główny kontener panelu + this.container = document.createElement('div'); + this.container.className = 'layers-panel'; + this.container.innerHTML = ` +
+ Warstwy +
+ + +
+
+
+ +
+ `; + + this.layersContainer = this.container.querySelector('#layers-container'); + + // Dodanie stylów CSS + this.injectStyles(); + + // Setup event listeners dla przycisków + this.setupControlButtons(); + + log.debug('Panel structure created'); + return this.container; + } + + /** + * Dodaje style CSS do panelu + */ + injectStyles() { + const styleId = 'layers-panel-styles'; + if (document.getElementById(styleId)) { + return; // Style już istnieją + } + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .layers-panel { + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 4px; + padding: 8px; + height: 100%; + overflow: hidden; + font-family: Arial, sans-serif; + font-size: 12px; + color: #ffffff; + user-select: none; + display: flex; + flex-direction: column; + } + + .layers-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 8px; + border-bottom: 1px solid #3a3a3a; + margin-bottom: 8px; + } + + .layers-panel-title { + font-weight: bold; + color: #ffffff; + } + + .layers-panel-controls { + display: flex; + gap: 4px; + } + + .layers-btn { + background: #3a3a3a; + border: 1px solid #4a4a4a; + color: #ffffff; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + } + + .layers-btn:hover { + background: #4a4a4a; + } + + .layers-btn:active { + background: #5a5a5a; + } + + .layers-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + } + + .layer-row { + display: flex; + align-items: center; + padding: 6px 4px; + margin-bottom: 2px; + border-radius: 3px; + cursor: pointer; + transition: background-color 0.15s ease; + position: relative; + gap: 6px; + } + + .layer-row:hover { + background: rgba(255, 255, 255, 0.05); + } + + .layer-row.selected { + background: #2d5aa0 !important; + box-shadow: inset 0 0 0 1px #4a7bc8; + } + + .layer-row.dragging { + opacity: 0.6; + } + + + .layer-thumbnail { + width: 48px; + height: 48px; + border: 1px solid #4a4a4a; + border-radius: 2px; + background: transparent; + position: relative; + flex-shrink: 0; + overflow: hidden; + } + + .layer-thumbnail canvas { + width: 100%; + height: 100%; + display: block; + } + + .layer-thumbnail::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(45deg, #555 25%, transparent 25%), + linear-gradient(-45deg, #555 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #555 75%), + linear-gradient(-45deg, transparent 75%, #555 75%); + background-size: 8px 8px; + background-position: 0 0, 0 4px, 4px -4px, -4px 0px; + z-index: 1; + } + + .layer-thumbnail canvas { + position: relative; + z-index: 2; + } + + .layer-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 2px 4px; + border-radius: 2px; + color: #ffffff; + } + + .layer-name.editing { + background: #4a4a4a; + border: 1px solid #6a6a6a; + outline: none; + color: #ffffff; + } + + .layer-name input { + background: transparent; + border: none; + color: #ffffff; + font-size: 12px; + width: 100%; + outline: none; + } + + .drag-insertion-line { + position: absolute; + left: 0; + right: 0; + height: 2px; + background: #4a7bc8; + border-radius: 1px; + z-index: 1000; + box-shadow: 0 0 4px rgba(74, 123, 200, 0.6); + } + + .layers-container::-webkit-scrollbar { + width: 6px; + } + + .layers-container::-webkit-scrollbar-track { + background: #2a2a2a; + } + + .layers-container::-webkit-scrollbar-thumb { + background: #4a4a4a; + border-radius: 3px; + } + + .layers-container::-webkit-scrollbar-thumb:hover { + background: #5a5a5a; + } + `; + + document.head.appendChild(style); + log.debug('Styles injected'); + } + + /** + * Konfiguruje event listenery dla przycisków kontrolnych + */ + setupControlButtons() { + const addBtn = this.container.querySelector('#add-layer-btn'); + const deleteBtn = this.container.querySelector('#delete-layer-btn'); + + addBtn?.addEventListener('click', () => { + log.info('Add layer button clicked'); + // TODO: Implementacja dodawania warstwy + }); + + deleteBtn?.addEventListener('click', () => { + log.info('Delete layer button clicked'); + this.deleteSelectedLayers(); + }); + } + + /** + * Renderuje listę warstw + */ + renderLayers() { + if (!this.layersContainer) { + log.warn('Layers container not initialized'); + return; + } + + // Wyczyść istniejącą zawartość + this.layersContainer.innerHTML = ''; + + // Usuń linię wstawiania jeśli istnieje + this.removeDragInsertionLine(); + + // Sortuj warstwy według zIndex (od najwyższej do najniższej) + const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); + + sortedLayers.forEach((layer, index) => { + const layerElement = this.createLayerElement(layer, index); + this.layersContainer.appendChild(layerElement); + }); + + log.debug(`Rendered ${sortedLayers.length} layers`); + } + + /** + * Tworzy element HTML dla pojedynczej warstwy + */ + createLayerElement(layer, index) { + const layerRow = document.createElement('div'); + layerRow.className = 'layer-row'; + layerRow.draggable = true; + layerRow.dataset.layerIndex = index; + + // Sprawdź czy warstwa jest zaznaczona + const isSelected = this.canvas.selectedLayers.includes(layer); + if (isSelected) { + layerRow.classList.add('selected'); + } + + // Ustawienie domyślnych właściwości jeśli nie istnieją + if (!layer.name) { + layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer); + } else { + // Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu) + layer.name = this.ensureUniqueName(layer.name, layer); + } + + layerRow.innerHTML = ` +
+ ${layer.name} + `; + + // Wygeneruj miniaturkę + this.generateThumbnail(layer, layerRow.querySelector('.layer-thumbnail')); + + // Event listenery + this.setupLayerEventListeners(layerRow, layer, index); + + return layerRow; + } + + /** + * Generuje miniaturkę warstwy + */ + generateThumbnail(layer, thumbnailContainer) { + if (!layer.image) { + thumbnailContainer.style.background = '#4a4a4a'; + return; + } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + canvas.width = 48; + canvas.height = 48; + + // Oblicz skalę zachowując proporcje + const scale = Math.min(48 / layer.image.width, 48 / layer.image.height); + const scaledWidth = layer.image.width * scale; + const scaledHeight = layer.image.height * scale; + + // Wycentruj obraz + const x = (48 - scaledWidth) / 2; + const y = (48 - scaledHeight) / 2; + + // Narysuj obraz z wyższą jakością + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight); + + thumbnailContainer.appendChild(canvas); + } + + /** + * Konfiguruje event listenery dla elementu warstwy + */ + setupLayerEventListeners(layerRow, layer, index) { + // Click handler - natychmiastowe zaznaczanie + layerRow.addEventListener('click', (e) => { + const nameElement = layerRow.querySelector('.layer-name'); + if (nameElement && nameElement.classList.contains('editing')) { + e.preventDefault(); + e.stopPropagation(); + return; + } + this.handleLayerClick(e, layer, index); + }); + + // Double click handler - edycja nazwy + layerRow.addEventListener('dblclick', (e) => { + e.preventDefault(); + e.stopPropagation(); + const nameElement = layerRow.querySelector('.layer-name'); + this.startEditingLayerName(nameElement, layer); + }); + + // Drag handlers + layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index)); + layerRow.addEventListener('dragover', this.handleDragOver); + layerRow.addEventListener('dragend', this.handleDragEnd); + layerRow.addEventListener('drop', (e) => this.handleDrop(e, index)); + } + + /** + * Obsługuje kliknięcie na warstwę, aktualizując stan bez pełnego renderowania. + */ + handleLayerClick(e, layer, index) { + // Zatrzymujemy, bo dblclick też wywołałby click + e.preventDefault(); + + const isCtrlPressed = e.ctrlKey || e.metaKey; + const isShiftPressed = e.shiftKey; + + // Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas + // Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu. + this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); + + // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM + this.updateSelectionAppearance(); + + log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.selectedLayers.length}`); + } + + + /** + * Rozpoczyna edycję nazwy warstwy + */ + startEditingLayerName(nameElement, layer) { + const currentName = layer.name; + nameElement.classList.add('editing'); + + const input = document.createElement('input'); + input.type = 'text'; + input.value = currentName; + input.style.width = '100%'; + + nameElement.innerHTML = ''; + nameElement.appendChild(input); + + input.focus(); + input.select(); + + const finishEditing = () => { + let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`; + newName = this.ensureUniqueName(newName, layer); + layer.name = newName; + nameElement.classList.remove('editing'); + nameElement.textContent = newName; + + this.canvas.saveState(); + log.info(`Layer renamed to: ${newName}`); + }; + + input.addEventListener('blur', finishEditing); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + finishEditing(); + } else if (e.key === 'Escape') { + nameElement.classList.remove('editing'); + nameElement.textContent = currentName; + } + }); + } + + + /** + * Zapewnia unikalność nazwy warstwy + */ + ensureUniqueName(proposedName, currentLayer) { + const existingNames = this.canvas.layers + .filter(layer => layer !== currentLayer) + .map(layer => layer.name); + + if (!existingNames.includes(proposedName)) { + return proposedName; + } + + // Sprawdź czy nazwa już ma numerację w nawiasach + const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/); + let baseName, startNumber; + + if (match) { + baseName = match[1].trim(); + startNumber = parseInt(match[2]) + 1; + } else { + baseName = proposedName; + startNumber = 1; + } + + // Znajdź pierwszą dostępną numerację + let counter = startNumber; + let uniqueName; + + do { + uniqueName = `${baseName} (${counter})`; + counter++; + } while (existingNames.includes(uniqueName)); + + return uniqueName; + } + + /** + * Usuwa zaznaczone warstwy + */ + deleteSelectedLayers() { + if (this.canvas.selectedLayers.length === 0) { + log.debug('No layers selected for deletion'); + return; + } + + log.info(`Deleting ${this.canvas.selectedLayers.length} selected layers`); + this.canvas.removeSelectedLayers(); + this.renderLayers(); + } + + /** + * Rozpoczyna przeciąganie warstwy + */ + handleDragStart(e, layer, index) { + // Sprawdź czy jakakolwiek warstwa jest w trybie edycji + const editingElement = this.layersContainer.querySelector('.layer-name.editing'); + if (editingElement) { + e.preventDefault(); + return; + } + + // Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją + if (!this.canvas.selectedLayers.includes(layer)) { + this.canvas.updateSelection([layer]); + this.renderLayers(); + } + + this.draggedElements = [...this.canvas.selectedLayers]; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', ''); // Wymagane przez standard + + // Dodaj klasę dragging do przeciąganych elementów + this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => { + const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); + if (this.draggedElements.includes(sortedLayers[idx])) { + row.classList.add('dragging'); + } + }); + + log.debug(`Started dragging ${this.draggedElements.length} layers`); + } + + /** + * Obsługuje przeciąganie nad warstwą + */ + handleDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + const layerRow = e.currentTarget; + const rect = layerRow.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + const isUpperHalf = e.clientY < midpoint; + + this.showDragInsertionLine(layerRow, isUpperHalf); + } + + /** + * Pokazuje linię wskaźnika wstawiania + */ + showDragInsertionLine(targetRow, isUpperHalf) { + this.removeDragInsertionLine(); + + const line = document.createElement('div'); + line.className = 'drag-insertion-line'; + + if (isUpperHalf) { + line.style.top = '-1px'; + } else { + line.style.bottom = '-1px'; + } + + targetRow.style.position = 'relative'; + targetRow.appendChild(line); + this.dragInsertionLine = line; + } + + /** + * Usuwa linię wskaźnika wstawiania + */ + removeDragInsertionLine() { + if (this.dragInsertionLine) { + this.dragInsertionLine.remove(); + this.dragInsertionLine = null; + } + } + + /** + * Obsługuje upuszczenie warstwy + */ + handleDrop(e, targetIndex) { + e.preventDefault(); + this.removeDragInsertionLine(); + + if (this.draggedElements.length === 0) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + const isUpperHalf = e.clientY < midpoint; + + // Oblicz docelowy indeks + let insertIndex = targetIndex; + if (!isUpperHalf) { + insertIndex = targetIndex + 1; + } + + this.moveLayersToPosition(this.draggedElements, insertIndex); + + log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`); + } + + /** + * Kończy przeciąganie + */ + handleDragEnd(e) { + this.removeDragInsertionLine(); + + // Usuń klasę dragging ze wszystkich elementów + this.layersContainer.querySelectorAll('.layer-row').forEach(row => { + row.classList.remove('dragging'); + }); + + this.draggedElements = []; + } + + /** + * Przenosi warstwy na nową pozycję + */ + moveLayersToPosition(layers, insertIndex) { + const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); + + // Usuń przeciągane warstwy z listy + const filteredLayers = sortedLayers.filter(layer => !layers.includes(layer)); + + // Wstaw warstwy w nowej pozycji (odwróć kolejność bo renderujemy od góry) + const reverseInsertIndex = filteredLayers.length - insertIndex; + filteredLayers.splice(reverseInsertIndex, 0, ...layers); + + // Zaktualizuj zIndex dla wszystkich warstw + filteredLayers.forEach((layer, index) => { + layer.zIndex = index; + }); + + this.canvas.layers = filteredLayers; + this.canvas.render(); + this.renderLayers(); + this.canvas.saveState(); + } + + /** + * Aktualizuje panel gdy zmienią się warstwy + */ + onLayersChanged() { + this.renderLayers(); + } + + /** + * Aktualizuje wygląd zaznaczenia w panelu bez pełnego renderowania. + */ + updateSelectionAppearance() { + const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); + const layerRows = this.layersContainer.querySelectorAll('.layer-row'); + + layerRows.forEach((row, index) => { + const layer = sortedLayers[index]; + if (this.canvas.selectedLayers.includes(layer)) { + row.classList.add('selected'); + } else { + row.classList.remove('selected'); + } + }); + } + + /** + * Aktualizuje panel gdy zmienią się warstwy (np. dodanie, usunięcie, zmiana kolejności) + * To jest jedyne miejsce, gdzie powinniśmy w pełni renderować panel. + */ + onLayersChanged() { + this.renderLayers(); + } + + /** + * Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz). + * Zamiast pełnego renderowania, tylko aktualizujemy wygląd. + */ + onSelectionChanged() { + this.updateSelectionAppearance(); + } + + /** + * Niszczy panel i czyści event listenery + */ + destroy() { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + this.container = null; + this.layersContainer = null; + this.draggedElements = []; + this.removeDragInsertionLine(); + + log.info('CanvasLayersPanel destroyed'); + } +} diff --git a/js/CanvasState.js b/js/CanvasState.js index a15dbd8..e0e93c2 100644 --- a/js/CanvasState.js +++ b/js/CanvasState.js @@ -16,6 +16,25 @@ export class CanvasState { this.saveTimeout = null; this.lastSavedStateSignature = null; this._loadInProgress = null; + + // Inicjalizacja Web Workera w sposób odporny na problemy ze ścieżkami + try { + // new URL(..., import.meta.url) tworzy absolutną ścieżkę do workera + this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' }); + log.info("State saver worker initialized successfully."); + + this.stateSaverWorker.onmessage = (e) => { + log.info("Message from state saver worker:", e.data); + }; + this.stateSaverWorker.onerror = (e) => { + log.error("Error in state saver worker:", e.message, e.filename, e.lineno); + // Zapobiegaj dalszym próbom, jeśli worker nie działa + this.stateSaverWorker = null; + }; + } catch (e) { + log.error("Failed to initialize state saver worker:", e); + this.stateSaverWorker = null; + } } @@ -182,47 +201,35 @@ export class CanvasState { img.src = imageSrc; } - async saveStateToDB(immediate = false) { - log.info("Preparing to save state to IndexedDB for node:", this.canvas.node.id); + async saveStateToDB() { if (!this.canvas.node.id) { log.error("Node ID is not available for saving state to DB."); return; } - const currentStateSignature = getStateSignature(this.canvas.layers); - if (this.lastSavedStateSignature === currentStateSignature) { - log.debug("State unchanged, skipping save to IndexedDB."); + log.info("Preparing state to be sent to worker..."); + const state = { + layers: await this._prepareLayers(), + viewport: this.canvas.viewport, + width: this.canvas.width, + height: this.canvas.height, + }; + + state.layers = state.layers.filter(layer => layer !== null); + if (state.layers.length === 0) { + log.warn("No valid layers to save, skipping."); return; } - if (this.saveTimeout) { - clearTimeout(this.saveTimeout); - } - - const saveFunction = withErrorHandling(async () => { - const state = { - layers: await this._prepareLayers(), - viewport: this.canvas.viewport, - width: this.canvas.width, - height: this.canvas.height, - }; - - state.layers = state.layers.filter(layer => layer !== null); - if (state.layers.length === 0) { - log.warn("No valid layers to save, skipping save to IndexedDB."); - return; - } - - await setCanvasState(this.canvas.node.id, state); - log.info("Canvas state saved to IndexedDB."); - this.lastSavedStateSignature = currentStateSignature; - this.canvas.render(); - }, 'CanvasState.saveStateToDB'); - - if (immediate) { - await saveFunction(); + if (this.stateSaverWorker) { + log.info("Posting state to worker for background saving."); + this.stateSaverWorker.postMessage({ + nodeId: this.canvas.node.id, + state: state + }); } else { - this.saveTimeout = setTimeout(saveFunction, 1000); + log.warn("State saver worker not available. Saving on main thread."); + await setCanvasState(this.canvas.node.id, state); } } @@ -264,14 +271,15 @@ export class CanvasState { } const currentState = cloneLayers(this.canvas.layers); + const currentStateSignature = getStateSignature(currentState); if (this.layersUndoStack.length > 0) { const lastState = this.layersUndoStack[this.layersUndoStack.length - 1]; - if (getStateSignature(currentState) === getStateSignature(lastState)) { - return; + if (getStateSignature(lastState) === currentStateSignature) { + return; } } - + this.layersUndoStack.push(currentState); if (this.layersUndoStack.length > this.historyLimit) { @@ -279,7 +287,11 @@ export class CanvasState { } this.layersRedoStack = []; this.canvas.updateHistoryButtons(); - this._debouncedSave = this._debouncedSave || debounce(() => this.saveStateToDB(), 500); + + // Użyj debouncingu, aby zapobiec zbyt częstym zapisom + if (!this._debouncedSave) { + this._debouncedSave = debounce(() => this.saveStateToDB(), 1000); + } this._debouncedSave(); } diff --git a/js/CanvasView.js b/js/CanvasView.js index 37c3b77..71dc1f5 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -1068,18 +1068,32 @@ async function createCanvasWidget(node, widget, app) { }; + // Tworzenie panelu warstw + const layersPanel = canvas.canvasLayersPanel.createPanelStructure(); + const canvasContainer = $el("div.painterCanvasContainer.painter-container", { style: { position: "absolute", top: "60px", left: "10px", - right: "10px", + right: "320px", // Zostawiamy miejsce na panel warstw bottom: "10px", - overflow: "hidden" } }, [canvas.canvas]); + // Kontener dla panelu warstw + const layersPanelContainer = $el("div.painterLayersPanelContainer", { + style: { + position: "absolute", + top: "60px", + right: "10px", + width: "300px", + bottom: "10px", + overflow: "hidden" + } + }, [layersPanel]); + canvas.canvas.addEventListener('focus', () => { canvasContainer.classList.add('has-focus'); }); @@ -1100,7 +1114,7 @@ async function createCanvasWidget(node, widget, app) { width: "100%", height: "100%" } - }, [controlPanel, canvasContainer]); + }, [controlPanel, canvasContainer, layersPanelContainer]); @@ -1167,6 +1181,10 @@ async function createCanvasWidget(node, widget, app) { setTimeout(() => { canvas.loadInitialState(); + // Renderuj panel warstw po załadowaniu stanu + if (canvas.canvasLayersPanel) { + canvas.canvasLayersPanel.renderLayers(); + } }, 100); const showPreviewWidget = node.widgets.find(w => w.name === "show_preview"); diff --git a/js/state-saver.worker.js b/js/state-saver.worker.js new file mode 100644 index 0000000..a3d20aa --- /dev/null +++ b/js/state-saver.worker.js @@ -0,0 +1,93 @@ +console.log('[StateWorker] Worker script loaded and running.'); + +const DB_NAME = 'CanvasNodeDB'; +const STATE_STORE_NAME = 'CanvasState'; +const DB_VERSION = 3; + +let db; + +function log(...args) { + console.log('[StateWorker]', ...args); +} + +function error(...args) { + console.error('[StateWorker]', ...args); +} + +function createDBRequest(store, operation, data, errorMessage) { + return new Promise((resolve, reject) => { + let request; + switch (operation) { + case 'put': + request = store.put(data); + break; + default: + reject(new Error(`Unknown operation: ${operation}`)); + return; + } + + request.onerror = (event) => { + error(errorMessage, event.target.error); + reject(errorMessage); + }; + + request.onsuccess = (event) => { + resolve(event.target.result); + }; + }); +} + +function openDB() { + return new Promise((resolve, reject) => { + if (db) { + resolve(db); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = (event) => { + error("IndexedDB error:", event.target.error); + reject("Error opening IndexedDB."); + }; + + request.onsuccess = (event) => { + db = event.target.result; + log("IndexedDB opened successfully in worker."); + resolve(db); + }; + + request.onupgradeneeded = (event) => { + log("Upgrading IndexedDB in worker..."); + const tempDb = event.target.result; + if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) { + tempDb.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'}); + } + }; + }); +} + +async function setCanvasState(id, state) { + const db = await openDB(); + const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STATE_STORE_NAME); + await createDBRequest(store, 'put', {id, state}, "Error setting canvas state"); +} + +self.onmessage = async function(e) { + log('Message received from main thread:', e.data ? 'data received' : 'no data'); + const { state, nodeId } = e.data; + + if (!state || !nodeId) { + error('Invalid data received from main thread'); + return; + } + + try { + log(`Saving state for node: ${nodeId}`); + await setCanvasState(nodeId, state); + log(`State saved successfully for node: ${nodeId}`); + } catch (err) { + error(`Failed to save state for node: ${nodeId}`, err); + } +}; diff --git a/js/utils/LoggerUtils.js b/js/utils/LoggerUtils.js index 2db0f6c..622a488 100644 --- a/js/utils/LoggerUtils.js +++ b/js/utils/LoggerUtils.js @@ -11,7 +11,7 @@ import {logger, LogLevel} from "../logger.js"; * @param {LogLevel} level - Poziom logowania (domyślnie DEBUG) * @returns {Object} Obiekt z metodami logowania */ -export function createModuleLogger(moduleName, level = LogLevel.NONE) { +export function createModuleLogger(moduleName, level = LogLevel.DEBUG) { logger.setModuleLevel(moduleName, level); return {