add a costume editor

This commit is contained in:
2026-01-20 21:33:23 -06:00
parent 23cb91f548
commit 6f59e34761
8 changed files with 2441 additions and 11 deletions

View File

@@ -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 = `
<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="draw" title="Draw">✏️</button>
<button class="tool-btn" data-tool="line" title="Line">📏</button>
<button class="tool-btn" data-tool="rect" title="Rectangle">⬜</button>
<button class="tool-btn" data-tool="circle" title="Circle">⭕</button>
<button class="tool-btn" data-tool="text" title="Text">T</button>
<button class="tool-btn" data-tool="bucket" title="Paint Bucket">🪣</button>
<button class="tool-btn" data-tool="erase" title="Eraser">🧹</button>
<button class="tool-btn" data-tool="select" title="Select">👆</button>
</div>
<div class="tool-group">
<label>Fill: <input type="color" id="color-picker" value="#000000"></label>
<label><input type="checkbox" id="fill-enabled" checked></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-width" min="0" max="50" value="2" style="width: 60px;"></label>
</div>
<div class="tool-group">
<label>Size: <input type="range" id="brush-size" min="1" max="50" value="5"><span id="size-display">5px</span></label>
</div>
<div class="tool-group">
<button class="action-btn" id="clear-canvas">🗑️ Clear</button>
<button class="action-btn" id="delete-selected">❌ Delete</button>
<button class="action-btn" id="undo-btn" disabled>↶ Undo</button>
<button class="action-btn" id="redo-btn" disabled>↷ Redo</button>
</div>
</div>
<div class="costume-editor-canvas-container">
<canvas id="fabric-canvas"></canvas>
</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 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);