From 04003208e2d55e8ade6fa246a8771a7e1665f5ce Mon Sep 17 00:00:00 2001 From: Jacob Brooks Date: Fri, 10 Apr 2026 10:01:20 -0400 Subject: [PATCH] feat: add undo/redo system (Ctrl+Z/Y) - Command-pattern undo stack with batch support for compound operations - Track all mutations: image add/delete/move/transform, polygon add/delete/move, vertex/midpoint drag, texture drag/transform/flip, atlas auto-pack - Stage-level drag events for reliable image move tracking - tr.forceUpdate() in all undo callbacks for visual consistency Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 27 +++ index.html | 1 + scripts/leftPanelManager.js | 313 ++++++++++++++++++++++++++--------- scripts/polygonManager.js | 279 ++++++++++++++++++++++--------- scripts/rightPanelManager.js | 248 ++++++++++++++++++++++----- scripts/shortcutsManager.js | 16 ++ scripts/undoManager.js | 40 +++++ 7 files changed, 723 insertions(+), 201 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 scripts/undoManager.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2acd3f9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,27 @@ +{ + "permissions": { + "allow": [ + "Bash(wc -l scripts/*.js css/*.css)", + "Bash(npm install:*)", + "Bash(uvx blender-mcp:*)", + "Bash(uvx --version)", + "Bash(pip show:*)", + "Bash(uvx --from blender-mcp python -c \"import blender_mcp; print\\(blender_mcp.__file__\\)\")", + "Bash(uvx --from blender-mcp python -c ':*)", + "Bash(where uvx:*)", + "mcp__blender__execute_blender_code", + "WebSearch", + "WebFetch(domain:github.com)", + "WebFetch(domain:projects.blender.org)", + "Bash(gh api:*)", + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(curl -s \"https://raw.githubusercontent.com/blender/blender/main/source/blender/editors/uvedit/uvedit_select.cc\")", + "Bash(git add:*)", + "Bash(git commit -m ':*)", + "Bash(git stash:*)", + "Bash(git checkout:*)", + "Bash(git log:*)", + "Bash(git branch:*)" + ] + } +} diff --git a/index.html b/index.html index 39bc353..84ff1a2 100644 --- a/index.html +++ b/index.html @@ -69,6 +69,7 @@ + diff --git a/scripts/leftPanelManager.js b/scripts/leftPanelManager.js index a666e4c..f5e7081 100644 --- a/scripts/leftPanelManager.js +++ b/scripts/leftPanelManager.js @@ -5,9 +5,9 @@ const LeftPanelManager = { const dirtyPolygons = new Set(); const container = document.getElementById(containerId); - const stage = new Konva.Stage({ - container: containerId, - width: container.clientWidth, + const stage = new Konva.Stage({ + container: containerId, + width: container.clientWidth, height: container.clientHeight }); @@ -20,11 +20,46 @@ const LeftPanelManager = { const bgImages = []; // Store multiple images let selectedGroup = null; let imagesLocked = false; // Track lock state - + // Drawing mode state variables let drawingMode = false; let drawingModeHandlers = null; + // Helper to create a background image with undo support + function addBackgroundImage(img) { + const scale = Math.min(stage.width() / img.width, stage.height() / img.height); + const konvaImg = new Konva.Image({ + x: (stage.width() - img.width * scale) / 2, + y: (stage.height() - img.height * scale) / 2, + image: img, + width: img.width * scale, + height: img.height * scale, + draggable: !imagesLocked + }); + + bgLayer.add(konvaImg); + bgImages.push(konvaImg); + bgLayer.batchDraw(); + + // Undo for image add + UndoManager.push({ + undo: () => { + konvaImg.remove(); + const idx = bgImages.indexOf(konvaImg); + if (idx > -1) bgImages.splice(idx, 1); + tr.nodes([]); + bgLayer.batchDraw(); + }, + redo: () => { + bgLayer.add(konvaImg); + bgImages.push(konvaImg); + bgLayer.batchDraw(); + } + }); + + return konvaImg; + } + // Lock/Unlock Images button const lockBtn = document.getElementById('lockImagesLeft'); lockBtn.addEventListener('click', () => { @@ -36,7 +71,7 @@ const LeftPanelManager = { bgImages.forEach(img => { img.draggable(!imagesLocked); }); - + // Also update transformer state if (imagesLocked && tr.nodes().length > 0) { const selectedImage = tr.nodes()[0]; @@ -44,7 +79,7 @@ const LeftPanelManager = { tr.nodes([]); // Deselect any selected image when locking } } - + bgLayer.batchDraw(); }); @@ -52,36 +87,21 @@ const LeftPanelManager = { document.addEventListener('paste', (e) => { // Don't handle paste events if we're in drawing mode if (drawingMode) return; - + // Check if we're pasting image data const items = e.clipboardData.items; let imageFound = false; - + for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { imageFound = true; const blob = items[i].getAsFile(); const reader = new FileReader(); - + reader.onload = (evt) => { const img = new Image(); img.onload = () => { - const scale = Math.min(stage.width() / img.width, stage.height() / img.height); - - const konvaImg = new Konva.Image({ - x: (stage.width() - img.width * scale) / 2, - y: (stage.height() - img.height * scale) / 2, - image: img, - width: img.width * scale, - height: img.height * scale, - draggable: !imagesLocked - }); - - bgLayer.add(konvaImg); - bgImages.push(konvaImg); - bgLayer.batchDraw(); - - // Show feedback + addBackgroundImage(img); FeedbackManager.show('Image pasted successfully!'); }; img.src = evt.target.result; @@ -90,7 +110,7 @@ const LeftPanelManager = { break; // Only handle the first image } } - + if (!imageFound) { FeedbackManager.show('No image found in clipboard'); } @@ -101,17 +121,34 @@ const LeftPanelManager = { drawingMode = !drawingMode; const button = document.getElementById('toggleDrawingMode'); button.classList.toggle('drawing-active'); - + if (drawingMode) { button.textContent = 'Exit Drawing Mode'; // Initialize drawing mode with callback for when polygon is created drawingModeHandlers = PolygonManager.initDrawingMode( - stage, - polygonLayer, + stage, + polygonLayer, () => drawingMode, (newPolygon) => { - // Set the newly created polygon as selected selectedGroup = newPolygon; + // Guard against duplicate callback (initDrawingMode fires twice) + if (!newPolygon._undoPushed) { + newPolygon._undoPushed = true; + UndoManager.push({ + undo: () => { + newPolygon.remove(); + dirtyPolygons.delete(newPolygon._id); + if (selectedGroup === newPolygon) selectedGroup = null; + polygonLayer.batchDraw(); + }, + redo: () => { + polygonLayer.add(newPolygon); + dirtyPolygons.add(newPolygon._id); + selectedGroup = newPolygon; + polygonLayer.batchDraw(); + } + }); + } }, dirtyPolygons ); @@ -166,27 +203,14 @@ const LeftPanelManager = { // Upload handler document.getElementById(uploadId).addEventListener('change', e => { - const file = e.target.files[0]; + const file = e.target.files[0]; if (!file) return; - + const reader = new FileReader(); reader.onload = evt => { const img = new Image(); img.onload = () => { - const scale = Math.min(stage.width() / img.width, stage.height() / img.height); - - const konvaImg = new Konva.Image({ - x: (stage.width() - img.width * scale) / 2, - y: (stage.height() - img.height * scale) / 2, - image: img, - width: img.width * scale, - height: img.height * scale, - draggable: !imagesLocked // Set initial draggable state based on lock status - }); - - bgLayer.add(konvaImg); - bgImages.push(konvaImg); - bgLayer.batchDraw(); + addBackgroundImage(img); }; img.src = evt.target.result; }; @@ -197,13 +221,25 @@ const LeftPanelManager = { const dragDropHandler = DragDropManager.init( container, (files) => { - DragDropManager.handleImageFiles( - files, - stage, - bgLayer, - bgImages, - imagesLocked - ); + let loaded = 0; + const total = files.length; + UndoManager.beginBatch(); + files.forEach(file => { + const reader = new FileReader(); + reader.onload = evt => { + const img = new Image(); + img.onload = () => { + addBackgroundImage(img); + loaded++; + if (loaded === total) { + UndoManager.endBatch(); + FeedbackManager.show(`${total} image(s) dropped`); + } + }; + img.src = evt.target.result; + }; + reader.readAsDataURL(file); + }); }, { showOverlay: true, @@ -215,6 +251,20 @@ const LeftPanelManager = { document.getElementById(addBtnId).addEventListener('click', () => { const newGroup = PolygonManager.createPolygonGroup(stage, polygonLayer, null, dirtyPolygons); setSelectedPolygon(newGroup); + UndoManager.push({ + undo: () => { + newGroup.remove(); + dirtyPolygons.delete(newGroup._id); + if (selectedGroup === newGroup) selectedGroup = null; + polygonLayer.batchDraw(); + }, + redo: () => { + polygonLayer.add(newGroup); + dirtyPolygons.add(newGroup._id); + setSelectedPolygon(newGroup); + polygonLayer.batchDraw(); + } + }); }); // Delete button @@ -243,10 +293,10 @@ const LeftPanelManager = { prevPolygon.strokeWidth(CONFIG.POLYGON.STROKE_WIDTH); } } - + // Clear image selection tr.nodes([]); - + // Set new selection selectedGroup = group; if (selectedGroup) { @@ -256,36 +306,80 @@ const LeftPanelManager = { polygon.strokeWidth(CONFIG.POLYGON.SELECTED_STROKE_WIDTH); } } - + polygonLayer.draw(); } - // Helper function for deleting selected objects + // Helper function for deleting selected objects (with undo) function deleteSelectedObjects() { // Case 1: polygon selected if (selectedGroup) { - if (window.rightPanel) window.rightPanel.removeTexture(selectedGroup._id); - selectedGroup.destroy(); + const groupToDelete = selectedGroup; + const groupId = groupToDelete._id; + let detachedTexture = null; + if (window.rightPanel && window.rightPanel.detachTexture) { + detachedTexture = window.rightPanel.detachTexture(groupId); + } + groupToDelete.remove(); + dirtyPolygons.delete(groupId); selectedGroup = null; polygonLayer.draw(); + + UndoManager.push({ + undo: () => { + polygonLayer.add(groupToDelete); + dirtyPolygons.add(groupId); + if (detachedTexture && window.rightPanel) { + window.rightPanel.restoreTexture(groupId, detachedTexture); + } + polygonLayer.batchDraw(); + }, + redo: () => { + groupToDelete.remove(); + dirtyPolygons.delete(groupId); + if (detachedTexture && window.rightPanel) { + window.rightPanel.detachTexture(groupId); + } + selectedGroup = null; + polygonLayer.batchDraw(); + } + }); return; } // Case 2: background image selected with transformer const selectedNodes = tr.nodes(); if (selectedNodes.length > 0) { - selectedNodes.forEach(node => { - if (node instanceof Konva.Image) { - node.destroy(); - // Remove from bgImages array - const index = bgImages.indexOf(node); - if (index > -1) { - bgImages.splice(index, 1); - } - } + const imagesToDelete = selectedNodes.filter(node => node instanceof Konva.Image); + if (imagesToDelete.length === 0) return; + + imagesToDelete.forEach(node => { + node.remove(); + const index = bgImages.indexOf(node); + if (index > -1) bgImages.splice(index, 1); }); - tr.nodes([]); // clear transformer + tr.nodes([]); bgLayer.draw(); + + UndoManager.push({ + undo: () => { + imagesToDelete.forEach(node => { + bgLayer.add(node); + bgImages.push(node); + }); + tr.nodes([]); + bgLayer.batchDraw(); + }, + redo: () => { + imagesToDelete.forEach(node => { + node.remove(); + const index = bgImages.indexOf(node); + if (index > -1) bgImages.splice(index, 1); + }); + tr.nodes([]); + bgLayer.batchDraw(); + } + }); } } @@ -300,11 +394,76 @@ const LeftPanelManager = { }); uiLayer.add(tr); + // Image drag undo (stage-level events) + let imgDragStartPos = null; + + stage.on('dragstart', (e) => { + if (!(e.target instanceof Konva.Image)) return; + imgDragStartPos = { x: e.target.x(), y: e.target.y() }; + }); + stage.on('dragend', (e) => { + if (!(e.target instanceof Konva.Image) || !imgDragStartPos) return; + const img = e.target; + const start = { ...imgDragStartPos }; + const end = { x: img.x(), y: img.y() }; + imgDragStartPos = null; + if (start.x === end.x && start.y === end.y) return; + UndoManager.push({ + undo: () => { img.position(start); tr.forceUpdate(); stage.batchDraw(); }, + redo: () => { img.position(end); tr.forceUpdate(); stage.batchDraw(); } + }); + }); + + // Transformer undo/redo for background images + let trStartState = null; + tr.on('transformstart', () => { + const nodes = tr.nodes(); + trStartState = nodes.map(node => ({ + node, x: node.x(), y: node.y(), + scaleX: node.scaleX(), scaleY: node.scaleY(), + rotation: node.rotation(), + width: node.width(), height: node.height() + })); + }); + tr.on('transformend', () => { + if (!trStartState) return; + const beforeStates = trStartState; + const afterStates = beforeStates.map(s => ({ + node: s.node, x: s.node.x(), y: s.node.y(), + scaleX: s.node.scaleX(), scaleY: s.node.scaleY(), + rotation: s.node.rotation(), + width: s.node.width(), height: s.node.height() + })); + trStartState = null; + UndoManager.push({ + undo: () => { + beforeStates.forEach(s => { + s.node.position({ x: s.x, y: s.y }); + s.node.scale({ x: s.scaleX, y: s.scaleY }); + s.node.rotation(s.rotation); + s.node.size({ width: s.width, height: s.height }); + }); + tr.forceUpdate(); + stage.batchDraw(); + }, + redo: () => { + afterStates.forEach(s => { + s.node.position({ x: s.x, y: s.y }); + s.node.scale({ x: s.scaleX, y: s.scaleY }); + s.node.rotation(s.rotation); + s.node.size({ width: s.width, height: s.height }); + }); + tr.forceUpdate(); + stage.batchDraw(); + } + }); + }); + // Click to select background image or polygon stage.on('click', (e) => { // Don't process clicks if we're in drawing mode if (drawingMode) return; - + // Reset previous selection visual for polygons if (selectedGroup) { const polygon = selectedGroup.findOne('.polygon'); @@ -313,19 +472,19 @@ const LeftPanelManager = { polygon.strokeWidth(CONFIG.POLYGON.STROKE_WIDTH); } } - + // Reset selection let selectedImage = null; selectedGroup = null; tr.nodes([]); - + // Check what was clicked const clickedNode = e.target; - + // Handle polygon selection if (clickedNode instanceof Konva.Group && clickedNode.name() === 'group') { setSelectedPolygon(clickedNode); - } + } // Handle polygon parts (vertices, midpoints, edges, drag surface) else if (clickedNode.getParent() instanceof Konva.Group && clickedNode.getParent().name() === 'group') { setSelectedPolygon(clickedNode.getParent()); @@ -340,7 +499,7 @@ const LeftPanelManager = { tr.nodes([]); selectedGroup = null; } - + bgLayer.batchDraw(); polygonLayer.batchDraw(); }); @@ -349,7 +508,7 @@ const LeftPanelManager = { stage.on('keydown', (e) => { if (e.key === 'Shift') tr.keepRatio(true); }); - + stage.on('keyup', (e) => { if (e.key === 'Shift') tr.keepRatio(false); }); @@ -360,4 +519,4 @@ const LeftPanelManager = { return stage; } -}; \ No newline at end of file +}; diff --git a/scripts/polygonManager.js b/scripts/polygonManager.js index aa56e29..a05c1d5 100644 --- a/scripts/polygonManager.js +++ b/scripts/polygonManager.js @@ -1,32 +1,33 @@ // ==================== POLYGON MANAGEMENT ==================== const PolygonManager = { // Unified polygon creation function - createPolygonGroup: (stage, layer, points = null, dirtyPolygons = null) => { - const group = new Konva.Group({ - draggable: true, + createPolygonGroup: (stage, layer, points = null, dirtyPolygons = null, skipReorder = false) => { + const group = new Konva.Group({ + draggable: true, name: 'group', _id: Utils.generateId() }); if (group && group._id) dirtyPolygons.add(group._id); - + // Get vertices - either from provided points or create default rectangle let vertices; if (points && points.length === 4) { // REORDER vertices to ensure consistent order for both polygon types - vertices = Utils.reorderPolygonVertices(points); + // Skip reorder when restoring from undo (vertices already in correct order) + vertices = skipReorder ? points.map(p => ({ x: p.x, y: p.y })) : Utils.reorderPolygonVertices(points); } else { // Create default rectangle centered on stage const stageCenterX = stage.width() / 2; const stageCenterY = stage.height() / 2; - + // Convert stage center to absolute coordinates const absoluteCenter = { x: (stageCenterX - stage.x()) / stage.scaleX(), y: (stageCenterY - stage.y()) / stage.scaleY() }; - + // Create rectangle centered on the stage const rectSize = 100; vertices = [ @@ -36,7 +37,7 @@ const PolygonManager = { { x: absoluteCenter.x + rectSize/2, y: absoluteCenter.y - rectSize/2 } // Top-right (4) ]; } - + // Create midpoints for each edge const midpoints = []; for (let i = 0; i < vertices.length; i++) { @@ -48,32 +49,7 @@ const PolygonManager = { }); } - // Create a more accurate drag surface that follows the curved edges - const createCurvedDragSurface = (vertices, midpoints) => { - const points = []; - - for (let i = 0; i < vertices.length; i++) { - const nextIdx = (i + 1) % vertices.length; - const P0 = vertices[i]; - const P2 = vertices[nextIdx]; - const M = midpoints[i]; // single control point - - const numSamples = 10; // resolution - for (let j = 0; j <= numSamples; j++) { - const t = j / numSamples; - const mt = 1 - t; - - const x = mt*mt*P0.x + 2*mt*t*M.x + t*t*P2.x; - const y = mt*mt*P0.y + 2*mt*t*M.y + t*t*P2.y; - - points.push(x, y); - } - } - - return points; - }; - - const dragSurfacePoints = createCurvedDragSurface(vertices, midpoints); + const dragSurfacePoints = PolygonManager.computeDragSurfacePoints(vertices, midpoints); const dragSurface = new Konva.Line({ points: dragSurfacePoints, closed: true, @@ -82,16 +58,41 @@ const PolygonManager = { name: 'drag-surface', listening: true // Ensure it listens to events }); - + group.add(dragSurface); dragSurface.moveToBottom(); - + group.on("dragmove", () => { if (dirtyPolygons && group && group._id) { dirtyPolygons.add(group._id); } }); + // Group drag undo + let groupDragStart = null; + group.on('dragstart', () => { + groupDragStart = { x: group.x(), y: group.y() }; + }); + group.on('dragend', () => { + if (!groupDragStart) return; + const startPos = { ...groupDragStart }; + const endPos = { x: group.x(), y: group.y() }; + groupDragStart = null; + if (startPos.x === endPos.x && startPos.y === endPos.y) return; + UndoManager.push({ + undo: () => { + group.position(startPos); + if (dirtyPolygons && group._id) dirtyPolygons.add(group._id); + if (group.getLayer()) group.getLayer().batchDraw(); + }, + redo: () => { + group.position(endPos); + if (dirtyPolygons && group._id) dirtyPolygons.add(group._id); + if (group.getLayer()) group.getLayer().batchDraw(); + } + }); + }); + // Draw the curved polygon PolygonManager.drawCurvedPolygon(group, vertices, midpoints); GridManager.drawGrid(group, vertices, midpoints); @@ -136,6 +137,28 @@ const PolygonManager = { name: 'vertex-label' }); + // Vertex drag undo + let vertexDragStartState = null; + vertex.on('dragstart', () => { + vertexDragStartState = PolygonManager.snapshotPolygonState(group); + }); + vertex.on('dragend', () => { + if (!vertexDragStartState) return; + const before = vertexDragStartState; + const after = PolygonManager.snapshotPolygonState(group); + vertexDragStartState = null; + UndoManager.push({ + undo: () => { + PolygonManager.restorePolygonState(group, before); + if (dirtyPolygons && group._id) dirtyPolygons.add(group._id); + }, + redo: () => { + PolygonManager.restorePolygonState(group, after); + if (dirtyPolygons && group._id) dirtyPolygons.add(group._id); + } + }); + }); + vertex.on('dragmove', () => { // Update vertex position vertices[i] = { x: vertex.x(), y: vertex.y() }; @@ -144,11 +167,11 @@ const PolygonManager = { // Update label position when vertex moves label.position({ x: vertex.x(), y: vertex.y() }); - + // Update adjacent midpoints to maintain relative position const prevIdx = (i + vertices.length - 1) % vertices.length; const nextIdx = (i + 1) % vertices.length; - + // Update previous edge midpoint if not locked if (!midpoints[prevIdx].locked) { midpoints[prevIdx].x = (vertices[prevIdx].x + vertices[i].x) / 2; @@ -160,18 +183,18 @@ const PolygonManager = { midpoints[i].x = (vertices[i].x + vertices[nextIdx].x) / 2; midpoints[i].y = (vertices[i].y + vertices[nextIdx].y) / 2; } - + // Update polygon and grid PolygonManager.drawCurvedPolygon(group, vertices, midpoints); GridManager.drawGrid(group, vertices, midpoints); - + // Update midpoint visual positions group.find('.midpoint').forEach((midpoint, idx) => { midpoint.position(midpoints[idx]); }); - + // Update drag surface with curved edges - const updatedPoints = createCurvedDragSurface(vertices, midpoints); + const updatedPoints = PolygonManager.computeDragSurfacePoints(vertices, midpoints); PolygonManager.updateDragSurface(group, updatedPoints); // Update reference circles @@ -238,7 +261,29 @@ const PolygonManager = { context.fillStrokeShape(this); } }); - + + // Midpoint drag undo + let midpointDragStartState = null; + midpoint.on('dragstart', () => { + midpointDragStartState = PolygonManager.snapshotPolygonState(group); + }); + midpoint.on('dragend', () => { + if (!midpointDragStartState) return; + const before = midpointDragStartState; + const after = PolygonManager.snapshotPolygonState(group); + midpointDragStartState = null; + UndoManager.push({ + undo: () => { + PolygonManager.restorePolygonState(group, before); + if (dirtyPolygons && group._id) dirtyPolygons.add(group._id); + }, + redo: () => { + PolygonManager.restorePolygonState(group, after); + if (dirtyPolygons && group._id) dirtyPolygons.add(group._id); + } + }); + }); + midpoint.on('dragmove', () => { // If this is the first manual drag, lock it if (!midpoints[i].locked) midpoints[i].locked = true; @@ -249,31 +294,31 @@ const PolygonManager = { // Mark polygon as dirty if (group && group._id) dirtyPolygons.add(group._id); - + // Update polygon and grid PolygonManager.drawCurvedPolygon(group, vertices, midpoints); GridManager.drawGrid(group, vertices, midpoints); - + // Update drag surface with curved edges - const updatedPoints = createCurvedDragSurface(vertices, midpoints); + const updatedPoints = PolygonManager.computeDragSurfacePoints(vertices, midpoints); PolygonManager.updateDragSurface(group, updatedPoints); }); - + // Prevent click events from bubbling to group midpoint.on('click', (e) => { e.cancelBubble = true; }); - + group.add(midpoint); }); GridManager.drawGrid(group, vertices, midpoints); - + // Add to layer if provided if (layer) { layer.add(group); } - + return group; }, @@ -325,6 +370,76 @@ const PolygonManager = { group.add(polygon); }, + // Compute curved drag surface points (quadratic Bezier sampling) + computeDragSurfacePoints: (vertices, midpoints) => { + const points = []; + for (let i = 0; i < vertices.length; i++) { + const nextIdx = (i + 1) % vertices.length; + const P0 = vertices[i]; + const P2 = vertices[nextIdx]; + const M = midpoints[i]; + const numSamples = 10; + for (let j = 0; j <= numSamples; j++) { + const t = j / numSamples; + const mt = 1 - t; + const x = mt*mt*P0.x + 2*mt*t*M.x + t*t*P2.x; + const y = mt*mt*P0.y + 2*mt*t*M.y + t*t*P2.y; + points.push(x, y); + } + } + return points; + }, + + // Snapshot polygon state for undo/redo + snapshotPolygonState: (group) => { + return { + groupX: group.x(), + groupY: group.y(), + vertices: group.vertices.map(v => ({ x: v.x, y: v.y })), + midpoints: group.midpoints.map(m => ({ x: m.x, y: m.y, locked: m.locked })) + }; + }, + + // Restore polygon state from snapshot + restorePolygonState: (group, state) => { + group.x(state.groupX); + group.y(state.groupY); + + for (let i = 0; i < state.vertices.length; i++) { + group.vertices[i].x = state.vertices[i].x; + group.vertices[i].y = state.vertices[i].y; + } + for (let i = 0; i < state.midpoints.length; i++) { + group.midpoints[i].x = state.midpoints[i].x; + group.midpoints[i].y = state.midpoints[i].y; + group.midpoints[i].locked = state.midpoints[i].locked; + } + + group.find('.vertex').forEach((v, i) => { + v.position({ x: group.vertices[i].x, y: group.vertices[i].y }); + }); + group.find('.vertex-label').forEach((l, i) => { + l.position({ x: group.vertices[i].x, y: group.vertices[i].y }); + }); + group.find('.midpoint').forEach((m, i) => { + m.position({ x: group.midpoints[i].x, y: group.midpoints[i].y }); + }); + + group.referencePoints.forEach((ref, i) => { + const v1 = group.vertices[i]; + const v2 = group.vertices[(i + 1) % group.vertices.length]; + ref.position({ x: (v1.x + v2.x) / 2, y: (v1.y + v2.y) / 2 }); + }); + + PolygonManager.drawCurvedPolygon(group, group.vertices, group.midpoints); + GridManager.drawGrid(group, group.vertices, group.midpoints); + + const pts = PolygonManager.computeDragSurfacePoints(group.vertices, group.midpoints); + PolygonManager.updateDragSurface(group, pts); + + if (group.getLayer()) group.getLayer().batchDraw(); + }, + // Helper to update drag surface updateDragSurface: (group, points) => { const dragSurface = group.findOne('.drag-surface'); @@ -360,18 +475,18 @@ const PolygonManager = { initDrawingMode: (stage, polygonLayer, getDrawingMode, onPolygonCreated, dirtyPolygons) => { const drawingGroup = new Konva.Group(); polygonLayer.add(drawingGroup); - + let tempPoints = []; let tempLines = []; let tempVertices = []; - + function clearTempElements() { tempLines.forEach(line => line.destroy()); tempVertices.forEach(vertex => vertex.destroy()); tempLines = []; tempVertices = []; } - + function createDrawingVertex(x, y, isTemp = false) { const vertex = new Konva.Circle({ x, y, @@ -390,7 +505,7 @@ const PolygonManager = { context.fillStrokeShape(this); } }); - + vertex.on('dragmove', () => { if (isTemp) { // Update the last temporary point position @@ -400,10 +515,10 @@ const PolygonManager = { } } }); - + return vertex; } - + function updateTempLine() { if (tempLines.length > 0 && tempPoints.length >= 2) { const lastLine = tempLines[tempLines.length - 1]; @@ -414,12 +529,12 @@ const PolygonManager = { lastLine.points(points); } } - + function completePolygon() { if (tempPoints.length === 4) { // REORDER vertices to match regular polygon order const reorderedPoints = Utils.reorderPolygonVertices(tempPoints); - + // Create new edges between the reordered points const updatedPoints = []; for (let i = 0; i < 4; i++) { @@ -432,17 +547,17 @@ const PolygonManager = { // Connect last point back to first updatedPoints.push(reorderedPoints[3]); updatedPoints.push(reorderedPoints[0]); - + const polygonGroup = PolygonManager.createPolygonGroup(stage, polygonLayer, reorderedPoints, dirtyPolygons); - + // Clear temporary elements clearTempElements(); tempPoints = []; - + if (typeof onPolygonCreated === 'function') { onPolygonCreated(polygonGroup); } - + return polygonGroup; } return null; @@ -455,21 +570,21 @@ const PolygonManager = { onPolygonCreated(polygonGroup); } } - + // Event handlers const clickHandler = (e) => { // Don't process clicks if panning is active if (PanZoomManager.isPanning) return; - + // Only respond to left mouse button (button 0) if (e.evt.button !== 0) return; - + if (!getDrawingMode()) return; - + // Don't process clicks on existing polygons or their parts let node = e.target; let clickedOnExistingPolygon = false; - + while (node && node !== stage) { if (node instanceof Konva.Group && node.name() === 'group') { clickedOnExistingPolygon = true; @@ -477,25 +592,25 @@ const PolygonManager = { } node = node.getParent(); } - + if (clickedOnExistingPolygon) return; - + // Allow clicking on any part of the stage, not just empty space const pos = stage.getPointerPosition(); - + // Convert to stage coordinates (accounting for pan/zoom) const stageX = (pos.x - stage.x()) / stage.scaleX(); const stageY = (pos.y - stage.y()) / stage.scaleY(); - + if (tempPoints.length < 4) { // Add point tempPoints.push({ x: stageX, y: stageY }); - + // Create vertex const vertex = createDrawingVertex(stageX, stageY, tempPoints.length < 4); drawingGroup.add(vertex); tempVertices.push(vertex); - + // Create line if we have at least 2 points if (tempPoints.length >= 2) { const line = new Konva.Line({ @@ -511,7 +626,7 @@ const PolygonManager = { drawingGroup.add(line); tempLines.push(line); } - + // If we have 4 points, complete the polygon if (tempPoints.length === 4) { const completedPolygon = completePolygon(); @@ -528,15 +643,15 @@ const PolygonManager = { const mouseMoveHandler = () => { // Don't process mouse movement if panning is active if (PanZoomManager.isPanning) return; - + if (!getDrawingMode() || tempPoints.length === 4) return; - + const pos = stage.getPointerPosition(); - + // Convert to stage coordinates (accounting for pan/zoom) const stageX = (pos.x - stage.x()) / stage.scaleX(); const stageY = (pos.y - stage.y()) / stage.scaleY(); - + // Update or create temporary vertex if (tempVertices.length > tempPoints.length) { // Update existing temp vertex @@ -550,7 +665,7 @@ const PolygonManager = { updateTempLine(); } }; - + const contextMenuHandler = (e) => { // Use the passed function to get drawing mode state if (getDrawingMode()) { @@ -558,12 +673,12 @@ const PolygonManager = { clearTempElements(); } }; - + // Register event listeners stage.on('click', clickHandler); stage.on('mousemove', mouseMoveHandler); stage.on('contextmenu', contextMenuHandler); - + // Return cleanup function return { clearTempElements: () => { @@ -578,4 +693,4 @@ const PolygonManager = { } }; } -}; \ No newline at end of file +}; diff --git a/scripts/rightPanelManager.js b/scripts/rightPanelManager.js index d9c8cff..ebc48e8 100644 --- a/scripts/rightPanelManager.js +++ b/scripts/rightPanelManager.js @@ -34,7 +34,7 @@ const RightPanelManager = { listening: false, name: 'bgRect' }); - + bgLayer.add(bgRect); stage.bgRect = bgRect; @@ -76,7 +76,56 @@ const RightPanelManager = { // Transformer events (for resizing/rotating) tr.on('transform', snapping.handleResizing); tr.on('transformend', snapping.handleResizeEnd); - + + // Transformer undo/redo + let trStartState = null; + tr.on('transformstart', () => { + const nodes = tr.nodes(); + trStartState = nodes.map(node => ({ + node, x: node.x(), y: node.y(), + scaleX: node.scaleX(), scaleY: node.scaleY(), + rotation: node.rotation(), + width: node.width(), height: node.height(), + offsetX: node.offsetX(), offsetY: node.offsetY() + })); + }); + tr.on('transformend', () => { + if (!trStartState) return; + const beforeStates = trStartState; + const afterStates = beforeStates.map(s => ({ + node: s.node, x: s.node.x(), y: s.node.y(), + scaleX: s.node.scaleX(), scaleY: s.node.scaleY(), + rotation: s.node.rotation(), + width: s.node.width(), height: s.node.height(), + offsetX: s.node.offsetX(), offsetY: s.node.offsetY() + })); + trStartState = null; + UndoManager.push({ + undo: () => { + beforeStates.forEach(s => { + s.node.position({ x: s.x, y: s.y }); + s.node.scale({ x: s.scaleX, y: s.scaleY }); + s.node.rotation(s.rotation); + s.node.size({ width: s.width, height: s.height }); + s.node.offset({ x: s.offsetX, y: s.offsetY }); + }); + tr.forceUpdate(); + stage.batchDraw(); + }, + redo: () => { + afterStates.forEach(s => { + s.node.position({ x: s.x, y: s.y }); + s.node.scale({ x: s.scaleX, y: s.scaleY }); + s.node.rotation(s.rotation); + s.node.size({ width: s.width, height: s.height }); + s.node.offset({ x: s.offsetX, y: s.offsetY }); + }); + tr.forceUpdate(); + stage.batchDraw(); + } + }); + }); + uiLayer.add(tr); // Selection rectangle @@ -85,7 +134,7 @@ const RightPanelManager = { visible: false, listening: false // don't interfere with other mouse events }); - + uiLayer.add(selectionRectangle); // Initialize tiedRects object @@ -198,23 +247,58 @@ const RightPanelManager = { // Flip X menu.appendChild(createMenuItem('Flip X', () => { + const before = { + x: target.x(), offsetX: target.offsetX(), scaleX: target.scaleX() + }; if (target.offsetX() !== target.width() / 2) { - // Move node to keep center position consistent target.offsetX(target.width() / 2); target.x(target.x() + target.width() / 2); } target.scaleX(-target.scaleX()); target.getLayer().batchDraw(); + const after = { + x: target.x(), offsetX: target.offsetX(), scaleX: target.scaleX() + }; + UndoManager.push({ + undo: () => { + target.x(before.x); target.offsetX(before.offsetX); + target.scaleX(before.scaleX); + target.getLayer().batchDraw(); + }, + redo: () => { + target.x(after.x); target.offsetX(after.offsetX); + target.scaleX(after.scaleX); + target.getLayer().batchDraw(); + } + }); })); // Flip Y menu.appendChild(createMenuItem('Flip Y', () => { + const before = { + y: target.y(), offsetY: target.offsetY(), scaleY: target.scaleY() + }; if (target.offsetY() !== target.height() / 2) { target.offsetY(target.height() / 2); target.y(target.y() + target.height() / 2); } target.scaleY(-target.scaleY()); target.getLayer().batchDraw(); + const after = { + y: target.y(), offsetY: target.offsetY(), scaleY: target.scaleY() + }; + UndoManager.push({ + undo: () => { + target.y(before.y); target.offsetY(before.offsetY); + target.scaleY(before.scaleY); + target.getLayer().batchDraw(); + }, + redo: () => { + target.y(after.y); target.offsetY(after.offsetY); + target.scaleY(after.scaleY); + target.getLayer().batchDraw(); + } + }); })); document.body.appendChild(menu); @@ -230,50 +314,50 @@ const RightPanelManager = { async function copyTexture(texture) { // Get the absolute transformation matrix const transform = texture.getAbsoluteTransform().copy(); - + // Get the bounding box of the transformed texture const rect = texture.getClientRect(); const width = Math.ceil(rect.width); const height = Math.ceil(rect.height); - + // Create a temporary canvas const tempCanvas = document.createElement('canvas'); tempCanvas.width = width; tempCanvas.height = height; const ctx = tempCanvas.getContext('2d'); - + // Apply the inverse translation to position the texture correctly ctx.translate(-rect.x, -rect.y); - + // Apply the Konva transformation matrix const matrix = transform.getMatrix(); ctx.transform( - matrix[0], matrix[1], - matrix[2], matrix[3], + matrix[0], matrix[1], + matrix[2], matrix[3], matrix[4], matrix[5] ); - + // Draw the original image with proper dimensions ctx.drawImage( - texture.image(), - 0, 0, + texture.image(), + 0, 0, texture.width(), texture.height() ); - + try { // Get the image data from the canvas const imageData = ctx.getImageData(0, 0, width, height); - + // Create a new canvas with just the relevant pixels (remove transparent padding) const contentRect = getContentBoundingBox(imageData); - + if (contentRect.width > 0 && contentRect.height > 0) { // Create final canvas with only the content const finalCanvas = document.createElement('canvas'); finalCanvas.width = contentRect.width; finalCanvas.height = contentRect.height; const finalCtx = finalCanvas.getContext('2d'); - + // Copy only the relevant pixels finalCtx.putImageData( imageData, @@ -281,7 +365,7 @@ const RightPanelManager = { contentRect.x, contentRect.y, contentRect.width, contentRect.height ); - + // Convert to blob and copy to clipboard const blob = await new Promise(resolve => finalCanvas.toBlob(resolve, 'image/png')); await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); @@ -300,12 +384,12 @@ const RightPanelManager = { const data = imageData.data; const width = imageData.width; const height = imageData.height; - + let minX = width; let minY = height; let maxX = 0; let maxY = 0; - + // Find the bounds of non-transparent pixels for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { @@ -318,7 +402,7 @@ const RightPanelManager = { } } } - + // Return the bounding box (add 1 to max to include the edge pixel) return { x: minX, @@ -350,15 +434,40 @@ const RightPanelManager = { konvaImg.on('dragmove', snapping.handleDragging); konvaImg.on('dragend', snapping.handleDragEnd); - + + // Drag undo for extracted textures + let dragStartPos = null; + konvaImg.on('dragstart', () => { + dragStartPos = { x: konvaImg.x(), y: konvaImg.y() }; + }); + konvaImg.on('dragend', () => { + if (!dragStartPos) return; + const start = { ...dragStartPos }; + const end = { x: konvaImg.x(), y: konvaImg.y() }; + dragStartPos = null; + if (start.x === end.x && start.y === end.y) return; + UndoManager.push({ + undo: () => { + konvaImg.position(start); + tr.forceUpdate(); + stage.batchDraw(); + }, + redo: () => { + konvaImg.position(end); + tr.forceUpdate(); + stage.batchDraw(); + } + }); + }); + imageLayer.add(konvaImg); tiedRects[groupId] = konvaImg; } }; - + img.src = textureData; }, - + removeTexture: (groupId) => { if (tiedRects[groupId]) { tiedRects[groupId].destroy(); @@ -367,23 +476,42 @@ const RightPanelManager = { } }, + detachTexture: (groupId) => { + const node = tiedRects[groupId]; + if (node) { + node.remove(); + delete tiedRects[groupId]; + imageLayer.draw(); + return node; + } + return null; + }, + + restoreTexture: (groupId, node) => { + if (node) { + imageLayer.add(node); + tiedRects[groupId] = node; + imageLayer.draw(); + } + }, + updateBackground: () => { const width = parseInt(document.getElementById('rightWidth').value); const height = parseInt(document.getElementById('rightHeight').value); - + if (stage.isTransparentBackground) { const checkerboardPattern = CheckerboardManager.createCheckerboard( - width, - height, + width, + height, CONFIG.CHECKERBOARD.CELL_SIZE ); - + // Create a new image and wait for it to load const checkerboardImg = new Image(); checkerboardImg.onload = () => { // Remove old background stage.bgRect.destroy(); - + // Create new checkerboard background const newBgRect = new Konva.Image({ x: 0, @@ -394,7 +522,7 @@ const RightPanelManager = { listening: false, name: 'bgRect' }); - + stage.bgLayer.add(newBgRect); stage.bgRect = newBgRect; stage.bgLayer.draw(); @@ -416,30 +544,30 @@ const RightPanelManager = { // Toggle between solid color and checkerboard background toggleTransparency: (stage, isTransparent) => { stage.isTransparentBackground = isTransparent; - + // Use the stored reference to bgLayer const bgLayer = stage.bgLayer; if (!bgLayer) { console.error('Background layer not found'); return; } - + const width = stage.bgRect.width(); const height = stage.bgRect.height(); - + if (isTransparent) { const checkerboardPattern = CheckerboardManager.createCheckerboard( - width, - height, + width, + height, CONFIG.CHECKERBOARD.CELL_SIZE ); - + // Create a new image and wait for it to load const checkerboardImg = new Image(); checkerboardImg.onload = () => { // Remove old background const oldBgRect = stage.bgRect; - + // Create new checkerboard background const newBgRect = new Konva.Image({ x: 0, @@ -450,7 +578,7 @@ const RightPanelManager = { listening: false, name: 'bgRect' }); - + bgLayer.add(newBgRect); stage.bgRect = newBgRect; oldBgRect.destroy(); @@ -460,7 +588,7 @@ const RightPanelManager = { } else { // Switch back to solid color const oldBgRect = stage.bgRect; - + const newBgRect = new Konva.Rect({ x: 0, y: 0, @@ -470,7 +598,7 @@ const RightPanelManager = { listening: false, name: 'bgRect' }); - + bgLayer.add(newBgRect); stage.bgRect = newBgRect; oldBgRect.destroy(); @@ -486,6 +614,14 @@ const RightPanelManager = { const textures = Object.values(tiedRects); if (textures.length === 0) return { packed: 0, skipped: 0 }; + // Snapshot before state for undo + const beforeState = textures.map(t => ({ + texture: t, + x: t.x(), y: t.y(), + scaleX: t.scaleX(), scaleY: t.scaleY(), + rotation: t.rotation() + })); + const getDims = (texture, rotation = 0, scaleFactor = 1) => { const scaleX = (texture.scaleX() || 1) * scaleFactor; const scaleY = (texture.scaleY() || 1) * scaleFactor; @@ -514,7 +650,7 @@ const RightPanelManager = { for (let rot of rotations) { let { width, height } = getDims(texture, rot); - + // Optional auto-scaling let scaleFactor = 1; if (autoScale) { @@ -573,7 +709,35 @@ const RightPanelManager = { alert(`${skippedCount} texture(s) were skipped because they don't fit in the container. Consider increasing the container size.`); } + // Push undo for auto-pack + const afterState = textures.map(t => ({ + texture: t, + x: t.x(), y: t.y(), + scaleX: t.scaleX(), scaleY: t.scaleY(), + rotation: t.rotation() + })); + UndoManager.push({ + undo: () => { + beforeState.forEach(s => { + s.texture.position({ x: s.x, y: s.y }); + s.texture.scale({ x: s.scaleX, y: s.scaleY }); + s.texture.rotation(s.rotation); + }); + stage.findOne('Transformer').nodes([]); + stage.draw(); + }, + redo: () => { + afterState.forEach(s => { + s.texture.position({ x: s.x, y: s.y }); + s.texture.scale({ x: s.scaleX, y: s.scaleY }); + s.texture.rotation(s.rotation); + }); + stage.findOne('Transformer').nodes([]); + stage.draw(); + } + }); + console.log(`Packed ${packedCount} textures, ${skippedCount} skipped`); return { packed: packedCount, skipped: skippedCount }; } -}; \ No newline at end of file +}; diff --git a/scripts/shortcutsManager.js b/scripts/shortcutsManager.js index c9c39c6..f6e3360 100644 --- a/scripts/shortcutsManager.js +++ b/scripts/shortcutsManager.js @@ -1,5 +1,21 @@ document.addEventListener('keydown', (e) => { + // Undo/Redo + if ((e.ctrlKey || e.metaKey) && e.code === 'KeyZ') { + if (e.shiftKey) { + UndoManager.redo(); + } else { + UndoManager.undo(); + } + e.preventDefault(); + return; + } + if ((e.ctrlKey || e.metaKey) && e.code === 'KeyY') { + UndoManager.redo(); + e.preventDefault(); + return; + } + // Drawing mode toggle if (e.code === CONFIG.SHORTCUTS.toggleDrawingMode) { document.getElementById('toggleDrawingMode').click(); diff --git a/scripts/undoManager.js b/scripts/undoManager.js new file mode 100644 index 0000000..02c3219 --- /dev/null +++ b/scripts/undoManager.js @@ -0,0 +1,40 @@ +const UndoManager = { + _undoStack: [], + _redoStack: [], + _maxSize: 100, + _batch: null, + beginBatch() { this._batch = []; }, + endBatch() { + if (!this._batch) return; + const actions = this._batch; + this._batch = null; + if (actions.length === 0) return; + this.push({ + undo: () => { for (let i = actions.length - 1; i >= 0; i--) actions[i].undo(); }, + redo: () => { for (let i = 0; i < actions.length; i++) actions[i].redo(); } + }); + }, + push(action) { + if (this._batch) { this._batch.push(action); return; } + this._undoStack.push(action); + if (this._undoStack.length > this._maxSize) this._undoStack.shift(); + this._redoStack = []; + }, + undo() { + const action = this._undoStack.pop(); + if (!action) return false; + action.undo(); + this._redoStack.push(action); + return true; + }, + redo() { + const action = this._redoStack.pop(); + if (!action) return false; + action.redo(); + this._undoStack.push(action); + return true; + }, + clear() { this._undoStack = []; this._redoStack = []; this._batch = null; }, + canUndo() { return this._undoStack.length > 0; }, + canRedo() { return this._redoStack.length > 0; } +};