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 {