From 6f59e34761523c4f59d296bdfedb3d997dfa4db0 Mon Sep 17 00:00:00 2001 From: arc360 Date: Tue, 20 Jan 2026 21:33:23 -0600 Subject: [PATCH] add a costume editor --- costume-editor-backup.txt | 1282 ++++++++++++++++++++++++++++++++++ package-lock.json | 463 ++++++++++++ package.json | 1 + public/favicon.ico | Bin 4158 -> 264254 bytes public/icons/default.png | Bin 9080 -> 8744 bytes src/index.css | 10 +- src/scripts/costumeEditor.js | 590 ++++++++++++++++ src/scripts/editor.js | 106 ++- 8 files changed, 2441 insertions(+), 11 deletions(-) create mode 100644 costume-editor-backup.txt create mode 100644 src/scripts/costumeEditor.js diff --git a/costume-editor-backup.txt b/costume-editor-backup.txt new file mode 100644 index 0000000..effb79e --- /dev/null +++ b/costume-editor-backup.txt @@ -0,0 +1,1282 @@ +import * as PIXI from "pixi.js-legacy"; + +/** + * Costume Editor - A comprehensive sprite/costume editor + * Supports drawing, shapes, text, and editing existing costumes + */ + +let editorApp = null; +let editorContainer = null; +let editorCanvas = null; +let currentTool = 'pen'; +let fillColor = '#000000'; +let outlineColor = '#000000'; +let outlineSize = 2; +let fillEnabled = true; +let penSize = 2; +let isDrawing = false; +let objects = []; // All drawable objects on canvas +let selectedObject = null; +let dragOffset = { x: 0, y: 0 }; +let isDragging = false; +let tempGraphics = null; +let startPoint = null; +let resizeHandles = []; +let isResizing = false; +let resizeHandle = null; +let shiftPressed = false; +let snapToGrid = false; +let currentStroke = null; +let lastDrawPoint = null; +let undoStack = []; +let redoStack = []; +const MAX_UNDO_STEPS = 50; +const GRID_SIZE = 20; +let penGraphics = null; +let isPenActive = false; +const SNAP_THRESHOLD = 10; +let initialSize = { width: 0, height: 0 }; +let initialPosition = { x: 0, y: 0 }; + +const CANVAS_WIDTH = 720; +const CANVAS_HEIGHT = 480; + +export function openCostumeEditor(existingCostume = null, onSave) { + closeCostumeEditor(); // Close any existing editor + + // Create editor container + editorContainer = document.createElement('div'); + editorContainer.className = 'costume-editor-overlay'; + editorContainer.innerHTML = ` +
+
+

Costume Editor

