1282 lines
36 KiB
Plaintext
1282 lines
36 KiB
Plaintext
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); |