347 lines
16 KiB
JavaScript
347 lines
16 KiB
JavaScript
export class BrushManager {
|
|
constructor(node) {
|
|
this.node = node;
|
|
this.isDrawing = false;
|
|
this.lastPoint = { x: 0, y: 0 };
|
|
this.settings = { size: 10, color: '#FFFFFF', mode: 'brush' };
|
|
|
|
this.finalDrawingCanvas = document.createElement('canvas');
|
|
this.finalDrawingCtx = this.finalDrawingCanvas.getContext('2d');
|
|
|
|
this.liveDrawingOverlay = null;
|
|
this.liveDrawingCtx = null;
|
|
|
|
this.boundHandleMouseEvent = this.handleMouseEvent.bind(this);
|
|
this.createSettingsToolbar();
|
|
}
|
|
|
|
createSettingsToolbar() {
|
|
|
|
const styleId = 'ls-brush-toolbar-style';
|
|
if (!document.getElementById(styleId)) {
|
|
const style = document.createElement('style');
|
|
style.id = styleId;
|
|
|
|
style.innerHTML = `
|
|
.ls-brush-toolbar button.ls-tool-button {
|
|
border: 1px solid white;
|
|
border-radius: 4px;
|
|
background-color: transparent;
|
|
transition: background-color 0.2s;
|
|
}
|
|
.ls-brush-toolbar button.ls-tool-button:not(.active):hover {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
.ls-brush-toolbar button.active {
|
|
background-color: rgba(100, 180, 255, 0.4);
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
this.toolbar = document.createElement("div");
|
|
this.toolbar.className = 'ls-brush-toolbar';
|
|
Object.assign(this.toolbar.style, {
|
|
position: 'fixed', display: 'none', zIndex: '10002',
|
|
backgroundColor: 'rgba(40, 40, 40, 0.9)', border: '1px solid #555',
|
|
borderRadius: '8px', padding: '8px', alignItems: 'center',
|
|
gap: '6px', color: 'white'
|
|
});
|
|
this.toolbar.innerHTML = `
|
|
<label>Size:</label>
|
|
<input type="range" min="1" max="200" value="${this.settings.size}" data-setting="size" style="width: 80px;">
|
|
<span style="min-width: 25px;" data-value="size">${this.settings.size}</span>
|
|
<label style="margin-left: 10px;">Color:</label>
|
|
<input type="color" value="${this.settings.color}" data-setting="color">
|
|
<button data-tool="eyedropper" title="Pipette" class="ls-tool-button" style="width: 32px; height: 32px; padding: 2px; font-size: 18px;">💧</button>
|
|
<button data-mode="brush" title="Brush" class="ls-tool-button" style="width: 32px; height: 32px; font-size: 18px;">🖌️</button>
|
|
<button data-mode="eraser" title="Eraser" class="ls-tool-button" style="width: 32px; height: 32px; font-size: 18px;">🧼</button>
|
|
<button data-action="apply" style="margin-left: 15px; background-color: #4CAF50; color: white; border: none; padding: 5px 10px; cursor: pointer;">Apply</button>
|
|
<button data-action="cancel" style="margin-left: 5px; background-color: #f44336; color: white; border: none; padding: 5px 10px; cursor: pointer;">Cancel</button>
|
|
`;
|
|
document.body.appendChild(this.toolbar);
|
|
|
|
this.toolbar.addEventListener('input', (e) => {
|
|
if (e.target.dataset.setting === 'size') {
|
|
this.settings.size = parseInt(e.target.value, 10);
|
|
this.toolbar.querySelector('span[data-value="size"]').textContent = this.settings.size;
|
|
}
|
|
if (e.target.dataset.setting === 'color') {
|
|
this.settings.color = e.target.value;
|
|
}
|
|
});
|
|
this.toolbar.addEventListener('click', (e) => {
|
|
const target = e.target.closest('button');
|
|
if (!target) return;
|
|
|
|
if (target.dataset.mode) this._updateToolState({ mode: target.dataset.mode });
|
|
if (target.dataset.tool === 'eyedropper') this._updateToolState({ tool: 'eyedropper' });
|
|
if (target.dataset.action === 'apply') this.finalizeDrawing();
|
|
if (target.dataset.action === 'cancel') this.hide();
|
|
});
|
|
}
|
|
|
|
_updateToolState({ mode = null, tool = null }) {
|
|
this.isEyedropperActive = (tool === 'eyedropper');
|
|
if (mode) {
|
|
this.settings.mode = mode;
|
|
}
|
|
|
|
if (this.isEyedropperActive) {
|
|
this.node.previewCanvas.style.cursor = 'copy';
|
|
} else {
|
|
this.node.previewCanvas.style.cursor = 'crosshair';
|
|
}
|
|
|
|
this.toolbar.querySelectorAll('.ls-tool-button').forEach(b => b.classList.remove('active'));
|
|
|
|
if (this.isEyedropperActive) {
|
|
this.toolbar.querySelector('[data-tool="eyedropper"]').classList.add('active');
|
|
} else {
|
|
this.toolbar.querySelector(`[data-mode="${this.settings.mode}"]`).classList.add('active');
|
|
}
|
|
}
|
|
|
|
activateEyedropper() {
|
|
this.isEyedropperActive = true;
|
|
}
|
|
|
|
pickColor(e) {
|
|
const ctx = this.node.previewCanvas.getContext('2d', { willReadFrequently: true });
|
|
const pixelData = ctx.getImageData(e.offsetX, e.offsetY, 1, 1).data;
|
|
const hexColor = `#${("00" + pixelData[0].toString(16)).slice(-2)}${("00" + pixelData[1].toString(16)).slice(-2)}${("00" + pixelData[2].toString(16)).slice(-2)}`;
|
|
|
|
this.settings.color = hexColor;
|
|
this.toolbar.querySelector('input[data-setting="color"]').value = hexColor;
|
|
|
|
this.isEyedropperActive = false;
|
|
this.settings.mode = 'brush';
|
|
this._updateToolState({ mode: 'brush' });
|
|
}
|
|
|
|
show() {
|
|
if (this.liveDrawingOverlay) {
|
|
this.liveDrawingOverlay.remove();
|
|
this.liveDrawingOverlay = null;
|
|
this.liveDrawingCtx = null;
|
|
}
|
|
this.activeLayer = this.node.getActiveLayer();
|
|
if (!this.activeLayer) {
|
|
alert("Please select a layer to draw on.");
|
|
if (this.node.toolbar.activeTool === 'brush') this.node.toolbar.activeTool = null;
|
|
this.node.refreshUI();
|
|
return;
|
|
}
|
|
|
|
const layerImage = this.node.loaded_preview_images[this.activeLayer.name];
|
|
if (!layerImage || !layerImage.naturalWidth) return;
|
|
|
|
this.finalDrawingCanvas.width = layerImage.naturalWidth;
|
|
this.finalDrawingCanvas.height = layerImage.naturalHeight;
|
|
this.finalDrawingCtx.clearRect(0, 0, this.finalDrawingCanvas.width, this.finalDrawingCanvas.height);
|
|
|
|
const preview = this.node.previewCanvas;
|
|
const container = preview.parentElement;
|
|
if (!container) return;
|
|
|
|
this.liveDrawingOverlay = document.createElement('canvas');
|
|
|
|
Object.assign(this.liveDrawingOverlay.style, {
|
|
position: 'absolute', top: '0', left: '0',
|
|
zIndex: '10001', pointerEvents: 'auto',
|
|
width: '100%', height: '100%'
|
|
});
|
|
|
|
this.liveDrawingOverlay.width = preview.width;
|
|
this.liveDrawingOverlay.height = preview.height;
|
|
this.liveDrawingCtx = this.liveDrawingOverlay.getContext('2d');
|
|
|
|
container.appendChild(this.liveDrawingOverlay);
|
|
|
|
this.liveDrawingOverlay.addEventListener('mousedown', this.boundHandleMouseEvent);
|
|
this.liveDrawingOverlay.addEventListener('mousemove', this.boundHandleMouseEvent);
|
|
this.liveDrawingOverlay.addEventListener('mouseup', this.boundHandleMouseEvent);
|
|
this.liveDrawingOverlay.addEventListener('mouseleave', this.boundHandleMouseEvent);
|
|
|
|
this.toolbar.style.display = 'flex';
|
|
this._updateToolState({ mode: 'brush' });
|
|
this.positionToolbar();
|
|
}
|
|
|
|
hide() {
|
|
this.toolbar.style.display = 'none';
|
|
|
|
if (this.liveDrawingOverlay) {
|
|
this.liveDrawingOverlay.style.cursor = 'default';
|
|
this.liveDrawingOverlay.removeEventListener('mousedown', this.boundHandleMouseEvent);
|
|
this.liveDrawingOverlay.removeEventListener('mousemove', this.boundHandleMouseEvent);
|
|
this.liveDrawingOverlay.removeEventListener('mouseup', this.boundHandleMouseEvent);
|
|
this.liveDrawingOverlay.removeEventListener('mouseleave', this.boundHandleMouseEvent);
|
|
this.liveDrawingOverlay.remove();
|
|
this.liveDrawingOverlay = null;
|
|
this.liveDrawingCtx = null;
|
|
}
|
|
if (this.node.toolbar.activeTool === 'brush') {
|
|
this.node.toolbar.activeTool = null;
|
|
this.node.refreshUI();
|
|
}
|
|
}
|
|
|
|
handleMouseEvent(e) {
|
|
this.liveDrawingOverlay.style.cursor = 'crosshair';
|
|
const mainCanvasRect = this.node.previewCanvas.getBoundingClientRect();
|
|
const zoom = app.canvas.ds.scale || 1;
|
|
const clickX_on_main_canvas = (e.clientX - mainCanvasRect.left) / zoom;
|
|
const clickY_on_main_canvas = (e.clientY - mainCanvasRect.top) / zoom;
|
|
|
|
if (e.type === 'mousedown' && this.node.toolbar.isClickOnToolbar(clickX_on_main_canvas, clickY_on_main_canvas)) {
|
|
this.node.toolbar.handleClick(e, clickX_on_main_canvas, clickY_on_main_canvas);
|
|
return;
|
|
}
|
|
|
|
const activeLayer = this.node.getActiveLayer();
|
|
if (!activeLayer) return;
|
|
|
|
if (this.isEyedropperActive) {
|
|
this.liveDrawingOverlay.style.cursor = 'copy';
|
|
if (e.type === 'mousedown') {
|
|
this.pickColor(e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
|
|
const props = this.node.layer_properties[activeLayer.name];
|
|
const layerImage = this.node.loaded_preview_images[activeLayer.name];
|
|
const preview = this.node.previewCanvas;
|
|
const toolbar = this.node.toolbar;
|
|
const baseImage = this.node.basePreviewImage;
|
|
if (!props || !layerImage || !preview || !toolbar || !baseImage) return;
|
|
|
|
const previewCanvasScale = (preview.width - toolbar.width) / baseImage.naturalWidth;
|
|
const imageAreaCenterX = toolbar.width + (preview.width - toolbar.width) / 2;
|
|
const imageAreaCenterY = preview.height / 2;
|
|
const transformedWidth = layerImage.naturalWidth * props.scale * previewCanvasScale;
|
|
const transformedHeight = layerImage.naturalHeight * props.scale * previewCanvasScale;
|
|
const centerX = imageAreaCenterX + (props.offset_x * previewCanvasScale);
|
|
const centerY = imageAreaCenterY + (props.offset_y * previewCanvasScale);
|
|
const clickX = e.offsetX;
|
|
const clickY = e.offsetY;
|
|
const dx = clickX - centerX;
|
|
const dy = clickY - centerY;
|
|
const angleRad = - (props.rotation || 0) * Math.PI / 180;
|
|
const unrotatedDx = dx * Math.cos(angleRad) - dy * Math.sin(angleRad);
|
|
const unrotatedDy = dx * Math.sin(angleRad) + dy * Math.cos(angleRad);
|
|
const localX = unrotatedDx + transformedWidth / 2;
|
|
const localY = unrotatedDy + transformedHeight / 2;
|
|
const originalX = localX / (transformedWidth / layerImage.naturalWidth);
|
|
const originalY = localY / (transformedHeight / layerImage.naturalHeight);
|
|
const coords = { x: originalX, y: originalY };
|
|
|
|
switch (e.type) {
|
|
case 'mousedown':
|
|
this.isDrawing = true;
|
|
this.lastPoint = coords;
|
|
break;
|
|
case 'mousemove':
|
|
if (this.isDrawing) {
|
|
this.draw(coords);
|
|
this.lastPoint = coords;
|
|
}
|
|
break;
|
|
case 'mouseup':
|
|
case 'mouseleave':
|
|
this.isDrawing = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
draw(coords) {
|
|
this.finalDrawingCtx.beginPath();
|
|
this.finalDrawingCtx.moveTo(this.lastPoint.x, this.lastPoint.y);
|
|
this.finalDrawingCtx.lineTo(coords.x, coords.y);
|
|
this.finalDrawingCtx.lineCap = 'round';
|
|
this.finalDrawingCtx.lineJoin = 'round';
|
|
this.finalDrawingCtx.lineWidth = this.settings.size;
|
|
if (this.settings.mode === 'eraser') {
|
|
this.finalDrawingCtx.globalCompositeOperation = 'destination-out';
|
|
} else {
|
|
this.finalDrawingCtx.globalCompositeOperation = 'source-over';
|
|
this.finalDrawingCtx.strokeStyle = this.settings.color;
|
|
}
|
|
this.finalDrawingCtx.stroke();
|
|
this.updatePreviewOverlay();
|
|
}
|
|
|
|
updatePreviewOverlay() {
|
|
if (!this.liveDrawingCtx) return;
|
|
this.liveDrawingCtx.clearRect(0, 0, this.liveDrawingOverlay.width, this.liveDrawingOverlay.height);
|
|
|
|
const props = this.node.layer_properties[this.activeLayer.name];
|
|
const layerImage = this.node.loaded_preview_images[this.activeLayer.name];
|
|
const preview = this.node.previewCanvas;
|
|
const toolbar = this.node.toolbar;
|
|
const baseImage = this.node.basePreviewImage;
|
|
if (!props || !layerImage || !preview || !toolbar || !baseImage) return;
|
|
|
|
const previewCanvasScale = (preview.width - toolbar.width) / baseImage.naturalWidth;
|
|
const imageAreaCenterX = toolbar.width + (preview.width - toolbar.width) / 2;
|
|
const imageAreaCenterY = preview.height / 2;
|
|
const transformedWidth = layerImage.naturalWidth * props.scale * previewCanvasScale;
|
|
const transformedHeight = layerImage.naturalHeight * props.scale * previewCanvasScale;
|
|
const centerX = imageAreaCenterX + (props.offset_x * previewCanvasScale);
|
|
const centerY = imageAreaCenterY + (props.offset_y * previewCanvasScale);
|
|
const angleRad = (props.rotation || 0) * Math.PI / 180;
|
|
|
|
this.liveDrawingCtx.save();
|
|
this.liveDrawingCtx.translate(centerX, centerY);
|
|
this.liveDrawingCtx.rotate(angleRad);
|
|
this.liveDrawingCtx.drawImage(this.finalDrawingCanvas, -transformedWidth / 2, -transformedHeight / 2, transformedWidth, transformedHeight);
|
|
this.liveDrawingCtx.restore();
|
|
}
|
|
|
|
async finalizeDrawing() {
|
|
const applyButton = this.toolbar.querySelector('[data-action="apply"]');
|
|
applyButton.textContent = "Applying...";
|
|
applyButton.disabled = true;
|
|
try {
|
|
const props = this.node.layer_properties[this.activeLayer.name];
|
|
const originalImage = this.node.loaded_preview_images[this.activeLayer.name];
|
|
const finalCanvas = document.createElement('canvas');
|
|
finalCanvas.width = originalImage.naturalWidth;
|
|
finalCanvas.height = originalImage.naturalHeight;
|
|
const finalCtx = finalCanvas.getContext('2d');
|
|
finalCtx.drawImage(originalImage, 0, 0);
|
|
|
|
finalCtx.drawImage(this.finalDrawingCanvas, 0, 0);
|
|
|
|
const blob = await new Promise(resolve => finalCanvas.toBlob(resolve, 'image/png'));
|
|
const file = new File([blob], props.source_filename, { type: 'image/png' });
|
|
const formData = new FormData();
|
|
formData.append('image', file);
|
|
formData.append('overwrite', 'true');
|
|
formData.append('type', 'input');
|
|
await fetch('/upload/image', { method: 'POST', body: formData });
|
|
|
|
const newPreviewImage = new Image();
|
|
newPreviewImage.src = finalCanvas.toDataURL();
|
|
await new Promise(resolve => newPreviewImage.onload = resolve);
|
|
this.node.loaded_preview_images[this.activeLayer.name] = newPreviewImage;
|
|
this.node.redrawPreviewCanvas();
|
|
|
|
} catch(e) {
|
|
console.error("Failed to apply drawing:", e);
|
|
} finally {
|
|
applyButton.textContent = "Apply";
|
|
applyButton.disabled = false;
|
|
this.hide();
|
|
}
|
|
}
|
|
|
|
positionToolbar() {
|
|
if (!this.node.previewCanvas) return;
|
|
const canvasRect = this.node.previewCanvas.getBoundingClientRect();
|
|
this.toolbar.style.left = `${canvasRect.left}px`;
|
|
this.toolbar.style.top = `${canvasRect.top - this.toolbar.offsetHeight - 5}px`;
|
|
}
|
|
} |