Files
NeoIDE/costume-editor-backup.txt
2026-01-20 21:33:23 -06:00

1282 lines
36 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `
<div class="costume-editor-modal">
<div class="costume-editor-header">
<h2>Costume Editor</h2>
<button class="close-editor-btn">×</button>
</div>
<div class="costume-editor-content">
<div class="costume-editor-toolbar">
<div class="tool-group">
<button class="tool-btn active" data-tool="pen" title="Pen">
<i class="fa-solid fa-pen"></i>
</button>
<button class="tool-btn" data-tool="eraser" title="Eraser">
<i class="fa-solid fa-eraser"></i>
</button>
<button class="tool-btn" data-tool="line" title="Line">
<i class="fa-solid fa-minus"></i>
</button>
<button class="tool-btn" data-tool="rect" title="Rectangle">
<i class="fa-solid fa-square"></i>
</button>
<button class="tool-btn" data-tool="circle" title="Circle">
<i class="fa-solid fa-circle"></i>
</button>
<button class="tool-btn" data-tool="text" title="Text">
<i class="fa-solid fa-font"></i>
</button>
<button class="tool-btn" data-tool="select" title="Select">
<i class="fa-solid fa-arrow-pointer"></i>
</button>
</div>
<div class="tool-group">
<label>Fill: <input type="color" id="fill-color-picker" value="#000000"></label>
<label><input type="checkbox" id="fill-enabled" checked> Enable Fill</label>
</div>
<div class="tool-group">
<label>Outline: <input type="color" id="outline-color-picker" value="#000000"></label>
<label>Width: <input type="number" id="outline-size" min="0" max="1000" value="2" style="width: 60px;"></label>
<span id="outline-size-display">2px</span>
</div>
<div class="tool-group">
<label>Pen Size: <input type="range" id="pen-size" min="1" max="50" value="2"></label>
<span id="size-display">2px</span>
</div>
<div class="tool-group">
<button class="action-btn" id="clear-canvas">
<i class="fa-solid fa-trash"></i> Clear
</button>
<button class="action-btn" id="delete-selected">
<i class="fa-solid fa-xmark"></i> Delete
</button>
<button class="action-btn" id="snap-to-center">
<i class="fa-solid fa-bullseye"></i> Center
</button>
<button class="action-btn toggle-btn" id="toggle-grid-snap">
<i class="fa-solid fa-grip"></i> Snap
</button>
<button class="action-btn" id="undo-button" disabled>
<i class="fa-solid fa-rotate-left"></i> Undo
</button>
<button class="action-btn" id="redo-button" disabled>
<i class="fa-solid fa-rotate-right"></i> Redo
</button>
</div>
</div>
<div class="costume-editor-canvas-container">
<div id="costume-canvas"></div>
</div>
<div class="costume-editor-footer">
<button class="cancel-btn">Cancel</button>
<button class="save-btn primary">Save Costume</button>
</div>
</div>
</div>
`;
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);