+ +
+
+
+
+ + + + + + + +
+
+ + +
+
+ + + 2px +
+
+ + 2px +
+
+ + + + + + +
+
+
+
+
+ +
+
+ `; + + document.body.appendChild(editorContainer); + + // Initialize PIXI app + editorApp = new PIXI.Application({ + width: CANVAS_WIDTH, + height: CANVAS_HEIGHT, + backgroundAlpha: 0, + antialias: true, + }); + + editorCanvas = document.getElementById('costume-canvas'); + editorCanvas.appendChild(editorApp.view); + + // Add checkerboard background to show transparency +const checkerboard = new PIXI.Graphics(); +const squareSize = 10; +for (let y = 0; y < CANVAS_HEIGHT; y += squareSize) { + for (let x = 0; x < CANVAS_WIDTH; x += squareSize) { + const isEven = ((x / squareSize) + (y / squareSize)) % 2 === 0; + checkerboard.beginFill(isEven ? 0xCCCCCC : 0xFFFFFF); + checkerboard.drawRect(x, y, squareSize, squareSize); + checkerboard.endFill(); + } +} +editorApp.stage.addChildAt(checkerboard, 0); + + // Create a container for drawn paths + tempGraphics = new PIXI.Graphics(); + editorApp.stage.addChild(tempGraphics); + + // Load existing costume if provided + if (existingCostume) { + loadExistingCostume(existingCostume); + } + + // Setup event listeners + setupEditorEvents(onSave); +} + +function loadExistingCostume(costume) { + const sprite = new PIXI.Sprite(costume.texture); + + // Center the sprite + sprite.anchor.set(0.5); + sprite.x = CANVAS_WIDTH / 2; + sprite.y = CANVAS_HEIGHT / 2; + + // Make it selectable and movable +sprite.eventMode = 'static'; + sprite.isObject = true; + sprite.objectType = 'image'; + + editorApp.stage.addChild(sprite); + objects.push(sprite); +} + +function saveState() { + try { + // Save a snapshot of the entire stage (excluding UI elements) + const snapshot = editorApp.renderer.extract.canvas(editorApp.stage); + const dataURL = snapshot.toDataURL(); + + // Also save the objects array structure for reconstruction + const objectsData = objects.map(obj => ({ + type: obj.objectType, + x: obj.x, + y: obj.y, + scaleX: obj.scale.x, + scaleY: obj.scale.y, + angle: obj.angle || 0 + })); + + undoStack.push({ + canvas: dataURL, + objects: objectsData + }); + + if (undoStack.length > MAX_UNDO_STEPS) { + undoStack.shift(); + } + + redoStack = []; + updateUndoRedoButtons(); + console.log('State saved, undo stack size:', undoStack.length); + } catch (e) { + console.error('Failed to save state:', e); + } +} + + +function serializeObject(obj) { + try { + if (obj.objectType === 'path' || obj.objectType === 'line' || obj.objectType === 'rect' || obj.objectType === 'circle') { + // Use canvas to extract the graphics as data URL + const bounds = obj.getBounds(); + const renderTexture = PIXI.RenderTexture.create({ + width: Math.max(1, bounds.width), + height: Math.max(1, bounds.height) + }); + + // Render the object to the texture + const matrix = new PIXI.Matrix(); + matrix.translate(-bounds.x, -bounds.y); + editorApp.renderer.render(obj, { renderTexture, transform: matrix }); + + // Extract as canvas then to data URL + const canvas = editorApp.renderer.extract.canvas(renderTexture); + const dataURL = canvas.toDataURL(); + + // Clean up + renderTexture.destroy(true); + + return { + texture: dataURL, + x: obj.x, + y: obj.y, + scaleX: obj.scale.x, + scaleY: obj.scale.y, + boundsX: bounds.x, + boundsY: bounds.y, + width: bounds.width, + height: bounds.height + }; + } else if (obj.objectType === 'text') { + return { + text: obj.text, + style: { + fontFamily: obj.style.fontFamily, + fontSize: obj.style.fontSize, + fill: obj.style.fill, + align: obj.style.align + }, + x: obj.x, + y: obj.y + }; + } else if (obj.objectType === 'image') { + return { + texture: obj.texture.baseTexture.resource.url, + x: obj.x, + y: obj.y, + scaleX: obj.scale.x, + scaleY: obj.scale.y, + angle: obj.angle + }; + } + } catch (e) { + console.error('Error serializing object:', e, obj); + return null; + } +} + +function undo() { + if (undoStack.length === 0) return; + + console.log('Undo called'); + + // Save current to redo + try { + const snapshot = editorApp.renderer.extract.canvas(editorApp.stage); + const dataURL = snapshot.toDataURL(); + const objectsData = objects.map(obj => ({ + type: obj.objectType, + x: obj.x, + y: obj.y, + scaleX: obj.scale.x, + scaleY: obj.scale.y, + angle: obj.angle || 0 + })); + + redoStack.push({ + canvas: dataURL, + objects: objectsData + }); + } catch (e) { + console.error('Failed to save current state for redo:', e); + } + + // Restore previous state + const state = undoStack.pop(); + restoreState(state); + updateUndoRedoButtons(); +} + +function redo() { + if (redoStack.length === 0) return; + + console.log('Redo called'); + + // Save current to undo + try { + const snapshot = editorApp.renderer.extract.canvas(editorApp.stage); + const dataURL = snapshot.toDataURL(); + const objectsData = objects.map(obj => ({ + type: obj.objectType, + x: obj.x, + y: obj.y, + scaleX: obj.scale.x, + scaleY: obj.scale.y, + angle: obj.angle || 0 + })); + + undoStack.push({ + canvas: dataURL, + objects: objectsData + }); + } catch (e) { + console.error('Failed to save current state for undo:', e); + } + + // Restore redo state + const state = redoStack.pop(); + restoreState(state); + updateUndoRedoButtons(); +} + +function restoreState(state) { + // Clear all objects + objects.forEach(obj => { + if (obj && !obj.destroyed) { + editorApp.stage.removeChild(obj); + if (obj.destroy) obj.destroy(); + } + }); + objects = []; + + // Load the canvas snapshot as a single sprite + const texture = PIXI.Texture.from(state.canvas); + const sprite = new PIXI.Sprite(texture); + sprite.x = 0; + sprite.y = 0; + sprite.isObject = true; + sprite.objectType = 'snapshot'; + sprite.eventMode = 'static'; + + editorApp.stage.addChild(sprite); + objects.push(sprite); + + selectedObject = null; + clearHighlight(); + clearResizeHandles(); +} + +function updateUndoRedoButtons() { + const undoBtn = document.getElementById('undo-button'); + const redoBtn = document.getElementById('redo-button'); + + if (undoBtn) undoBtn.disabled = undoStack.length === 0; + if (redoBtn) redoBtn.disabled = redoStack.length === 0; +} +function setupEditorEvents(onSave) { + // Tool selection + document.querySelectorAll('.tool-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentTool = btn.dataset.tool; + selectedObject = null; + }); + }); + +// Fill color picker +const fillColorPicker = document.getElementById('fill-color-picker'); +fillColorPicker.addEventListener('input', (e) => { + fillColor = e.target.value; + if (selectedObject && selectedObject.objectType === 'text') { + selectedObject.style.fill = fillColor; + } +}); + +// Fill enabled checkbox +const fillEnabledCheckbox = document.getElementById('fill-enabled'); +fillEnabledCheckbox.addEventListener('change', (e) => { + fillEnabled = e.target.checked; +}); + +// Outline color picker +const outlineColorPicker = document.getElementById('outline-color-picker'); +outlineColorPicker.addEventListener('input', (e) => { + outlineColor = e.target.value; +}); + +// Outline size +const outlineSizeInput = document.getElementById('outline-size'); +const outlineSizeDisplay = document.getElementById('outline-size-display'); +outlineSizeInput.addEventListener('input', (e) => { + outlineSize = parseInt(e.target.value); + outlineSizeDisplay.textContent = `${outlineSize}px`; +}); + +// Pen size +const penSizeSlider = document.getElementById('pen-size'); +const sizeDisplay = document.getElementById('size-display'); +penSizeSlider.addEventListener('input', (e) => { + penSize = parseInt(e.target.value); + sizeDisplay.textContent = `${penSize}px`; +}); + + // Clear canvas + document.getElementById('clear-canvas').addEventListener('click', () => { + if (confirm('Clear entire canvas?')) { + clearCanvas(); + } + }); + + // Delete selected + document.getElementById('delete-selected').addEventListener('click', () => { + if (selectedObject) { + deleteObject(selectedObject); + } + }); + +// Snap to center +document.getElementById('snap-to-center').addEventListener('click', () => { + if (selectedObject) { + const bounds = selectedObject.getBounds(); + + // Calculate the offset from object position to its visual center + const offsetX = (bounds.x + bounds.width / 2) - selectedObject.x; + const offsetY = (bounds.y + bounds.height / 2) - selectedObject.y; + + // Position so the visual center is at canvas center + selectedObject.x = (CANVAS_WIDTH / 2) - offsetX; + selectedObject.y = (CANVAS_HEIGHT / 2) - offsetY; + + updateResizeHandles(selectedObject); + } +}); + +// Toggle grid snapping +document.getElementById('toggle-grid-snap').addEventListener('click', (e) => { + snapToGrid = !snapToGrid; + e.target.closest('.toggle-btn').classList.toggle('active', snapToGrid); +}); + + // Canvas interactions + editorApp.view.addEventListener('pointerdown', handlePointerDown); + editorApp.view.addEventListener('pointermove', handlePointerMove); + editorApp.view.addEventListener('pointerup', handlePointerUp); + + // Save button + document.querySelector('.save-btn').addEventListener('click', async () => { + const dataURL = await captureCanvas(); + if (onSave) { + onSave(dataURL); + } + closeCostumeEditor(); + }); + + // Cancel/Close buttons + document.querySelector('.cancel-btn').addEventListener('click', closeCostumeEditor); + document.querySelector('.close-editor-btn').addEventListener('click', closeCostumeEditor); + document.getElementById('undo-button').addEventListener('click', undo); + document.getElementById('redo-button').addEventListener('click', redo); +} + +// Keyboard listeners for Shift key +window.addEventListener('keydown', (e) => { + if (e.key === 'Shift') { + shiftPressed = true; + } +}); + +window.addEventListener('keyup', (e) => { + if (e.key === 'Shift') { + shiftPressed = false; + } +}); + +function screenToCanvas(screenX, screenY) { + const rect = editorApp.view.getBoundingClientRect(); + const canvasX = screenX - rect.left; + const canvasY = screenY - rect.top; + + // Account for canvas scaling if any + const scaleX = CANVAS_WIDTH / rect.width; + const scaleY = CANVAS_HEIGHT / rect.height; + + return { + x: canvasX * scaleX, + y: canvasY * scaleY + }; +} + +function handlePointerDown(e) { + const pos = screenToCanvas(e.clientX, e.clientY); + const x = pos.x; + const y = pos.y; + + // Check if clicking on a resize handle + for (let handle of resizeHandles) { + // ADD THIS CHECK - skip destroyed handles + if (!handle || handle.destroyed || !handle.transform) continue; + + const bounds = handle.getBounds(); + if (x >= bounds.x && x <= bounds.x + bounds.width && + y >= bounds.y && y <= bounds.y + bounds.height) { + isResizing = true; + resizeHandle = handle; + selectedObject = handle.targetObject; + const objBounds = selectedObject.getBounds(); + initialSize = { width: objBounds.width, height: objBounds.height }; + initialPosition = { x: objBounds.x, y: objBounds.y }; + return; + } + } + + if (currentTool === 'select') { + const clickedObject = findObjectAt(x, y); + if (clickedObject) { + selectedObject = clickedObject; + isDragging = true; + dragOffset.x = x - clickedObject.x; + dragOffset.y = y - clickedObject.y; + highlightSelected(); + createResizeHandles(clickedObject); + } else { + selectedObject = null; + clearHighlight(); + clearResizeHandles(); + } + } else if (currentTool === 'text') { + const text = prompt('Enter text:'); + if (text) { + addText(text, x, y); + saveState(); + } +} else if (currentTool === 'pen') { + isDrawing = true; + isPenActive = true; + + // Create fresh graphics object + penGraphics = new PIXI.Graphics(); + penGraphics.isObject = true; + penGraphics.objectType = 'path'; + penGraphics.eventMode = 'static'; + penGraphics.x = 0; + penGraphics.y = 0; + + // Add to stage + editorApp.stage.addChild(penGraphics); + objects.push(penGraphics); + + // Draw initial dot + penGraphics.beginFill(parseInt(fillColor.replace('#', '0x'))); + penGraphics.drawCircle(x, y, penSize / 2); + penGraphics.endFill(); + + // Store the last position + lastDrawPoint = { x, y }; + } else if (currentTool === 'eraser') { + isDrawing = true; + lastDrawPoint = { x, y }; + // Immediately check for objects to erase + eraseAtPoint(x, y); + } else if (['line', 'rect', 'circle'].includes(currentTool)) { + isDrawing = true; + startPoint = { x, y }; + } +} + +function createResizeHandles(obj) { + clearResizeHandles(); + + const bounds = obj.getBounds(); + const handleSize = 8; + const positions = [ + { x: bounds.x, y: bounds.y, cursor: 'nwse-resize', type: 'tl' }, // top-left + { x: bounds.x + bounds.width, y: bounds.y, cursor: 'nesw-resize', type: 'tr' }, // top-right + { x: bounds.x, y: bounds.y + bounds.height, cursor: 'nesw-resize', type: 'bl' }, // bottom-left + { x: bounds.x + bounds.width, y: bounds.y + bounds.height, cursor: 'nwse-resize', type: 'br' }, // bottom-right + { x: bounds.x + bounds.width / 2, y: bounds.y, cursor: 'ns-resize', type: 't' }, // top + { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height, cursor: 'ns-resize', type: 'b' }, // bottom + { x: bounds.x, y: bounds.y + bounds.height / 2, cursor: 'ew-resize', type: 'l' }, // left + { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2, cursor: 'ew-resize', type: 'r' }, // right + ]; + + positions.forEach(pos => { + const handle = new PIXI.Graphics(); + handle.beginFill(0x0066FF); + handle.drawRect(pos.x - handleSize / 2, pos.y - handleSize / 2, handleSize, handleSize); + handle.endFill(); + handle.isResizeHandle = true; + handle.handleType = pos.type; + handle.targetObject = obj; + editorApp.stage.addChild(handle); + resizeHandles.push(handle); + }); +} + +function clearResizeHandles() { + resizeHandles.forEach(handle => { + if (handle && !handle.destroyed) { + editorApp.stage.removeChild(handle); + handle.destroy(); + } + }); + resizeHandles = []; +} +function updateResizeHandles(obj) { + if (resizeHandles.length === 0 || !obj || obj.destroyed) return; + + const bounds = obj.getBounds(); + const handleSize = 8; + const positions = [ + { x: bounds.x, y: bounds.y }, + { x: bounds.x + bounds.width, y: bounds.y }, + { x: bounds.x, y: bounds.y + bounds.height }, + { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + { x: bounds.x + bounds.width / 2, y: bounds.y }, + { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }, + { x: bounds.x, y: bounds.y + bounds.height / 2 }, + { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }, + ]; + + resizeHandles.forEach((handle, i) => { + handle.clear(); + handle.beginFill(0x0066FF); + handle.drawRect(positions[i].x - handleSize / 2, positions[i].y - handleSize / 2, handleSize, handleSize); + handle.endFill(); + }); +} + +function handlePointerMove(e) { + const pos = screenToCanvas(e.clientX, e.clientY); + const x = pos.x; + const y = pos.y; + +if (isResizing && resizeHandle && selectedObject) { + const handleType = resizeHandle.handleType; + + // Store initial values ONCE + if (!selectedObject._resizing) { + selectedObject._resizing = true; + selectedObject._initialScale = { x: selectedObject.scale.x, y: selectedObject.scale.y }; + selectedObject._initialX = selectedObject.x; + selectedObject._initialY = selectedObject.y; + + const bounds = selectedObject.getBounds(); + selectedObject._initialBounds = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + centerX: bounds.x + bounds.width / 2, + centerY: bounds.y + bounds.height / 2 + }; + } + + const initial = selectedObject._initialBounds; + let newScaleX = selectedObject._initialScale.x; + let newScaleY = selectedObject._initialScale.y; + + // Calculate new scale based on handle + if (handleType === 'br') { // bottom-right + newScaleX = selectedObject._initialScale.x * ((x - initial.x) / initial.width); + newScaleY = selectedObject._initialScale.y * ((y - initial.y) / initial.height); + } else if (handleType === 'bl') { // bottom-left + newScaleX = selectedObject._initialScale.x * ((initial.x + initial.width - x) / initial.width); + newScaleY = selectedObject._initialScale.y * ((y - initial.y) / initial.height); + } else if (handleType === 'tr') { // top-right + newScaleX = selectedObject._initialScale.x * ((x - initial.x) / initial.width); + newScaleY = selectedObject._initialScale.y * ((initial.y + initial.height - y) / initial.height); + } else if (handleType === 'tl') { // top-left + newScaleX = selectedObject._initialScale.x * ((initial.x + initial.width - x) / initial.width); + newScaleY = selectedObject._initialScale.y * ((initial.y + initial.height - y) / initial.height); + } else if (handleType === 'r') { // right + newScaleX = selectedObject._initialScale.x * ((x - initial.x) / initial.width); + } else if (handleType === 'l') { // left + newScaleX = selectedObject._initialScale.x * ((initial.x + initial.width - x) / initial.width); + } else if (handleType === 'b') { // bottom + newScaleY = selectedObject._initialScale.y * ((y - initial.y) / initial.height); + } else if (handleType === 't') { // top + newScaleY = selectedObject._initialScale.y * ((initial.y + initial.height - y) / initial.height); + } + + // Maintain aspect ratio if shift NOT pressed and corner handle + if (!shiftPressed && (handleType === 'tl' || handleType === 'tr' || handleType === 'bl' || handleType === 'br')) { + const avgScale = (Math.abs(newScaleX) + Math.abs(newScaleY)) / 2; + newScaleX = avgScale * Math.sign(newScaleX || 1); + newScaleY = avgScale * Math.sign(newScaleY || 1); + } + + // Prevent too small + newScaleX = Math.max(0.05, Math.abs(newScaleX)) * Math.sign(newScaleX || 1); + newScaleY = Math.max(0.05, Math.abs(newScaleY)) * Math.sign(newScaleY || 1); + + selectedObject.scale.x = newScaleX; + selectedObject.scale.y = newScaleY; + + // Adjust position to keep opposite corner fixed + const newBounds = selectedObject.getBounds(); + const centerOffsetX = (newBounds.x + newBounds.width / 2) - initial.centerX; + const centerOffsetY = (newBounds.y + newBounds.height / 2) - initial.centerY; + + selectedObject.x = selectedObject._initialX - centerOffsetX; + selectedObject.y = selectedObject._initialY - centerOffsetY; + + updateResizeHandles(selectedObject); + return; +} + + if (currentTool === 'select' && isDragging && selectedObject) { + const rawX = x - dragOffset.x; + const rawY = y - dragOffset.y; + const snapped = snapToGridOrCenter(rawX, rawY); + selectedObject.x = snapped.x; + selectedObject.y = snapped.y; + updateResizeHandles(selectedObject); +} else if (isDrawing) { +if (currentTool === 'pen' && isDrawing && isPenActive && penGraphics && lastDrawPoint) { + + // Draw a line segment from last point to current point using filled rectangles + const dx = x - lastDrawPoint.x; + const dy = y - lastDrawPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 0) { + // Draw circles along the path for smooth line + const steps = Math.max(1, Math.floor(distance / (penSize / 4))); + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const px = lastDrawPoint.x + dx * t; + const py = lastDrawPoint.y + dy * t; + + penGraphics.beginFill(parseInt(fillColor.replace('#', '0x'))); + penGraphics.drawCircle(px, py, penSize / 2); + penGraphics.endFill(); + } + } + + lastDrawPoint = { x, y }; + } else if (currentTool === 'eraser') { + if (lastDrawPoint) { + eraseAtPoint(x, y); + lastDrawPoint = { x, y }; + } + } else if (currentTool === 'line') { + // Preview line + tempGraphics.clear(); + tempGraphics.lineStyle(penSize, parseInt(fillColor.replace('#', '0x'))); + tempGraphics.moveTo(startPoint.x, startPoint.y); + tempGraphics.lineTo(x, y); + } else if (currentTool === 'rect') { + // Preview rectangle + tempGraphics.clear(); + if (outlineSize > 0) { + tempGraphics.lineStyle(outlineSize, parseInt(outlineColor.replace('#', '0x'))); + } + if (fillEnabled) { + tempGraphics.beginFill(parseInt(fillColor.replace('#', '0x'))); + } + const width = x - startPoint.x; + const height = y - startPoint.y; + tempGraphics.drawRect(startPoint.x, startPoint.y, width, height); + if (fillEnabled) { + tempGraphics.endFill(); + } + } else if (currentTool === 'circle') { + // Preview circle + tempGraphics.clear(); + if (outlineSize > 0) { + tempGraphics.lineStyle(outlineSize, parseInt(outlineColor.replace('#', '0x'))); + } + if (fillEnabled) { + tempGraphics.beginFill(parseInt(fillColor.replace('#', '0x'))); + } + const radius = Math.sqrt(Math.pow(x - startPoint.x, 2) + Math.pow(y - startPoint.y, 2)); + tempGraphics.drawCircle(startPoint.x, startPoint.y, radius); + if (fillEnabled) { + tempGraphics.endFill(); + } + } + } +} + +function handlePointerUp(e) { + const pos = screenToCanvas(e.clientX, e.clientY); + const x = pos.x; + const y = pos.y; + + // Clear resize state + if (selectedObject && selectedObject._resizing) { + delete selectedObject._resizing; + delete selectedObject._initialScale; + delete selectedObject._initialX; + delete selectedObject._initialY; + delete selectedObject._initialBounds; + } + +// Finish pen drawing + if (currentTool === 'pen' && isPenActive) { + isPenActive = false; + penGraphics = null; + } + +// Save state after drawing - but only if we actually drew something +// Save state after any drawing action + if (isDrawing || isPenActive) { + setTimeout(() => saveState(), 50); + } + + // Finish erasing + if (currentTool === 'eraser') { + lastDrawPoint = null; + } + + // Finish shape tools + if (currentTool === 'line' && isDrawing) { + addLine(startPoint.x, startPoint.y, x, y); + } else if (currentTool === 'rect' && isDrawing) { + addRectangle(startPoint.x, startPoint.y, x, y); + } else if (currentTool === 'circle' && isDrawing) { + addCircle(startPoint.x, startPoint.y, x, y); + } + + // Reset all interaction states + isDrawing = false; + isDragging = false; + isResizing = false; + resizeHandle = null; + startPoint = null; + tempGraphics.clear(); +} + +function eraseAtPoint(x, y) { + const eraserRadius = penSize * 2; + + // Check all objects and delete ones that intersect + objects = objects.filter(obj => { + if (obj === currentStroke) return true; // Don't erase the current stroke being drawn + + const bounds = obj.getBounds(); + + // Check if eraser point is inside object bounds + const isInside = x >= bounds.x - eraserRadius && + x <= bounds.x + bounds.width + eraserRadius && + y >= bounds.y - eraserRadius && + y <= bounds.y + bounds.height + eraserRadius; + + if (isInside) { + // Delete immediately + editorApp.stage.removeChild(obj); + if (obj.destroy) obj.destroy(); + clearResizeHandles(); // Clear handles if we erased the selected object + selectedObject = null; + return false; + } + + return true; + }); +} + +function addLine(x1, y1, x2, y2) { + const graphics = new PIXI.Graphics(); + graphics.lineStyle(penSize, parseInt(fillColor.replace('#', '0x'))); + graphics.moveTo(x1, y1); + graphics.lineTo(x2, y2); + graphics.isObject = true; + graphics.objectType = 'line'; + graphics.eventMode = 'static'; + editorApp.stage.addChild(graphics); + objects.push(graphics); +} + +function addRectangle(x1, y1, x2, y2) { + const graphics = new PIXI.Graphics(); + + // Set outline + if (outlineSize > 0) { + graphics.lineStyle(outlineSize, parseInt(outlineColor.replace('#', '0x'))); + } + + // Set fill + if (fillEnabled) { + graphics.beginFill(parseInt(fillColor.replace('#', '0x'))); + } + + const width = x2 - x1; + const height = y2 - y1; + graphics.drawRect(x1, y1, width, height); + + if (fillEnabled) { + graphics.endFill(); + } + + graphics.isObject = true; + graphics.objectType = 'rect'; +graphics.eventMode = 'static'; + editorApp.stage.addChild(graphics); + objects.push(graphics); +} + +function addCircle(x1, y1, x2, y2) { + const graphics = new PIXI.Graphics(); + + // Set outline + if (outlineSize > 0) { + graphics.lineStyle(outlineSize, parseInt(outlineColor.replace('#', '0x'))); + } + + // Set fill + if (fillEnabled) { + graphics.beginFill(parseInt(fillColor.replace('#', '0x'))); + } + + const radius = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); + graphics.drawCircle(x1, y1, radius); + + if (fillEnabled) { + graphics.endFill(); + } + + graphics.isObject = true; + graphics.objectType = 'circle'; +graphics.eventMode = 'static'; + editorApp.stage.addChild(graphics); + objects.push(graphics); +} + +function addText(text, x, y) { + const style = new PIXI.TextStyle({ + fontFamily: 'Arial', + fontSize: penSize * 10, + fill: fillColor, + align: 'center', + }); + + const textObj = new PIXI.Text(text, style); + textObj.x = x; + textObj.y = y; + textObj.anchor.set(0.5); + textObj.isObject = true; + textObj.objectType = 'text'; + textObj.eventMode = 'static'; + + editorApp.stage.addChild(textObj); + objects.push(textObj); +} + +function snapToGridOrCenter(x, y) { + let snappedX = x; + let snappedY = y; + + // Snap to center + const centerX = CANVAS_WIDTH / 2; + const centerY = CANVAS_HEIGHT / 2; + + if (Math.abs(x - centerX) < SNAP_THRESHOLD) { + snappedX = centerX; + } + if (Math.abs(y - centerY) < SNAP_THRESHOLD) { + snappedY = centerY; + } + + // Snap to grid + if (snapToGrid) { + snappedX = Math.round(snappedX / GRID_SIZE) * GRID_SIZE; + snappedY = Math.round(snappedY / GRID_SIZE) * GRID_SIZE; + } + + return { x: snappedX, y: snappedY }; +} + +function findObjectAt(x, y) { + // Check objects in reverse order (top to bottom) + for (let i = objects.length - 1; i >= 0; i--) { + const obj = objects[i]; + if (!obj.eventMode || obj.eventMode === 'none') continue; + + const bounds = obj.getBounds(); + if (x >= bounds.x && x <= bounds.x + bounds.width && + y >= bounds.y && y <= bounds.y + bounds.height) { + return obj; + } + } + return null; +} + +function highlightSelected() { + clearHighlight(); + if (selectedObject && selectedObject.objectType !== 'path' && selectedObject.objectType !== 'eraser') { + const bounds = selectedObject.getBounds(); + const highlight = new PIXI.Graphics(); + highlight.lineStyle(2, 0x0000FF, 0.5); + highlight.drawRect(bounds.x - 2, bounds.y - 2, bounds.width + 4, bounds.height + 4); + highlight.isHighlight = true; + editorApp.stage.addChild(highlight); + } +} + +function clearHighlight() { + editorApp.stage.children.forEach(child => { + if (child.isHighlight) { + editorApp.stage.removeChild(child); + } + }); +} + +function deleteObject(obj) { + const index = objects.indexOf(obj); + if (index > -1) { + objects.splice(index, 1); + } + editorApp.stage.removeChild(obj); + if (obj.destroy) { + obj.destroy(); + } + selectedObject = null; + clearHighlight(); + saveState(); +} + +function clearCanvas() { + objects.forEach(obj => { + editorApp.stage.removeChild(obj); + if (obj.destroy) { + obj.destroy(); + } + }); + objects = []; + selectedObject = null; + clearHighlight(); + tempGraphics.clear(); + saveState(); +} + +async function captureCanvas() { + // Create a temporary container with only the objects + const exportContainer = new PIXI.Container(); + + // Add all drawable objects to the export container + objects.forEach(obj => { + exportContainer.addChild(obj); + }); + + // Extract the image + const base64 = editorApp.renderer.extract.base64(exportContainer); + + // Put objects back on the stage + objects.forEach(obj => { + editorApp.stage.addChild(obj); + }); + + return base64; +} +export function closeCostumeEditor() { + if (editorContainer) { + editorContainer.remove(); + editorContainer = null; + } + if (editorApp) { + editorApp.destroy(true, { children: true }); + editorApp = null; + } + objects = []; + selectedObject = null; + tempGraphics = null; +} + +// Add CSS styles +const style = document.createElement('style'); +style.textContent = ` + .costume-editor-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + } + + .costume-editor-modal { + background: #2b2b2b; + border-radius: 8px; + width: 90%; + max-width: 800px; + max-height: 90vh; + display: flex; + flex-direction: column; + color: white; + } + + .costume-editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #444; + } + + .costume-editor-header h2 { + margin: 0; + font-size: 20px; + } + + .close-editor-btn { + background: none; + border: none; + color: white; + font-size: 30px; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + line-height: 30px; + } + + .close-editor-btn:hover { + color: #ff4444; + } + + .costume-editor-content { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + } + + .costume-editor-toolbar { + display: flex; + gap: 20px; + padding: 15px 20px; + border-bottom: 1px solid #444; + flex-wrap: wrap; + background: #333; + } + + .tool-group { + display: flex; + gap: 8px; + align-items: center; + } + + .tool-btn { + background: #444; + border: 2px solid transparent; + color: white; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + } + + .tool-btn:hover { + background: #555; + } + + .tool-btn.active { + background: #0066ff; + border-color: #0044cc; + } + + .action-btn { + background: #444; + border: none; + color: white; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + } + + .action-btn:hover { + background: #555; + } + + .tool-group label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + } + + #color-picker { + width: 40px; + height: 30px; + border: none; + cursor: pointer; + } + + #pen-size { + width: 100px; + } + + .costume-editor-canvas-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #1a1a1a; + overflow: auto; + padding: 20px; + } + + #costume-canvas { + border: 2px solid #444; + border-radius: 4px; + } + + #costume-canvas canvas { + display: block; + } + + .costume-editor-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 15px 20px; + border-top: 1px solid #444; + } + + .costume-editor-footer button { + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + } + + .cancel-btn { + background: #555; + color: white; + } + + .cancel-btn:hover { + background: #666; + } + + .save-btn { + background: #0066ff; + color: white; + } + + .save-btn:hover { + background: #0055dd; + } + + .primary { + font-weight: bold; + } + + #size-display { + font-size: 12px; + color: #aaa; + min-width: 40px; + } +.toggle-btn.active { + background: #0066ff !important; + border-color: #0044cc; +} +`; +document.head.appendChild(style); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b8e0ac5..81d8053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@blockly/plugin-strict-connection-checker": "^6.0.1", "@fortawesome/fontawesome-free": "^7.0.1", "blockly": "^12.2.0", + "fabric": "^7.1.0", "jszip": "^3.10.1", "pako": "^2.1.0", "pixi.js-legacy": "^7.4.3", @@ -1455,6 +1456,54 @@ "node": ">= 14" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/blockly": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/blockly/-/blockly-12.2.0.tgz", @@ -1467,6 +1516,31 @@ "node": ">=18" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1496,6 +1570,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/canvas": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.1.tgz", + "integrity": "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -1551,6 +1647,42 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1571,6 +1703,16 @@ "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", "license": "ISC" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io-client": { "version": "6.6.3", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", @@ -1721,6 +1863,29 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fabric": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-7.1.0.tgz", + "integrity": "sha512-061QsoSw6xn7UoRXYq816qMyvObP4gRNVph0jAFWtG5E2kBlfdjrYBiLPRuaAHSmVQUz9RjbPpePB/hljiYJIw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "canvas": "^3.2.0", + "jsdom": "^26.1.0" + } + }, "node_modules/fdir": { "version": "6.4.6", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", @@ -1736,6 +1901,13 @@ } } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1797,6 +1969,13 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1883,6 +2062,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -1895,6 +2095,13 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC", + "optional": true + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1994,6 +2201,36 @@ "node": ">= 0.4" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2019,6 +2256,33 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, "node_modules/nwsapi": { "version": "2.2.20", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", @@ -2037,6 +2301,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -2169,12 +2443,50 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2199,6 +2511,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -2284,6 +2612,19 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -2362,6 +2703,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/socket.io-client": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", @@ -2443,12 +2831,67 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -2508,6 +2951,19 @@ "node": ">=18" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/url": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", @@ -2663,6 +3119,13 @@ "node": ">=18" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/package.json b/package.json index 9a77f97..83a6f08 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@blockly/plugin-strict-connection-checker": "^6.0.1", "@fortawesome/fontawesome-free": "^7.0.1", "blockly": "^12.2.0", + "fabric": "^7.1.0", "jszip": "^3.10.1", "pako": "^2.1.0", "pixi.js-legacy": "^7.4.3", diff --git a/public/favicon.ico b/public/favicon.ico index a094c18323004c08406d044c430db484c213ea07..b9d85a12d4e4a268f42c62bbe0881cbe7f77b729 100644 GIT binary patch literal 264254 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 ZJ)pNNdCdLg3B|wrpC8gIa-c4M{vW9ku8sfz literal 4158 zcmeH~Ylu~46o&Vtq$oNnIfkfX_-80FO$4z&O24EM%!)e znuUqzq>`4Il~N`)k=NAnI^NS6ujAM_j`{Sy`#alHKLpP)sbJRd?S0PvzWu#xz3W}; zI}=4Y`pa)0>DoRjXck3zQ4|$uDxFBKv#!MRb(Hba~jX5#$f0)6?TY+Z0-1YcvP5oBzt>*5A7Y_lkc9_ z$no9yzH801AHo&!m5mjBsy)1avgeI(g_kG3-&qg+#6z;ao%nbDGc{|c%d`}>_U^3D zqnE=V@onC_y&iaN^0L-wew5~HYkz@;!^5-O5%AOD@H~@i*=exf35T=34R2d#*4stt z&SUylL1%~0g!%9GLF1Rgdrjq6X;~(yVQqypcE9wJXsF{97`Y6VZVozMz7?i^0e$Ac z+zn~%f4LhPycn=sjD&4RGspHDNe= zMcNz^-fKM;_LXOceewc4@xE$kXbNB2?LQx`3fnc@U>JNNtjcQn@*ezH1}k?!eXp0# zcaI7FWQQJNa&0HI)mQPlZUzXuaADxUoVOYs`kFbvQ$88ny=Lbv)VTYjV@j}}Qry*g z_UdP_R~+wG4HZlRYuY_^-JZ10n=9X2v&{E7<#G5DXk^{G#>Kg7tX6R!R2?|}VVbvJ z)|F%Y`q#f`?GJ^uh{(En)2Q~XULCtm3!6Gnq6?ugp}TluY7bZ2A@ecYmULO8aqez>YAFr z5f*HM8Q(w$?eBQ|NrzQ6!6$R;v(}us@5HsK_nV26OnU1j?f0-^i8)R27wgTRlkeI$ zPyP*A9I)(@eOs|jnp~9bG1fw+djEcZI=;BZgk>J$Aug}`;LjeK^Ev8xbG*(zikn_< z!uj8Cmhtqu(lU5vfpjOGIgjneL))TohUF^PF)s;r3jex)!fiVa+;6fq*Kv6z96bv@ z-(1^1V^{ACzYls$*LhnUdW`*QseYC(AD^5D=1xc7Z87W1XQ|H<_y2MDr T|Ese)2L2-qB>#v1TlYT!0xfHTcy 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 IM6N<$g4}cRLI3~& delta 9072 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 iIN;XpPmw7)-11lN;(S20? +
+

Costume Editor

+ +
+
+
+
+ + + + + + + + +
+
+ + +
+
+ + +
+
+ +
+
+ + + + +
+
+
+ +
+ +
+ + `; + + document.body.appendChild(editorContainer); + + // Initialize Fabric.js canvas + const canvasEl = document.getElementById('fabric-canvas'); + canvas = new Canvas(canvasEl, { + width: 720, + height: 480, + backgroundColor: null, + isDrawingMode: false + }); + +// Load existing costume if provided +setupControls(onSave, existingCostume); +} + +function setupControls(onSave, existingCostume) { + const colorPicker = document.getElementById('color-picker'); + const brushSize = document.getElementById('brush-size'); + const sizeDisplay = document.getElementById('size-display'); + const fillEnabledCheckbox = document.getElementById('fill-enabled'); + const toolButtons = document.querySelectorAll('.tool-btn'); + +let isDrawing = false; + let startPoint = null; + let currentShape = null; + let eraserPaths = []; + let outlineColor = '#000000'; + let outlineWidth = 2; + + // History setup + const history = []; + let historyStep = -1; + + function saveHistory() { + if (historyStep < history.length - 1) { + history.splice(historyStep + 1); + } + const json = canvas.toJSON(); + history.push(JSON.stringify(json)); + historyStep++; + if (history.length > 50) { + history.shift(); + historyStep--; + } + updateUndoRedoButtons(); + } + + function updateUndoRedoButtons() { + document.getElementById('undo-btn').disabled = historyStep <= 0; + document.getElementById('redo-btn').disabled = historyStep >= history.length - 1; + } + + // Tool selection + toolButtons.forEach(btn => { + btn.addEventListener('click', () => { + toolButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentTool = btn.dataset.tool; + + // Clean up existing listeners + canvas.off('mouse:down'); + canvas.off('mouse:move'); + canvas.off('mouse:up'); + canvas.isDrawingMode = false; + canvas.selection = false; + + if (currentTool === 'draw') { + canvas.isDrawingMode = true; + canvas.freeDrawingBrush = new PencilBrush(canvas); + canvas.freeDrawingBrush.color = currentColor; + canvas.freeDrawingBrush.width = currentSize; + } else if (currentTool === 'erase') { + canvas.isDrawingMode = true; + const eraserBrush = new PencilBrush(canvas); + eraserBrush.width = currentSize * 2; + eraserBrush.color = '#FFFFFF'; + eraserBrush.inverted = true; + canvas.freeDrawingBrush = eraserBrush; + + // Use destination-out for actual erasing + canvas.on('before:path:created', (e) => { + e.path.globalCompositeOperation = 'destination-out'; + }); + } else if (currentTool === 'select') { + canvas.selection = true; + } else if (currentTool === 'text') { + canvas.on('mouse:down', (options) => { + if (options.target) return; // Don't create new text if clicking existing object + + const pointer = canvas.getScenePoint(options.e); + const text = new IText('Text', { + left: pointer.x, + top: pointer.y, + fill: currentColor, + fontSize: Math.max(20, currentSize * 4), + fontFamily: 'Arial' + }); + canvas.add(text); + canvas.setActiveObject(text); + text.enterEditing(); + text.selectAll(); + setTimeout(() => saveHistory(), 100); + }); +} else if (currentTool === 'line' || currentTool === 'rect' || currentTool === 'circle') { + canvas.on('mouse:down', (options) => { + if (options.target) return; // Don't draw if clicking on existing object + + isDrawing = true; + const pointer = canvas.getScenePoint(options.e); + startPoint = { x: pointer.x, y: pointer.y }; + + if (currentTool === 'line') { + currentShape = new Line([startPoint.x, startPoint.y, startPoint.x, startPoint.y], { + stroke: outlineColor, + strokeWidth: outlineWidth, + selectable: false + }); + } else if (currentTool === 'rect') { + currentShape = new Rect({ + left: startPoint.x, + top: startPoint.y, + width: 1, + height: 1, + fill: fillEnabled ? currentColor : 'transparent', + stroke: outlineColor, + strokeWidth: outlineWidth, + selectable: false + }); + } else if (currentTool === 'circle') { + currentShape = new Circle({ + left: startPoint.x, + top: startPoint.y, + radius: 1, + fill: fillEnabled ? currentColor : 'transparent', + stroke: outlineColor, + strokeWidth: outlineWidth, + selectable: false, + originX: 'center', + originY: 'center' + }); + } + + if (currentShape) { + canvas.add(currentShape); + } + }); + +canvas.on('mouse:move', (options) => { + if (!isDrawing || !currentShape) return; + + const pointer = canvas.getScenePoint(options.e); + +if (currentTool === 'line') { + currentShape.set({ x2: pointer.x, y2: pointer.y }); + } else if (currentTool === 'rect') { + const width = pointer.x - startPoint.x; + const height = pointer.y - startPoint.y; + currentShape.set({ + width: Math.abs(width), + height: Math.abs(height), + left: width > 0 ? startPoint.x : pointer.x, + top: height > 0 ? startPoint.y : pointer.y + }); + } else if (currentTool === 'circle') { + const radius = Math.sqrt( + Math.pow(pointer.x - startPoint.x, 2) + + Math.pow(pointer.y - startPoint.y, 2) + ); + currentShape.set({ radius: Math.max(1, radius) }); + } + + canvas.renderAll(); + }); + + canvas.on('mouse:up', () => { + if (isDrawing && currentShape) { + currentShape.setCoords(); + currentShape.set({ selectable: true }); + saveHistory(); + } + isDrawing = false; + currentShape = null; + }); +} else if (currentTool === 'bucket') { + canvas.on('mouse:down', (options) => { + if (!options.target) return; + + const target = options.target; + + if (target.type === 'rect' || target.type === 'circle' || target.type === 'triangle' || target.type === 'polygon') { + target.set({ + fill: currentColor, + stroke: outlineColor, + strokeWidth: outlineWidth + }); + canvas.renderAll(); + saveHistory(); + } else if (target.type === 'line' || target.type === 'path') { + target.set({ + stroke: currentColor, + strokeWidth: outlineWidth + }); + canvas.renderAll(); + saveHistory(); + } else if (target.type === 'i-text' || target.type === 'text') { + target.set('fill', currentColor); + canvas.renderAll(); + saveHistory(); + } + }); + } + }); + }); + + colorPicker.addEventListener('input', (e) => { + currentColor = e.target.value; + if (canvas.freeDrawingBrush && currentTool === 'draw') { + canvas.freeDrawingBrush.color = currentColor; + } + }); + +const outlineColorPicker = document.getElementById('outline-color-picker'); + const outlineWidthInput = document.getElementById('outline-width'); + + outlineColorPicker.addEventListener('input', (e) => { + outlineColor = e.target.value; + }); + + outlineWidthInput.addEventListener('input', (e) => { + outlineWidth = parseInt(e.target.value); + }); + + brushSize.addEventListener('input', (e) => { + currentSize = parseInt(e.target.value); + sizeDisplay.textContent = `${currentSize}px`; + if (canvas.freeDrawingBrush) { + canvas.freeDrawingBrush.width = currentSize; + } + }); + + fillEnabledCheckbox.addEventListener('change', (e) => { + fillEnabled = e.target.checked; + }); + + document.getElementById('clear-canvas').addEventListener('click', () => { + if (confirm('Clear entire canvas?')) { + canvas.clear(); + canvas.backgroundColor = null; + canvas.renderAll(); + saveHistory(); + } + }); + + document.getElementById('delete-selected').addEventListener('click', () => { + const activeObjects = canvas.getActiveObjects(); + if (activeObjects.length > 0) { + activeObjects.forEach(obj => canvas.remove(obj)); + canvas.discardActiveObject(); + canvas.renderAll(); + saveHistory(); + } + }); + + canvas.on('path:created', saveHistory); + canvas.on('object:added', (e) => { + if (e.target && e.target.type !== 'path') { + saveHistory(); + } + }); + canvas.on('object:modified', saveHistory); + + // Snap to center functionality + const SNAP_DISTANCE = 15; + const centerX = 240; + const centerY = 180; + + canvas.on('object:moving', (e) => { + const obj = e.target; + + // Snap to horizontal center + if (Math.abs(obj.left - centerX) < SNAP_DISTANCE) { + obj.set({ left: centerX }); + } + + // Snap to vertical center + if (Math.abs(obj.top - centerY) < SNAP_DISTANCE) { + obj.set({ top: centerY }); + } + + canvas.renderAll(); + }); + + document.getElementById('undo-btn').addEventListener('click', () => { + if (historyStep > 0) { + historyStep--; + canvas.loadFromJSON(history[historyStep]).then(() => { + canvas.renderAll(); + updateUndoRedoButtons(); + }); + } + }); + + document.getElementById('redo-btn').addEventListener('click', () => { + if (historyStep < history.length - 1) { + historyStep++; + canvas.loadFromJSON(history[historyStep]).then(() => { + canvas.renderAll(); + updateUndoRedoButtons(); + }); + } + }); + + // Trigger draw mode by default +// Trigger draw mode by default + document.querySelector('[data-tool="draw"]').click(); + + // Load existing costume if provided + if (existingCostume && existingCostume.texture) { + const url = existingCostume.texture.baseTexture?.resource?.url || existingCostume.texture.baseTexture?.cacheId; + + if (url) { + FabricImage.fromURL(url).then((img) => { + img.set({ + left: 240, + top: 180, + originX: 'center', + originY: 'center' + }); + + const scale = Math.min(460 / img.width, 340 / img.height, 1); + img.scale(scale); + + canvas.add(img); + canvas.renderAll(); + saveHistory(); + }).catch(err => { + console.error('Failed to load costume:', err); + }); + } + } else { + // Save initial empty state + saveHistory(); + } + + document.querySelector('.save-btn').addEventListener('click', () => { + const dataURL = canvas.toDataURL({ + format: 'png', + quality: 1, + multiplier: 1 + }); + if (onSave) onSave(dataURL); + closeCostumeEditor(); + }); + + document.querySelector('.cancel-btn').addEventListener('click', closeCostumeEditor); + document.querySelector('.close-editor-btn').addEventListener('click', closeCostumeEditor); +} + +export function closeCostumeEditor() { + if (editorContainer) { + editorContainer.remove(); + editorContainer = null; + } + if (canvas) { + canvas.dispose(); + canvas = null; + } +} + +// CSS (same as before) +const style = document.createElement('style'); +style.textContent = ` + .costume-editor-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + } + .costume-editor-modal { + background: #2b2b2b; + border-radius: 8px; + width: 90%; + max-width: 800px; + max-height: 90vh; + display: flex; + flex-direction: column; + color: white; + } + .costume-editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #444; + } + .costume-editor-header h2 { + margin: 0; + font-size: 20px; + } + .close-editor-btn { + background: none; + border: none; + color: white; + font-size: 30px; + cursor: pointer; + } + .close-editor-btn:hover { + color: #ff4444; + } + .costume-editor-content { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + } + .costume-editor-toolbar { + display: flex; + gap: 15px; + padding: 15px 20px; + border-bottom: 1px solid #444; + flex-wrap: wrap; + background: #333; + align-items: center; + } + .tool-group { + display: flex; + gap: 8px; + align-items: center; + } + .tool-btn, .action-btn { + background: #444; + border: 2px solid transparent; + color: white; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + font-size: 16px; + } + .tool-btn:hover, .action-btn:hover { + background: #555; + } + .tool-btn.active { + background: #0066ff; + border-color: #0044cc; + } + .costume-editor-toolbar label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + } + #color-picker { + width: 40px; + height: 30px; + border: none; + cursor: pointer; + } + #brush-size { + width: 100px; + } + #size-display { + font-size: 12px; + color: #aaa; + min-width: 35px; + } + .costume-editor-canvas-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #1a1a1a; + overflow: auto; + padding: 20px; + } + .canvas-container { + border: 2px solid #444; + border-radius: 4px; + background-image: + linear-gradient(45deg, #666 25%, transparent 25%), + linear-gradient(-45deg, #666 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #666 75%), + linear-gradient(-45deg, transparent 75%, #666 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0px; + } + .costume-editor-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 15px 20px; + border-top: 1px solid #444; + } + .costume-editor-footer button { + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + } + .cancel-btn { + background: #555; + color: white; + } + .cancel-btn:hover { + background: #666; + } + .save-btn { + background: #0066ff; + color: white; + } + .save-btn:hover { + background: #0055dd; + } + .primary { + font-weight: bold; + } + .action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; +document.head.appendChild(style); \ No newline at end of file diff --git a/src/scripts/editor.js b/src/scripts/editor.js index e150c6e..4c00de0 100644 --- a/src/scripts/editor.js +++ b/src/scripts/editor.js @@ -6,6 +6,7 @@ import * as PIXI from "pixi.js-legacy"; import pako from "pako"; import JSZip from "jszip"; import { io } from "socket.io-client"; +import { openCostumeEditor, closeCostumeEditor } from "./costumeEditor.js"; import CustomRenderer from "../functions/render.js"; import { setupThemeButton } from "../functions/theme.js"; @@ -64,6 +65,19 @@ const tabButtons = document.querySelectorAll(".tab-button"); const tabContents = document.querySelectorAll(".tab-content"); const fullscreenButton = document.getElementById("fullscreen-button"); +// Add this after the costumes-list setup or in your HTML +const createCostumeButton = document.createElement('button'); +createCostumeButton.id = 'create-costume-button'; +createCostumeButton.className = 'primary'; +createCostumeButton.innerHTML = ' Create New Costume (WORK IN PROGRESS)'; +createCostumeButton.style.margin = '10px'; + +// Insert it before the costumes list +const costumesTab = document.getElementById('costumes-tab'); +if (costumesTab) { + costumesTab.insertBefore(createCostumeButton, costumesList); +} + export const BASE_WIDTH = 480; export const BASE_HEIGHT = 360; const MAX_HTTP_BUFFER = 20 * 1024 * 1024; @@ -109,7 +123,7 @@ createPenGraphics(); window.projectVariables = {}; export const projectVariables = window.projectVariables; window.sprites = []; -export const sprites = window.sprites; +export let sprites = window.sprites; export let activeSprite = null; window.projectSounds = []; window.projectCostumes = ["default"]; @@ -394,7 +408,9 @@ function deleteSprite(id, emit = false) { } }); - sprites = sprites.filter(s => s.id !== sprite.id); + window.sprites = sprites.filter(s => s.id !== sprite.id); + sprites.length = 0; + window.sprites.forEach(s => sprites.push(s)); workspace.clear(); @@ -541,7 +557,6 @@ function renderCostumesList() { const oldName = costume.name; costume.name = newName; - // ADD THIS CODE: const oldIndex = window.projectCostumes.indexOf(oldName); if (oldIndex !== -1 && !window.projectCostumes.includes(newName)) { window.projectCostumes[oldIndex] = newName; @@ -573,11 +588,46 @@ function renderCostumesList() { }); } + // ADD THIS: Edit button + const editBtn = document.createElement("button"); + editBtn.innerHTML = ''; + editBtn.className = "button"; + editBtn.draggable = false; + editBtn.title = "Edit costume"; + editBtn.onclick = () => { + openCostumeEditor(costume, async (dataURL) => { + if (!dataURL) return; + + // Update the existing costume + const newTexture = PIXI.Texture.from(dataURL); + costume.texture = newTexture; + + // Update sprite if this is the current costume + if (activeSprite.pixiSprite.texture === costume.texture) { + activeSprite.pixiSprite.texture = newTexture; + } + + renderCostumesList(); + showNotification({ message: 'โœ“ Costume updated' }); + + if (currentSocket && currentRoom) { + currentSocket.emit("projectUpdate", { + roomId: currentRoom, + type: "updateCostume", + data: { + spriteId: activeSprite.id, + name: costume.name, + texture: dataURL, + }, + }); + } + }); + }; + const deleteBtn = createDeleteButton(() => { const deleted = activeSprite.costumes[index]; activeSprite.costumes.splice(index, 1); - // ADD THIS CODE to remove from global array if not used elsewhere: if (deleted) { const existsElsewhere = sprites.some(s => s.id !== activeSprite.id && s.costumes.some(c => c.name === deleted.name) @@ -593,8 +643,6 @@ function renderCostumesList() { activeSprite.pixiSprite.texture = PIXI.Texture.EMPTY; } renderCostumesList(); - - // ADD THIS LINE to refresh toolbox: workspace.updateToolbox(document.getElementById('toolbox')); if (currentSocket && currentRoom && deleted) { @@ -611,6 +659,7 @@ function renderCostumesList() { costumeContainer.appendChild(img); costumeContainer.appendChild(renameableLabel); + costumeContainer.appendChild(editBtn); costumeContainer.appendChild(deleteBtn); costumeContainer.appendChild(sizeLabel); @@ -1776,6 +1825,51 @@ loadButton.addEventListener("click", () => { }); loadInput.addEventListener("change", loadProject); +// Create new costume with editor +document.getElementById('create-costume-button')?.addEventListener('click', () => { + if (!activeSprite) { + showNotification({ message: 'Please select a sprite first' }); + return; + } + + openCostumeEditor(null, async (dataURL) => { + if (!dataURL || !activeSprite) return; + + const texture = PIXI.Texture.from(dataURL); + + let uniqueName = 'costume'; + let counter = 1; + const nameExists = name => activeSprite.costumes.some(c => c.name === name); + + while (nameExists(uniqueName)) { + counter++; + uniqueName = `costume_${counter}`; + } + + activeSprite.costumes.push({ name: uniqueName, texture }); + + if (!window.projectCostumes.includes(uniqueName)) { + window.projectCostumes.push(uniqueName); + } + workspace.updateToolbox(document.getElementById('toolbox')); + + if (currentSocket && currentRoom) { + currentSocket.emit("projectUpdate", { + roomId: currentRoom, + type: "addCostume", + data: { + spriteId: activeSprite.id, + name: uniqueName, + texture: dataURL, + }, + }); + } + + renderCostumesList(); + showNotification({ message: 'โœ“ Costume created successfully' }); + }); +}); + document.getElementById("costume-upload").addEventListener("change", e => { const file = e.target.files[0]; if (!file || !activeSprite) return;