Files
NeoIDE/src/scripts/costumeEditor.js
2026-01-20 21:33:23 -06:00

590 lines
17 KiB
JavaScript
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 { 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);