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