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 a094c18..b9d85a1 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/icons/default.png b/public/icons/default.png index d85772a..2803efe 100644 Binary files a/public/icons/default.png and b/public/icons/default.png differ diff --git a/src/index.css b/src/index.css index d11c826..d5ba59b 100644 --- a/src/index.css +++ b/src/index.css @@ -4,10 +4,10 @@ html { --font: "Inter", sans-serif; --dark: #2b323b; --dark-light: #424c5a; - --primary: #833bf6; - --primary-dark: #8930dc; + --primary: #ff5959; + --primary-dark: #ff7575; --danger: #f63b3b; - --danger-dark: #dd3434; + --danger-dark: #ff3434; --color1: #f3f4f6; --color2: #e4e5e7; --color3: #cbcdcf; @@ -18,8 +18,8 @@ html { html.dark { --dark: #e2e8f0; --dark-light: #c8cdd4; - --primary: #833bf6; - --primary-dark: #8930dc; + --primary: #ff5959; + --primary-dark: #ff7575; --danger: #f63b3b; --danger-dark: #dd3434; --color1: #262d36; diff --git a/src/scripts/costumeEditor.js b/src/scripts/costumeEditor.js new file mode 100644 index 0000000..da44669 --- /dev/null +++ b/src/scripts/costumeEditor.js @@ -0,0 +1,590 @@ +import { Canvas, PencilBrush, Circle, Rect, Line, IText, FabricImage, Path } from 'fabric'; +let canvas = null; +let editorContainer = null; +let currentTool = 'draw'; +let currentColor = '#000000'; +let currentSize = 5; +let fillEnabled = true; + +export function openCostumeEditor(existingCostume = null, onSave) { + closeCostumeEditor(); + + editorContainer = document.createElement('div'); + editorContainer.className = 'costume-editor-overlay'; + editorContainer.innerHTML = ` +
+
+

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;