add a costume editor
This commit is contained in:
590
src/scripts/costumeEditor.js
Normal file
590
src/scripts/costumeEditor.js
Normal 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);
|
||||
Reference in New Issue
Block a user