2220 lines
89 KiB
JavaScript
2220 lines
89 KiB
JavaScript
import { ICONS, HSL_COLORS } from './constants.js';
|
|
import { clamp01, clamp255, rgbToHsl, hslToRgb, hueDistance } from './color.js';
|
|
import { CurveEditor } from './curve.js';
|
|
|
|
export class ImageEditor {
|
|
constructor(imageSrc, saveCallback) {
|
|
this.imageSrc = imageSrc;
|
|
this.saveCallback = saveCallback;
|
|
|
|
// State
|
|
this.params = {
|
|
exposure: 0, // -100 to 100
|
|
contrast: 0, // -100 to 100
|
|
saturation: 0, // -100 to 100
|
|
temp: 0, // -100 to 100
|
|
tint: 0, // -100 to 100
|
|
vibrance: 0, // -100 to 100
|
|
hue: 0, // -180 to 180
|
|
highlight: 0, // -100 to 100
|
|
shadow: 0, // -100 to 100
|
|
blur: 0, // 0 to 100
|
|
noise: 0, // 0 to 100
|
|
grain: 0, // 0 to 100
|
|
clarity: 0, // -100 to 100
|
|
dehaze: 0, // -100 to 100
|
|
hslHue: 0, // -180 to 180 (current selection)
|
|
hslSaturation: 0, // -100 to 100 (current selection)
|
|
hslLightness: 0 // -100 to 100 (current selection)
|
|
};
|
|
this.activeHSLColor = HSL_COLORS[0]?.id || null;
|
|
this.hslAdjustments = this.getDefaultHSLAdjustments();
|
|
this.curveEditor = null;
|
|
|
|
this.history = [];
|
|
this.historyIndex = -1;
|
|
|
|
this.zoom = 1;
|
|
this.pan = { x: 0, y: 0 };
|
|
this.isDragging = false;
|
|
this.lastMousePos = { x: 0, y: 0 };
|
|
|
|
this.isCropping = false;
|
|
this.cropStart = null;
|
|
this.cropRect = null;
|
|
this.activeHandle = null;
|
|
|
|
this.isBrushing = false;
|
|
this.brushSize = 20;
|
|
this.brushOpacity = 50;
|
|
this.brushColor = '#ff0000';
|
|
this.brushStrokes = [];
|
|
this.currentStroke = null;
|
|
this.brushCursorPosImage = null;
|
|
|
|
this.isPenActive = false;
|
|
this.penPaths = [];
|
|
this.currentPath = null;
|
|
this.activePointIndex = -1;
|
|
this.activeHandle = null; // 'in' or 'out'
|
|
this.pendingClosePath = false;
|
|
this.penFillColor = '#ff0000';
|
|
this.penFillOpacity = 50;
|
|
this.penCursorPosImage = null;
|
|
this.penDashOffset = 0;
|
|
this.penAnimationFrame = null;
|
|
|
|
this.createUI();
|
|
this.loadImage();
|
|
}
|
|
|
|
createUI() {
|
|
this.overlay = document.createElement("div");
|
|
this.overlay.className = "apix-overlay";
|
|
|
|
this.overlay.innerHTML = `
|
|
<!-- Left Sidebar -->
|
|
<div class="apix-sidebar-left">
|
|
<button class="apix-tool-btn apix-mode-btn active" id="tool-adjust" title="Adjustments">${ICONS.adjust}</button>
|
|
<button class="apix-tool-btn apix-mode-btn" id="tool-crop" title="Crop">${ICONS.crop}</button>
|
|
<button class="apix-tool-btn apix-mode-btn" id="tool-brush" title="Brush">${ICONS.brush}</button>
|
|
<button class="apix-tool-btn apix-mode-btn" id="tool-pen" title="Pen Tool">${ICONS.pen}</button>
|
|
<div class="apix-sidebar-divider"></div>
|
|
<button class="apix-tool-btn icon-only" id="flip-btn-horizontal" title="Flip Horizontal">${ICONS.flipH}</button>
|
|
<button class="apix-tool-btn icon-only" id="flip-btn-vertical" title="Flip Vertical">${ICONS.flipV}</button>
|
|
<button class="apix-tool-btn icon-only" id="rotate-btn-90" title="Rotate 90 degrees">${ICONS.rotate}</button>
|
|
</div>
|
|
|
|
<!-- Main Area -->
|
|
<div class="apix-main-area">
|
|
<div class="apix-header">
|
|
<div class="apix-header-title">
|
|
<span>Image Editor</span>
|
|
</div>
|
|
<div style="display:flex; gap:10px;">
|
|
<button class="apix-tool-btn" id="action-undo" title="Undo" disabled>${ICONS.undo}</button>
|
|
<button class="apix-tool-btn" id="action-redo" title="Redo" disabled>${ICONS.redo}</button>
|
|
<div style="width:1px; background:var(--apix-border); margin:0 5px;"></div>
|
|
<button class="apix-btn apix-btn-secondary" id="action-reset">Reset All</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="apix-canvas-container" id="canvas-container">
|
|
<canvas id="editor-canvas"></canvas>
|
|
<div id="crop-box" class="apix-crop-overlay">
|
|
<div class="apix-crop-handle handle-tl" data-handle="tl"></div>
|
|
<div class="apix-crop-handle handle-tr" data-handle="tr"></div>
|
|
<div class="apix-crop-handle handle-bl" data-handle="bl"></div>
|
|
<div class="apix-crop-handle handle-br" data-handle="br"></div>
|
|
<div class="apix-crop-handle handle-t" data-handle="t"></div>
|
|
<div class="apix-crop-handle handle-b" data-handle="b"></div>
|
|
<div class="apix-crop-handle handle-l" data-handle="l"></div>
|
|
<div class="apix-crop-handle handle-r" data-handle="r"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="apix-bottom-bar">
|
|
<button class="apix-tool-btn" style="width:24px;height:24px;" id="zoom-out">-</button>
|
|
<span id="zoom-level">100%</span>
|
|
<button class="apix-tool-btn" style="width:24px;height:24px;" id="zoom-in">+</button>
|
|
<button class="apix-btn apix-btn-secondary" style="padding:2px 8px; font-size:10px; margin-left:10px;" id="zoom-fit">Fit</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Sidebar -->
|
|
<div class="apix-sidebar-right" id="sidebar-right">
|
|
<div class="apix-sidebar-scroll">
|
|
<!-- Crop Controls (Hidden by default) -->
|
|
<div class="apix-panel-content hidden" id="panel-crop-controls" style="flex:1;">
|
|
<div class="apix-control-row">
|
|
<label class="apix-control-label">Aspect Ratio</label>
|
|
<select id="crop-aspect" style="background:#333; color:#fff; border:none; padding:8px; border-radius:4px; width:100%;">
|
|
<option value="free">Free</option>
|
|
<option value="1">1:1 (Square)</option>
|
|
<option value="1.777">16:9</option>
|
|
<option value="0.5625">9:16</option>
|
|
<option value="1.333">4:3</option>
|
|
<option value="0.75">3:4</option>
|
|
<option value="1.5">3:2</option>
|
|
<option value="0.666">2:3</option>
|
|
<option value="0.8">4:5</option>
|
|
<option value="1.25">5:4</option>
|
|
</select>
|
|
</div>
|
|
<div style="margin-top:20px; display:flex; gap:10px;">
|
|
<button class="apix-btn apix-btn-secondary" style="flex:1" id="crop-cancel">Cancel</button>
|
|
<button class="apix-btn apix-btn-primary" style="flex:1" id="crop-apply">Apply Crop</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Brush Controls (Hidden by default) -->
|
|
<div class="apix-panel-content hidden" id="panel-brush-controls" style="flex:1;">
|
|
${this.renderSlider("Size", "brush-size", 5, 100, this.brushSize)}
|
|
${this.renderSlider("Opacity", "brush-opacity", 0, 100, this.brushOpacity)}
|
|
<div class="apix-control-row">
|
|
<label class="apix-control-label">Color</label>
|
|
<input type="color" id="brush-color" value="${this.brushColor}" style="width:100%; height:40px; border:none; border-radius:4px; cursor:pointer;">
|
|
</div>
|
|
<div style="margin-top:20px; display:flex; gap:10px;">
|
|
<button class="apix-btn apix-btn-secondary" style="flex:1" id="brush-clear">Clear Strokes</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pen Tool Controls (Hidden by default) -->
|
|
<div class="apix-panel-content hidden" id="panel-pen-controls" style="flex:1;">
|
|
<div class="apix-control-row">
|
|
<label class="apix-control-label">Fill Color</label>
|
|
<input type="color" id="pen-fill-color" value="${this.penFillColor}" style="width:100%; height:40px; border:none; border-radius:4px; cursor:pointer;">
|
|
</div>
|
|
${this.renderSlider("Fill Opacity", "pen-opacity", 0, 100, this.penFillOpacity)}
|
|
<div style="margin-top:20px; display:flex; flex-direction:column; gap:10px;">
|
|
<button class="apix-btn apix-btn-primary" id="pen-fill-apply">Fill Color</button>
|
|
<button class="apix-btn apix-btn-secondary" id="pen-close-path">Close Path</button>
|
|
<button class="apix-btn apix-btn-secondary" id="pen-clear-path">Clear Paths</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Light -->
|
|
<div class="apix-panel-section">
|
|
<div class="apix-panel-header" data-target="panel-light">
|
|
<span>Light</span>
|
|
${ICONS.chevronDown}
|
|
</div>
|
|
<div class="apix-panel-content" id="panel-light">
|
|
${this.renderSlider("Exposure", "exposure", -100, 100, 0)}
|
|
${this.renderSlider("Contrast", "contrast", -100, 100, 0)}
|
|
${this.renderSlider("Highlights", "highlight", -100, 100, 0)}
|
|
${this.renderSlider("Shadows", "shadow", -100, 100, 0)}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Color -->
|
|
<div class="apix-panel-section">
|
|
<div class="apix-panel-header" data-target="panel-color">
|
|
<span>Color</span>
|
|
${ICONS.chevronDown}
|
|
</div>
|
|
<div class="apix-panel-content" id="panel-color">
|
|
${this.renderSlider("Temp", "temp", -100, 100, 0)}
|
|
${this.renderSlider("Tint", "tint", -100, 100, 0)}
|
|
${this.renderSlider("Saturation", "saturation", -100, 100, 0)}
|
|
${this.renderSlider("Vibrance", "vibrance", -100, 100, 0)}
|
|
${this.renderSlider("Hue", "hue", -180, 180, 0)}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Curve -->
|
|
<div class="apix-panel-section">
|
|
<div class="apix-panel-header" data-target="panel-curve">
|
|
<span>Curve</span>
|
|
${ICONS.chevronDown}
|
|
</div>
|
|
<div class="apix-panel-content" id="panel-curve">
|
|
${this.renderCurvePanel()}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Effect -->
|
|
<div class="apix-panel-section">
|
|
<div class="apix-panel-header" data-target="panel-detail">
|
|
<span>Effect</span>
|
|
${ICONS.chevronDown}
|
|
</div>
|
|
<div class="apix-panel-content hidden" id="panel-detail">
|
|
${this.renderSlider("Blur", "blur", 0, 100, 0)}
|
|
${this.renderSlider("Noise", "noise", 0, 100, 0)}
|
|
${this.renderSlider("Grain", "grain", 0, 100, 0)}
|
|
${this.renderSlider("Clarity", "clarity", -100, 100, 0)}
|
|
${this.renderSlider("Dehaze", "dehaze", -100, 100, 0)}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- HSL -->
|
|
<div class="apix-panel-section">
|
|
<div class="apix-panel-header" data-target="panel-hsl">
|
|
<span>HSL</span>
|
|
${ICONS.chevronDown}
|
|
</div>
|
|
<div class="apix-panel-content hidden" id="panel-hsl">
|
|
${this.renderHSLSection()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="apix-footer">
|
|
<button class="apix-btn apix-btn-secondary" id="action-close">Cancel</button>
|
|
<div style="display:flex; gap:8px;">
|
|
<button class="apix-btn apix-btn-secondary" id="action-download">Download</button>
|
|
<button class="apix-btn apix-btn-primary" id="action-save">Save Image</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(this.overlay);
|
|
this.bindEvents();
|
|
}
|
|
|
|
renderSlider(label, id, min, max, val) {
|
|
return `
|
|
<div class="apix-control-row">
|
|
<div class="apix-control-label">
|
|
<span>${label}</span>
|
|
<div class="apix-slider-meta">
|
|
<span id="val-${id}">${val}</span>
|
|
</div>
|
|
</div>
|
|
<div class="apix-slider-wrapper">
|
|
<input type="range" class="apix-slider" id="param-${id}" min="${min}" max="${max}" value="${val}" data-default="${val}">
|
|
<button type="button" class="apix-slider-reset" data-slider="param-${id}" data-default="${val}" title="Reset ${label}" aria-label="Reset ${label}">${ICONS.reset}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderCurvePanel() {
|
|
return `
|
|
<div class="apix-curve-panel">
|
|
<div class="apix-curve-toolbar">
|
|
<span>Adjust</span>
|
|
<div class="apix-curve-channel-buttons">
|
|
<button type="button" class="apix-curve-channel-btn active" data-curve-channel="rgb" title="RGB Curve">RGB</button>
|
|
<button type="button" class="apix-curve-channel-btn" data-curve-channel="r" title="Red Curve">R</button>
|
|
<button type="button" class="apix-curve-channel-btn" data-curve-channel="g" title="Green Curve">G</button>
|
|
<button type="button" class="apix-curve-channel-btn" data-curve-channel="b" title="Blue Curve">B</button>
|
|
</div>
|
|
<button type="button" class="apix-curve-reset" id="curve-reset">Reset</button>
|
|
</div>
|
|
<div class="apix-curve-stage">
|
|
<canvas id="curve-canvas" width="240" height="240"></canvas>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderHSLSection() {
|
|
if (!this.activeHSLColor && HSL_COLORS.length) {
|
|
this.activeHSLColor = HSL_COLORS[0].id;
|
|
}
|
|
const swatches = HSL_COLORS.map(color => `
|
|
<button type="button" class="apix-hsl-chip${color.id === this.activeHSLColor ? " active" : ""}" data-color="${color.id}" style="--chip-color:${color.color}" title="${color.label}"></button>
|
|
`).join("");
|
|
return `
|
|
<div class="apix-hsl">
|
|
<div class="apix-hsl-swatches">${swatches}</div>
|
|
${this.renderHSLSlider("Hue", "h", -180, 180, this.params.hslHue)}
|
|
${this.renderHSLSlider("Saturation", "s", -100, 100, this.params.hslSaturation)}
|
|
${this.renderHSLSlider("Luminance", "l", -100, 100, this.params.hslLightness)}
|
|
<div class="apix-hsl-actions">
|
|
<span id="hsl-active-label">${this.getActiveHSLLabel()}</span>
|
|
<button type="button" class="apix-hsl-reset" id="hsl-reset">Reset</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderHSLSlider(label, key, min, max, val) {
|
|
return `
|
|
<div class="apix-control-row apix-hsl-slider">
|
|
<div class="apix-control-label">
|
|
<span>${label}</span>
|
|
<div class="apix-slider-meta">
|
|
<span id="val-hsl-${key}">${val}</span>
|
|
</div>
|
|
</div>
|
|
<div class="apix-slider-wrapper">
|
|
<input type="range" class="apix-slider" id="hsl-slider-${key}" min="${min}" max="${max}" value="${val}" data-default="0">
|
|
<button type="button" class="apix-slider-reset" data-slider="hsl-slider-${key}" data-default="0" data-hsl="true" data-hsl-key="${key}" title="Reset ${label}" aria-label="Reset ${label}">${ICONS.reset}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
getActiveHSLLabel() {
|
|
const active = HSL_COLORS.find(c => c.id === this.activeHSLColor);
|
|
return active ? active.label : (HSL_COLORS[0]?.label || "Color");
|
|
}
|
|
|
|
bindEvents() {
|
|
this.canvas = this.overlay.querySelector("#editor-canvas");
|
|
this.ctx = this.canvas.getContext("2d");
|
|
this.container = this.overlay.querySelector("#canvas-container");
|
|
this.cropBox = this.overlay.querySelector("#crop-box");
|
|
|
|
// Sliders
|
|
const hslKeys = new Set(["hslHue", "hslSaturation", "hslLightness"]);
|
|
Object.keys(this.params).forEach(key => {
|
|
if (hslKeys.has(key)) return;
|
|
const slider = this.overlay.querySelector(`#param-${key}`);
|
|
if (!slider) return;
|
|
slider.oninput = (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
this.params[key] = val;
|
|
const display = this.overlay.querySelector(`#val-${key}`);
|
|
if (display) display.textContent = val;
|
|
this.requestRender();
|
|
};
|
|
slider.onchange = () => this.pushHistory(); // Save state on release
|
|
});
|
|
this.bindHSLControls();
|
|
this.initCurveEditor();
|
|
|
|
// Accordions
|
|
this.overlay.querySelectorAll(".apix-panel-header").forEach(header => {
|
|
header.onclick = () => {
|
|
const targetId = header.dataset.target;
|
|
const content = this.overlay.querySelector(`#${targetId}`);
|
|
const isHidden = content.classList.contains("hidden");
|
|
// Close all first (optional, mimicking accordion)
|
|
// this.overlay.querySelectorAll(".apix-panel-content").forEach(c => c.classList.add("hidden"));
|
|
content.classList.toggle("hidden", !isHidden);
|
|
header.querySelector("svg").style.transform = isHidden ? "rotate(180deg)" : "rotate(0deg)";
|
|
};
|
|
});
|
|
|
|
// Tools
|
|
this.overlay.querySelector("#tool-crop").onclick = () => this.toggleMode('crop');
|
|
this.overlay.querySelector("#tool-adjust").onclick = () => this.toggleMode('adjust');
|
|
this.overlay.querySelector("#tool-brush").onclick = () => this.toggleMode('brush');
|
|
this.overlay.querySelector("#tool-pen").onclick = () => this.toggleMode('pen');
|
|
|
|
// Crop Actions
|
|
this.overlay.querySelector("#crop-apply").onclick = () => this.applyCrop();
|
|
this.overlay.querySelector("#crop-cancel").onclick = () => this.toggleMode('adjust');
|
|
|
|
// Brush Controls
|
|
const brushSizeSlider = this.overlay.querySelector("#param-brush-size");
|
|
if (brushSizeSlider) {
|
|
brushSizeSlider.oninput = (e) => {
|
|
this.brushSize = parseFloat(e.target.value);
|
|
const display = this.overlay.querySelector("#val-brush-size");
|
|
if (display) display.textContent = this.brushSize;
|
|
this.requestRender();
|
|
};
|
|
}
|
|
|
|
const brushOpacitySlider = this.overlay.querySelector("#param-brush-opacity");
|
|
if (brushOpacitySlider) {
|
|
brushOpacitySlider.oninput = (e) => {
|
|
this.brushOpacity = parseFloat(e.target.value);
|
|
const display = this.overlay.querySelector("#val-brush-opacity");
|
|
if (display) display.textContent = this.brushOpacity;
|
|
};
|
|
}
|
|
|
|
const brushColorPicker = this.overlay.querySelector("#brush-color");
|
|
if (brushColorPicker) {
|
|
brushColorPicker.oninput = (e) => {
|
|
this.brushColor = e.target.value;
|
|
};
|
|
}
|
|
|
|
const brushClearBtn = this.overlay.querySelector("#brush-clear");
|
|
if (brushClearBtn) {
|
|
brushClearBtn.onclick = () => {
|
|
this.brushStrokes = [];
|
|
this.requestRender();
|
|
this.pushHistory();
|
|
};
|
|
}
|
|
|
|
// Pen Controls
|
|
const penFillColorPicker = this.overlay.querySelector("#pen-fill-color");
|
|
if (penFillColorPicker) {
|
|
penFillColorPicker.oninput = (e) => {
|
|
this.penFillColor = e.target.value;
|
|
if (this.currentPath) this.currentPath.fillColor = this.penFillColor;
|
|
this.requestRender();
|
|
};
|
|
}
|
|
const penOpacitySlider = this.overlay.querySelector("#param-pen-opacity");
|
|
if (penOpacitySlider) {
|
|
penOpacitySlider.oninput = (e) => {
|
|
this.penFillOpacity = parseInt(e.target.value, 10);
|
|
this.overlay.querySelector("#val-pen-opacity").textContent = this.penFillOpacity;
|
|
if (this.currentPath) this.currentPath.fillOpacity = this.penFillOpacity;
|
|
this.requestRender();
|
|
};
|
|
}
|
|
const penFillPathBtn = this.overlay.querySelector("#pen-fill-apply");
|
|
if (penFillPathBtn) penFillPathBtn.onclick = () => this.fillCurrentPath();
|
|
const penClosePathBtn = this.overlay.querySelector("#pen-close-path");
|
|
if (penClosePathBtn) penClosePathBtn.onclick = () => this.closeCurrentPath();
|
|
const penClearPathBtn = this.overlay.querySelector("#pen-clear-path");
|
|
if (penClearPathBtn) {
|
|
penClearPathBtn.onclick = () => {
|
|
this.penPaths = [];
|
|
this.currentPath = null;
|
|
this.penCursorPosImage = null;
|
|
this.stopPenAnimation();
|
|
this.requestRender();
|
|
this.pushHistory();
|
|
};
|
|
}
|
|
|
|
// Zoom/Pan
|
|
this.container.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
this.zoom = Math.max(0.1, Math.min(10, this.zoom * delta));
|
|
this.updateZoomDisplay();
|
|
this.requestRender();
|
|
});
|
|
|
|
this.container.addEventListener('mousedown', (e) => {
|
|
if (this.isCropping) {
|
|
this.handleCropStart(e);
|
|
} else if (this.isBrushing) {
|
|
this.handleBrushStart(e);
|
|
} else if (this.isPenActive) {
|
|
this.handlePenStart(e);
|
|
} else {
|
|
this.isDragging = true;
|
|
this.lastMousePos = { x: e.clientX, y: e.clientY };
|
|
this.container.style.cursor = 'grabbing';
|
|
}
|
|
});
|
|
|
|
window.addEventListener('mousemove', (e) => {
|
|
if (this.isPenActive) {
|
|
this.handlePenMove(e);
|
|
return;
|
|
}
|
|
|
|
if (this.isDragging) {
|
|
const dx = e.clientX - this.lastMousePos.x;
|
|
const dy = e.clientY - this.lastMousePos.y;
|
|
this.pan.x += dx;
|
|
this.pan.y += dy;
|
|
this.lastMousePos = { x: e.clientX, y: e.clientY };
|
|
this.requestRender();
|
|
} else if (this.isCropping) {
|
|
this.handleCropMove(e);
|
|
} else if (this.isBrushing && this.currentStroke) {
|
|
this.handleBrushMove(e);
|
|
}
|
|
|
|
// Update brush cursor position for preview circle
|
|
if (this.isBrushing) {
|
|
const screenPos = this.getCanvasMousePosition(e);
|
|
const imagePos = this.screenToImageCoords(screenPos);
|
|
this.brushCursorPosImage = imagePos;
|
|
this.requestRender();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('mouseup', () => {
|
|
this.isDragging = false;
|
|
this.container.style.cursor = this.isCropping ? 'crosshair' : this.isBrushing ? 'crosshair' : this.isPenActive ? 'crosshair' : 'grab';
|
|
if (this.isCropping) this.handleCropEnd();
|
|
if (this.isBrushing && this.currentStroke) this.handleBrushEnd();
|
|
if (this.isPenActive) this.handlePenEnd();
|
|
});
|
|
|
|
// Zoom Buttons
|
|
this.overlay.querySelector("#zoom-in").onclick = () => { this.zoom *= 1.2; this.updateZoomDisplay(); this.requestRender(); };
|
|
this.overlay.querySelector("#zoom-out").onclick = () => { this.zoom /= 1.2; this.updateZoomDisplay(); this.requestRender(); };
|
|
this.overlay.querySelector("#zoom-fit").onclick = () => this.fitCanvas();
|
|
|
|
// Transform buttons
|
|
this.overlay.querySelector("#flip-btn-horizontal").onclick = () => this.flipImage("horizontal");
|
|
this.overlay.querySelector("#flip-btn-vertical").onclick = () => this.flipImage("vertical");
|
|
this.overlay.querySelector("#rotate-btn-90").onclick = () => this.rotateImage(90);
|
|
|
|
// Main Actions
|
|
this.overlay.querySelector("#action-close").onclick = () => this.close();
|
|
this.overlay.querySelector("#action-save").onclick = () => this.save();
|
|
this.overlay.querySelector("#action-download").onclick = () => this.download();
|
|
this.overlay.querySelector("#action-reset").onclick = () => this.reset();
|
|
this.overlay.querySelector("#action-undo").onclick = () => this.undo();
|
|
this.overlay.querySelector("#action-redo").onclick = () => this.redo();
|
|
|
|
// Keyboard Shortcuts
|
|
window.addEventListener('keydown', (e) => {
|
|
if (this.overlay.style.display === 'none') return;
|
|
// Ignore if typing in an input
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
|
|
switch(e.key.toLowerCase()) {
|
|
case 'z':
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
this.redo();
|
|
} else {
|
|
this.undo();
|
|
}
|
|
return;
|
|
}
|
|
break;
|
|
case '[':
|
|
e.preventDefault();
|
|
this.adjustBrushSize(-1); // Decrease
|
|
break;
|
|
case ']':
|
|
e.preventDefault();
|
|
this.adjustBrushSize(1); // Increase
|
|
break;
|
|
case 'b':
|
|
this.toggleMode('brush');
|
|
break;
|
|
case 'c':
|
|
this.toggleMode('crop');
|
|
break;
|
|
case 'p':
|
|
this.toggleMode('pen');
|
|
break;
|
|
case 'r':
|
|
this.rotateImage(90);
|
|
break;
|
|
case 'd':
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
this.deselectPenSelection();
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
bindHSLControls() {
|
|
if (!this.overlay) return;
|
|
this.overlay.querySelectorAll(".apix-hsl-chip").forEach(btn => {
|
|
btn.onclick = () => {
|
|
this.activeHSLColor = btn.dataset.color;
|
|
this.syncHSLSliders();
|
|
this.updateHSLUI();
|
|
};
|
|
});
|
|
|
|
const hslMap = { h: "hslHue", s: "hslSaturation", l: "hslLightness" };
|
|
["h", "s", "l"].forEach(key => {
|
|
const slider = this.overlay.querySelector(`#hsl-slider-${key}`);
|
|
if (!slider) return;
|
|
slider.oninput = (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
const current = this.hslAdjustments[this.activeHSLColor];
|
|
current[key] = val;
|
|
this.params[hslMap[key]] = val;
|
|
const label = this.overlay.querySelector(`#val-hsl-${key}`);
|
|
if (label) label.textContent = val;
|
|
this.requestRender();
|
|
};
|
|
slider.onchange = () => this.pushHistory();
|
|
});
|
|
|
|
const resetBtn = this.overlay.querySelector("#hsl-reset");
|
|
if (resetBtn) {
|
|
resetBtn.onclick = () => {
|
|
this.resetCurrentHSL();
|
|
this.pushHistory();
|
|
};
|
|
}
|
|
|
|
this.syncHSLSliders();
|
|
this.updateHSLUI();
|
|
this.bindSliderResetButtons();
|
|
}
|
|
|
|
initCurveEditor() {
|
|
if (!this.overlay) return;
|
|
const canvas = this.overlay.querySelector("#curve-canvas");
|
|
if (!canvas) return;
|
|
const channelButtons = Array.from(this.overlay.querySelectorAll(".apix-curve-channel-btn"));
|
|
const resetBtn = this.overlay.querySelector("#curve-reset");
|
|
this.curveEditor = new CurveEditor({
|
|
canvas,
|
|
channelButtons,
|
|
resetButton: resetBtn,
|
|
onChange: () => this.requestRender(),
|
|
onCommit: () => this.pushHistory()
|
|
});
|
|
}
|
|
|
|
resetCurrentHSL() {
|
|
const current = this.hslAdjustments[this.activeHSLColor];
|
|
if (!current) return;
|
|
current.h = 0;
|
|
current.s = 0;
|
|
current.l = 0;
|
|
this.syncHSLSliders();
|
|
this.requestRender();
|
|
}
|
|
|
|
syncHSLSliders() {
|
|
const current = this.hslAdjustments[this.activeHSLColor];
|
|
if (!current || !this.overlay) return;
|
|
const map = { h: "hslHue", s: "hslSaturation", l: "hslLightness" };
|
|
["h", "s", "l"].forEach(key => {
|
|
const slider = this.overlay.querySelector(`#hsl-slider-${key}`);
|
|
if (slider) slider.value = current[key];
|
|
const label = this.overlay.querySelector(`#val-hsl-${key}`);
|
|
if (label) label.textContent = current[key];
|
|
this.params[map[key]] = current[key];
|
|
});
|
|
}
|
|
|
|
updateHSLUI() {
|
|
if (!this.overlay) return;
|
|
this.overlay.querySelectorAll(".apix-hsl-chip").forEach(btn => {
|
|
btn.classList.toggle("active", btn.dataset.color === this.activeHSLColor);
|
|
});
|
|
const label = this.overlay.querySelector("#hsl-active-label");
|
|
if (label) label.textContent = this.getActiveHSLLabel();
|
|
}
|
|
|
|
bindSliderResetButtons() {
|
|
if (!this.overlay) return;
|
|
const hslMap = { h: "hslHue", s: "hslSaturation", l: "hslLightness" };
|
|
this.overlay.querySelectorAll(".apix-slider-reset").forEach(btn => {
|
|
const sliderId = btn.dataset.slider;
|
|
const slider = this.overlay.querySelector(`#${sliderId}`);
|
|
if (!slider) return;
|
|
const defaultVal = parseFloat(btn.dataset.default ?? "0");
|
|
const isHSL = btn.dataset.hsl === "true";
|
|
btn.onclick = () => {
|
|
slider.value = defaultVal;
|
|
if (!isHSL) {
|
|
const paramKey = sliderId.replace("param-", "");
|
|
if (this.params.hasOwnProperty(paramKey)) {
|
|
this.params[paramKey] = defaultVal;
|
|
}
|
|
const valueLabel = this.overlay.querySelector(`#val-${paramKey}`);
|
|
if (valueLabel) valueLabel.textContent = defaultVal;
|
|
} else {
|
|
const key = btn.dataset.hslKey;
|
|
const current = this.hslAdjustments[this.activeHSLColor];
|
|
if (current) {
|
|
current[key] = defaultVal;
|
|
}
|
|
if (hslMap[key]) {
|
|
this.params[hslMap[key]] = defaultVal;
|
|
}
|
|
const display = this.overlay.querySelector(`#val-hsl-${key}`);
|
|
if (display) display.textContent = defaultVal;
|
|
}
|
|
this.requestRender();
|
|
this.pushHistory();
|
|
};
|
|
});
|
|
}
|
|
|
|
cloneHSLAdjustments(source = this.hslAdjustments) {
|
|
const clone = {};
|
|
Object.keys(source || {}).forEach(key => {
|
|
clone[key] = { ...(source[key] || { h: 0, s: 0, l: 0 }) };
|
|
});
|
|
return clone;
|
|
}
|
|
|
|
getDefaultHSLAdjustments() {
|
|
const defaults = {};
|
|
HSL_COLORS.forEach(color => {
|
|
defaults[color.id] = { h: 0, s: 0, l: 0 };
|
|
});
|
|
return defaults;
|
|
}
|
|
|
|
hasHSLAdjustments() {
|
|
return Object.keys(this.hslAdjustments || {}).some(key => {
|
|
const adj = this.hslAdjustments[key];
|
|
return adj && (adj.h || adj.s || adj.l);
|
|
});
|
|
}
|
|
|
|
shouldApplyPixelEffects() {
|
|
const p = this.params;
|
|
const totalNoise = Math.max(0, (p.noise || 0) + (p.grain || 0));
|
|
return totalNoise > 0 || this.hasHSLAdjustments() || p.clarity !== 0 || p.dehaze !== 0 || p.highlight !== 0 || p.shadow !== 0 || p.vibrance !== 0 || (this.curveEditor?.hasAdjustments() ?? false);
|
|
}
|
|
|
|
loadImage() {
|
|
this.originalImage = new Image();
|
|
this.originalImage.onload = () => {
|
|
this.currentImage = this.originalImage;
|
|
this.fitCanvas();
|
|
this.syncHSLSliders();
|
|
this.updateHSLUI();
|
|
this.pushHistory(); // Initial state
|
|
};
|
|
this.originalImage.src = this.imageSrc;
|
|
}
|
|
|
|
fitCanvas() {
|
|
if (!this.currentImage) return;
|
|
const containerW = this.container.clientWidth - 40;
|
|
const containerH = this.container.clientHeight - 40;
|
|
const scale = Math.min(containerW / this.currentImage.width, containerH / this.currentImage.height);
|
|
this.zoom = scale;
|
|
this.pan = { x: 0, y: 0 }; // Center
|
|
this.updateZoomDisplay();
|
|
this.requestRender();
|
|
}
|
|
|
|
updateZoomDisplay() {
|
|
this.overlay.querySelector("#zoom-level").textContent = Math.round(this.zoom * 100) + "%";
|
|
}
|
|
|
|
toggleMode(mode) {
|
|
const currentMode = this.isCropping ? 'crop' : this.isBrushing ? 'brush' : this.isPenActive ? 'pen' : 'adjust';
|
|
if (mode === currentMode) {
|
|
mode = 'adjust'; // toggle off if same tool pressed again
|
|
}
|
|
|
|
this.isCropping = mode === 'crop';
|
|
this.isBrushing = mode === 'brush';
|
|
this.isPenActive = mode === 'pen';
|
|
|
|
// Update UI
|
|
this.overlay.querySelectorAll(".apix-mode-btn").forEach(b => b.classList.remove("active"));
|
|
this.overlay.querySelector(`#tool-${mode}`).classList.add("active");
|
|
|
|
// Show/Hide Control Panels
|
|
const cropPanel = this.overlay.querySelector("#panel-crop-controls");
|
|
const brushPanel = this.overlay.querySelector("#panel-brush-controls");
|
|
const penPanel = this.overlay.querySelector("#panel-pen-controls");
|
|
|
|
cropPanel.classList.add("hidden");
|
|
brushPanel.classList.add("hidden");
|
|
penPanel.classList.add("hidden");
|
|
|
|
if (mode === 'crop') {
|
|
cropPanel.classList.remove("hidden");
|
|
// Scroll to crop controls
|
|
const rightSidebar = this.overlay.querySelector("#sidebar-right");
|
|
if (rightSidebar) rightSidebar.scrollTop = 0;
|
|
} else if (mode === 'brush') {
|
|
brushPanel.classList.remove("hidden");
|
|
const rightSidebar = this.overlay.querySelector("#sidebar-right");
|
|
if (rightSidebar) rightSidebar.scrollTop = 0;
|
|
this.requestRender();
|
|
} else if (mode === 'pen') {
|
|
penPanel.classList.remove("hidden");
|
|
const rightSidebar = this.overlay.querySelector("#sidebar-right");
|
|
if (rightSidebar) rightSidebar.scrollTop = 0;
|
|
}
|
|
|
|
this.container.style.cursor = (this.isCropping || this.isBrushing || this.isPenActive) ? 'crosshair' : 'grab';
|
|
|
|
// Hide crop box if not cropping
|
|
this.cropBox.style.display = 'none';
|
|
this.cropStart = null;
|
|
this.cropRect = null;
|
|
|
|
this.requestRender();
|
|
}
|
|
|
|
// --- Rendering ---
|
|
|
|
requestRender() {
|
|
if (!this.renderRequested) {
|
|
this.renderRequested = true;
|
|
requestAnimationFrame(() => {
|
|
this.render();
|
|
this.renderRequested = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
render() {
|
|
if (!this.currentImage) return;
|
|
|
|
const w = this.container.clientWidth;
|
|
const h = this.container.clientHeight;
|
|
this.canvas.width = w;
|
|
this.canvas.height = h;
|
|
|
|
// Clear
|
|
this.ctx.clearRect(0, 0, w, h);
|
|
|
|
// Calculate transformed position
|
|
const imageRect = this.getImageRect();
|
|
if (!imageRect) return;
|
|
const { x, y, width: imgW, height: imgH } = imageRect;
|
|
|
|
// Save context for transforms
|
|
this.ctx.save();
|
|
|
|
// 1. Apply Filters (CSS style for preview performance)
|
|
// Note: Canvas filter API is widely supported now
|
|
const p = this.params;
|
|
const clarityBoost = 1 + (p.clarity || 0) / 200;
|
|
const dehazeBoost = 1 + Math.max(0, p.dehaze || 0) / 200;
|
|
const brightness = 100 + p.exposure;
|
|
const contrast = Math.max(0, (100 + p.contrast) * clarityBoost * dehazeBoost);
|
|
let saturate = 100 + p.saturation;
|
|
if (p.dehaze > 0) {
|
|
saturate += p.dehaze * 0.3;
|
|
}
|
|
const hue = p.hue;
|
|
const blur = p.blur / 5; // Scale down
|
|
|
|
let filterString = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturate}%) hue-rotate(${hue}deg)`;
|
|
if (blur > 0) filterString += ` blur(${blur}px)`;
|
|
|
|
this.ctx.filter = filterString;
|
|
|
|
const drawX = x;
|
|
const drawY = y;
|
|
const drawW = imgW;
|
|
const drawH = imgH;
|
|
this.ctx.drawImage(this.currentImage, drawX, drawY, drawW, drawH);
|
|
this.ctx.filter = 'none';
|
|
|
|
const rect = { x: drawX, y: drawY, width: drawW, height: drawH };
|
|
|
|
// 2. Overlays (Temp/Tint)
|
|
if (p.temp !== 0 || p.tint !== 0) {
|
|
this.ctx.globalCompositeOperation = 'overlay';
|
|
|
|
// Temp (Blue/Orange)
|
|
if (p.temp !== 0) {
|
|
this.ctx.fillStyle = p.temp > 0 ? `rgba(255, 160, 0, ${p.temp / 200})` : `rgba(0, 100, 255, ${Math.abs(p.temp) / 200})`;
|
|
this.ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
}
|
|
|
|
// Tint (Green/Magenta)
|
|
if (p.tint !== 0) {
|
|
this.ctx.fillStyle = p.tint > 0 ? `rgba(255, 0, 255, ${p.tint / 200})` : `rgba(0, 255, 0, ${Math.abs(p.tint) / 200})`;
|
|
this.ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
}
|
|
|
|
this.ctx.globalCompositeOperation = 'source-over';
|
|
}
|
|
|
|
if (this.shouldApplyPixelEffects()) {
|
|
this.applyPixelEffectsRegion(this.ctx, rect.x, rect.y, rect.width, rect.height);
|
|
}
|
|
|
|
// 3. Draw brush strokes
|
|
this.renderBrushStrokes(rect);
|
|
|
|
// 4. Draw brush cursor preview circle
|
|
if (this.isBrushing && this.brushCursorPosImage && this.currentImage) {
|
|
this.ctx.save();
|
|
this.ctx.strokeStyle = '#ffffff';
|
|
this.ctx.lineWidth = 2;
|
|
this.ctx.beginPath();
|
|
const cursorScreen = this.imageToScreenCoords(this.brushCursorPosImage);
|
|
const scale = rect.width / this.currentImage.width;
|
|
const effectiveSize = this.currentStroke && this.currentStroke.space === 'image'
|
|
? this.currentStroke.size * scale
|
|
: this.brushSize;
|
|
const radius = effectiveSize / 2;
|
|
this.ctx.arc(cursorScreen.x, cursorScreen.y, radius, 0, Math.PI * 2);
|
|
this.ctx.stroke();
|
|
|
|
this.ctx.strokeStyle = '#000000';
|
|
this.ctx.lineWidth = 1;
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(cursorScreen.x, cursorScreen.y, radius, 0, Math.PI * 2);
|
|
this.ctx.stroke();
|
|
this.ctx.restore();
|
|
}
|
|
|
|
// 5. Draw Pen Paths
|
|
this.renderPenPaths(rect);
|
|
|
|
this.ctx.restore();
|
|
}
|
|
|
|
flipImage(direction) {
|
|
if (!this.currentImage) return;
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = this.currentImage.width;
|
|
canvas.height = this.currentImage.height;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.save();
|
|
if (direction === "horizontal") {
|
|
ctx.translate(canvas.width, 0);
|
|
ctx.scale(-1, 1);
|
|
} else {
|
|
ctx.translate(0, canvas.height);
|
|
ctx.scale(1, -1);
|
|
}
|
|
ctx.drawImage(this.currentImage, 0, 0);
|
|
ctx.restore();
|
|
|
|
const flipped = new Image();
|
|
flipped.onload = () => {
|
|
this.currentImage = flipped;
|
|
this.requestRender();
|
|
this.pushHistory();
|
|
};
|
|
flipped.src = canvas.toDataURL();
|
|
}
|
|
|
|
rotateImage(angle = 90) {
|
|
if (!this.currentImage) return;
|
|
let normalized = angle % 360;
|
|
if (normalized < 0) normalized += 360;
|
|
if (normalized === 0) return;
|
|
|
|
const imgW = this.currentImage.width;
|
|
const imgH = this.currentImage.height;
|
|
const needsSwap = normalized === 90 || normalized === 270;
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = needsSwap ? imgH : imgW;
|
|
canvas.height = needsSwap ? imgW : imgH;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.save();
|
|
|
|
if (normalized === 90) {
|
|
ctx.translate(canvas.width, 0);
|
|
ctx.rotate(Math.PI / 2);
|
|
ctx.drawImage(this.currentImage, 0, 0);
|
|
} else if (normalized === 180) {
|
|
ctx.translate(canvas.width, canvas.height);
|
|
ctx.rotate(Math.PI);
|
|
ctx.drawImage(this.currentImage, 0, 0);
|
|
} else if (normalized === 270) {
|
|
ctx.translate(0, canvas.height);
|
|
ctx.rotate(-Math.PI / 2);
|
|
ctx.drawImage(this.currentImage, 0, 0);
|
|
} else {
|
|
ctx.translate(canvas.width / 2, canvas.height / 2);
|
|
ctx.rotate((Math.PI / 180) * normalized);
|
|
ctx.drawImage(this.currentImage, -imgW / 2, -imgH / 2);
|
|
}
|
|
|
|
ctx.restore();
|
|
|
|
const rotated = new Image();
|
|
rotated.onload = () => {
|
|
this.currentImage = rotated;
|
|
this.requestRender();
|
|
this.pushHistory();
|
|
};
|
|
rotated.src = canvas.toDataURL();
|
|
}
|
|
|
|
adjustBrushSize(delta) {
|
|
const slider = this.overlay?.querySelector("#param-brush-size");
|
|
const min = slider ? parseFloat(slider.min || "1") : 1;
|
|
const max = slider ? parseFloat(slider.max || "500") : 500;
|
|
const next = Math.min(max, Math.max(min, this.brushSize + delta));
|
|
this.brushSize = next;
|
|
if (slider) slider.value = next;
|
|
const display = this.overlay?.querySelector("#val-brush-size");
|
|
if (display) display.textContent = Math.round(next * 100) / 100;
|
|
this.requestRender();
|
|
}
|
|
|
|
handleBrushStart(e) {
|
|
const screenPos = this.getCanvasMousePosition(e);
|
|
const imagePos = this.screenToImageCoords(screenPos);
|
|
if (!imagePos) return;
|
|
|
|
// Convert brush size from screen pixels to image pixels so strokes stay aligned across zoom
|
|
const imageRect = this.getImageRect();
|
|
const imageScale = imageRect && this.currentImage ? (imageRect.width / this.currentImage.width) : 1;
|
|
const sizeInImagePx = this.brushSize / (imageScale || 1);
|
|
|
|
this.brushCursorPosImage = imagePos;
|
|
this.currentStroke = {
|
|
points: [{ x: imagePos.x, y: imagePos.y }],
|
|
size: sizeInImagePx,
|
|
opacity: this.brushOpacity / 100,
|
|
color: this.brushColor,
|
|
space: 'image'
|
|
};
|
|
}
|
|
|
|
handleBrushMove(e) {
|
|
if (!this.currentStroke) return;
|
|
|
|
const screenPos = this.getCanvasMousePosition(e);
|
|
const imagePos = this.screenToImageCoords(screenPos);
|
|
if (!imagePos) return;
|
|
|
|
this.brushCursorPosImage = imagePos;
|
|
this.currentStroke.points.push({ x: imagePos.x, y: imagePos.y });
|
|
this.requestRender();
|
|
}
|
|
|
|
handleBrushEnd() {
|
|
if (!this.currentStroke || this.currentStroke.points.length === 0) {
|
|
this.currentStroke = null;
|
|
return;
|
|
}
|
|
|
|
this.brushStrokes.push(this.currentStroke);
|
|
this.currentStroke = null;
|
|
this.pushHistory();
|
|
}
|
|
|
|
renderBrushStrokes(imageRect) {
|
|
const allStrokes = [...this.brushStrokes];
|
|
if (this.currentStroke) {
|
|
allStrokes.push(this.currentStroke);
|
|
}
|
|
|
|
if (allStrokes.length === 0 || !this.currentImage || !imageRect) return;
|
|
|
|
const scale = imageRect.width / this.currentImage.width;
|
|
|
|
this.ctx.save();
|
|
this.ctx.globalCompositeOperation = 'source-over';
|
|
|
|
for (const stroke of allStrokes) {
|
|
if (!stroke.points || stroke.points.length === 0) continue;
|
|
|
|
const useImageSpace = stroke.space === 'image';
|
|
const points = useImageSpace
|
|
? stroke.points.map(pt => this.imageToScreenCoords(pt))
|
|
: stroke.points;
|
|
|
|
this.ctx.strokeStyle = stroke.color;
|
|
this.ctx.lineWidth = useImageSpace ? stroke.size * scale : stroke.size;
|
|
this.ctx.lineCap = 'round';
|
|
this.ctx.lineJoin = 'round';
|
|
this.ctx.globalAlpha = stroke.opacity;
|
|
|
|
this.ctx.beginPath();
|
|
const firstPoint = points[0];
|
|
this.ctx.moveTo(firstPoint.x, firstPoint.y);
|
|
|
|
for (let i = 1; i < points.length; i++) {
|
|
const point = points[i];
|
|
this.ctx.lineTo(point.x, point.y);
|
|
}
|
|
|
|
this.ctx.stroke();
|
|
}
|
|
|
|
this.ctx.restore();
|
|
}
|
|
|
|
// --- Pen Tool Logic ---
|
|
getCanvasMousePosition(e) {
|
|
const rect = this.container.getBoundingClientRect();
|
|
return {
|
|
x: e.clientX - rect.left,
|
|
y: e.clientY - rect.top
|
|
};
|
|
}
|
|
|
|
getImageRect() {
|
|
if (!this.currentImage) return null;
|
|
const w = this.container.clientWidth;
|
|
const h = this.container.clientHeight;
|
|
const imgW = this.currentImage.width * this.zoom;
|
|
const imgH = this.currentImage.height * this.zoom;
|
|
const centerX = w / 2 + this.pan.x;
|
|
const centerY = h / 2 + this.pan.y;
|
|
const x = centerX - imgW / 2;
|
|
const y = centerY - imgH / 2;
|
|
return { x, y, width: imgW, height: imgH };
|
|
}
|
|
|
|
screenToImageCoords(screenPos) {
|
|
const rect = this.getImageRect();
|
|
if (!rect) return null;
|
|
const relX = screenPos.x - rect.x;
|
|
const relY = screenPos.y - rect.y;
|
|
// Allow drawing outside the image; clamp to nearest edge when converting
|
|
const clampedX = Math.min(Math.max(relX, 0), rect.width);
|
|
const clampedY = Math.min(Math.max(relY, 0), rect.height);
|
|
return {
|
|
x: (clampedX / rect.width) * this.currentImage.width,
|
|
y: (clampedY / rect.height) * this.currentImage.height
|
|
};
|
|
}
|
|
|
|
imageToScreenCoords(imagePos) {
|
|
const rect = this.getImageRect();
|
|
if (!rect) return { x: imagePos.x, y: imagePos.y };
|
|
return {
|
|
x: rect.x + (imagePos.x / this.currentImage.width) * rect.width,
|
|
y: rect.y + (imagePos.y / this.currentImage.height) * rect.height
|
|
};
|
|
}
|
|
|
|
hasPenSelection() {
|
|
return (this.penPaths && this.penPaths.length > 0) || (this.currentPath && this.currentPath.points.length > 0);
|
|
}
|
|
|
|
ensurePenAnimation() {
|
|
if (this.penAnimationFrame) return;
|
|
const step = () => {
|
|
if (!this.hasPenSelection()) {
|
|
this.penAnimationFrame = null;
|
|
return;
|
|
}
|
|
this.penDashOffset = (this.penDashOffset + 1.5) % 1000;
|
|
this.requestRender();
|
|
this.penAnimationFrame = requestAnimationFrame(step);
|
|
};
|
|
this.penAnimationFrame = requestAnimationFrame(step);
|
|
}
|
|
|
|
stopPenAnimation() {
|
|
if (this.penAnimationFrame) {
|
|
cancelAnimationFrame(this.penAnimationFrame);
|
|
this.penAnimationFrame = null;
|
|
}
|
|
this.penDashOffset = 0;
|
|
}
|
|
|
|
handlePenStart(e) {
|
|
console.log("Pen Start");
|
|
const screenPos = this.getCanvasMousePosition(e);
|
|
const imagePos = this.screenToImageCoords(screenPos);
|
|
this.penCursorPosImage = imagePos;
|
|
if (!imagePos) return;
|
|
|
|
// Check if clicking on an existing point or handle
|
|
if (this.currentPath) {
|
|
// Check handles first (if active point)
|
|
if (this.activePointIndex !== -1) {
|
|
const point = this.currentPath.points[this.activePointIndex];
|
|
if (point.handleIn && this.isPointClicked(screenPos, this.getAbsoluteHandlePos(point, 'in'))) {
|
|
this.activeHandle = 'in';
|
|
this.isDragging = true;
|
|
return;
|
|
}
|
|
if (point.handleOut && this.isPointClicked(screenPos, this.getAbsoluteHandlePos(point, 'out'))) {
|
|
this.activeHandle = 'out';
|
|
this.isDragging = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check points
|
|
for (let i = 0; i < this.currentPath.points.length; i++) {
|
|
if (this.isPointClicked(screenPos, this.currentPath.points[i])) {
|
|
// If clicking start point and path has > 2 points, close it
|
|
if (i === 0 && this.currentPath.points.length > 2 && !this.currentPath.closed) {
|
|
// Mark for closure on mouse up so user can adjust while holding
|
|
this.pendingClosePath = true;
|
|
this.activePointIndex = 0;
|
|
// Drag the outgoing handle so direction matches other points
|
|
this.activeHandle = 'out';
|
|
this.isDragging = true;
|
|
this.requestRender();
|
|
return;
|
|
}
|
|
this.activePointIndex = i;
|
|
this.activeHandle = null;
|
|
this.isDragging = true;
|
|
this.requestRender();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If not clicking existing, add new point
|
|
if (!this.currentPath) {
|
|
this.currentPath = {
|
|
points: [],
|
|
closed: false,
|
|
fillColor: this.penFillColor,
|
|
fillOpacity: this.penFillOpacity,
|
|
isFilled: false
|
|
};
|
|
}
|
|
|
|
const newPoint = { x: imagePos.x, y: imagePos.y, handleIn: null, handleOut: null };
|
|
this.currentPath.points.push(newPoint);
|
|
this.activePointIndex = this.currentPath.points.length - 1;
|
|
this.activeHandle = 'out'; // Start dragging out handle immediately for curves
|
|
this.isDragging = true;
|
|
this.requestRender();
|
|
this.ensurePenAnimation();
|
|
}
|
|
|
|
handlePenMove(e) {
|
|
const screenPos = this.getCanvasMousePosition(e);
|
|
const imagePos = this.screenToImageCoords(screenPos);
|
|
this.penCursorPosImage = imagePos; // Track cursor for rubber banding
|
|
|
|
if (!imagePos) {
|
|
this.requestRender();
|
|
return;
|
|
}
|
|
|
|
if (!this.isDragging || this.activePointIndex === -1 || !this.currentPath) {
|
|
this.requestRender(); // Request render to update rubber band
|
|
return;
|
|
}
|
|
|
|
const point = this.currentPath.points[this.activePointIndex];
|
|
|
|
if (this.activeHandle) {
|
|
// Dragging a handle
|
|
const isAltDown = e.altKey;
|
|
|
|
// Calculate relative handle position
|
|
const dx = imagePos.x - point.x;
|
|
const dy = imagePos.y - point.y;
|
|
|
|
if (this.activeHandle === 'out') {
|
|
point.handleOut = { x: dx, y: dy };
|
|
if (!isAltDown) {
|
|
// Symmetric handleIn
|
|
point.handleIn = { x: -dx, y: -dy };
|
|
}
|
|
} else if (this.activeHandle === 'in') {
|
|
point.handleIn = { x: dx, y: dy };
|
|
if (!isAltDown) {
|
|
// Symmetric handleOut
|
|
point.handleOut = { x: -dx, y: -dy };
|
|
}
|
|
}
|
|
} else {
|
|
// Dragging the point itself
|
|
point.x = imagePos.x;
|
|
point.y = imagePos.y;
|
|
}
|
|
this.requestRender();
|
|
}
|
|
|
|
handlePenEnd() {
|
|
this.isDragging = false;
|
|
this.activeHandle = null;
|
|
if (this.pendingClosePath) {
|
|
const shouldClose = this.currentPath && this.currentPath.points.length > 2 && !this.currentPath.closed;
|
|
this.pendingClosePath = false;
|
|
if (shouldClose) {
|
|
this.closeCurrentPath();
|
|
return; // closeCurrentPath already pushes history
|
|
}
|
|
}
|
|
this.pushHistory();
|
|
}
|
|
|
|
isPointClicked(clickPos, targetPos) {
|
|
// clickPos in screen space, targetPos in image space
|
|
const targetScreen = this.imageToScreenCoords(targetPos);
|
|
const dist = Math.sqrt(Math.pow(clickPos.x - targetScreen.x, 2) + Math.pow(clickPos.y - targetScreen.y, 2));
|
|
return dist < 10 / Math.abs(this.zoom || 1); // Hit area scales with zoom
|
|
}
|
|
|
|
getAbsoluteHandlePos(point, type) {
|
|
if (type === 'in' && point.handleIn) {
|
|
return { x: point.x + point.handleIn.x, y: point.y + point.handleIn.y };
|
|
}
|
|
if (type === 'out' && point.handleOut) {
|
|
return { x: point.x + point.handleOut.x, y: point.y + point.handleOut.y };
|
|
}
|
|
return point;
|
|
}
|
|
|
|
clampPathToImageBounds(path) {
|
|
if (!path || !this.currentImage) return;
|
|
const w = this.currentImage.width;
|
|
const h = this.currentImage.height;
|
|
path.points.forEach(p => {
|
|
p.x = Math.min(Math.max(p.x, 0), w);
|
|
p.y = Math.min(Math.max(p.y, 0), h);
|
|
});
|
|
}
|
|
|
|
deleteActivePenPoint() {
|
|
if (!this.isPenActive) return;
|
|
let targetPath = this.currentPath;
|
|
if (!targetPath && this.penPaths.length > 0) {
|
|
// Re-open last finished path for deletion
|
|
targetPath = this.penPaths.pop();
|
|
this.currentPath = targetPath;
|
|
this.pendingClosePath = false;
|
|
}
|
|
if (!targetPath || !targetPath.points || targetPath.points.length === 0) return;
|
|
|
|
const index = this.activePointIndex >= 0 ? this.activePointIndex : targetPath.points.length - 1;
|
|
targetPath.points.splice(index, 1);
|
|
this.pendingClosePath = false;
|
|
|
|
if (targetPath.points.length === 0) {
|
|
this.currentPath = null;
|
|
this.activePointIndex = -1;
|
|
this.requestRender();
|
|
this.pushHistory();
|
|
return;
|
|
}
|
|
|
|
this.activePointIndex = Math.min(Math.max(index - 1, 0), targetPath.points.length - 1);
|
|
this.requestRender();
|
|
this.pushHistory();
|
|
}
|
|
|
|
deselectPenSelection() {
|
|
this.penPaths = [];
|
|
this.currentPath = null;
|
|
this.activePointIndex = -1;
|
|
this.pendingClosePath = false;
|
|
this.stopPenAnimation();
|
|
this.requestRender();
|
|
this.pushHistory();
|
|
}
|
|
|
|
closeCurrentPath() {
|
|
if (this.currentPath && this.currentPath.points.length > 2) {
|
|
this.clampPathToImageBounds(this.currentPath);
|
|
this.currentPath.closed = true;
|
|
this.penPaths.push(this.currentPath);
|
|
this.currentPath = null;
|
|
this.activePointIndex = -1;
|
|
this.requestRender();
|
|
this.pushHistory();
|
|
this.ensurePenAnimation();
|
|
}
|
|
}
|
|
|
|
fillCurrentPath() {
|
|
// Determine target path (current drawing or last finished)
|
|
let target = this.currentPath || (this.penPaths.length > 0 ? this.penPaths[this.penPaths.length - 1] : null);
|
|
if (!target || target.points.length < 2) return;
|
|
|
|
// Close if needed and ensure stored in penPaths
|
|
this.clampPathToImageBounds(target);
|
|
target.closed = true;
|
|
target.isFilled = true;
|
|
target.fillColor = this.penFillColor;
|
|
target.fillOpacity = this.penFillOpacity;
|
|
|
|
if (this.currentPath) {
|
|
this.penPaths.push(target);
|
|
this.currentPath = null;
|
|
} else {
|
|
this.penPaths[this.penPaths.length - 1] = target;
|
|
}
|
|
|
|
this.activePointIndex = -1;
|
|
this.requestRender();
|
|
this.pushHistory();
|
|
this.ensurePenAnimation();
|
|
}
|
|
|
|
renderPenPaths(rect) {
|
|
const imageRect = this.getImageRect();
|
|
if (!imageRect) return;
|
|
|
|
const allPaths = [...this.penPaths];
|
|
if (this.currentPath) allPaths.push(this.currentPath);
|
|
|
|
const lineWidth = 2; // Keep constant screen thickness
|
|
const dashLength = 8; // Keep constant dash size
|
|
const dashOffset = this.penDashOffset;
|
|
this.ctx.save();
|
|
this.ctx.globalCompositeOperation = 'source-over';
|
|
|
|
if (allPaths.length > 0) {
|
|
this.ensurePenAnimation();
|
|
}
|
|
|
|
for (const path of allPaths) {
|
|
if (path.points.length < 2) {
|
|
// Draw single point
|
|
if (path.points.length === 1) {
|
|
this.drawAnchorPoint(path.points[0], path === this.currentPath && this.activePointIndex === 0);
|
|
|
|
// Rubber band from single point
|
|
if (path === this.currentPath && this.penCursorPosImage && !path.closed) {
|
|
const startScreen = this.imageToScreenCoords(path.points[0]);
|
|
const cursorScreen = this.imageToScreenCoords(this.penCursorPosImage);
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(startScreen.x, startScreen.y);
|
|
this.ctx.lineTo(cursorScreen.x, cursorScreen.y);
|
|
this.ctx.strokeStyle = '#facc15';
|
|
this.ctx.lineWidth = lineWidth;
|
|
this.ctx.setLineDash([5, 5]); // Dashed line for preview
|
|
this.ctx.stroke();
|
|
this.ctx.setLineDash([]);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
this.ctx.beginPath();
|
|
const first = this.imageToScreenCoords(path.points[0]);
|
|
this.ctx.moveTo(first.x, first.y);
|
|
|
|
for (let i = 0; i < path.points.length; i++) {
|
|
const p1 = path.points[i];
|
|
const p2 = path.points[(i + 1) % path.points.length];
|
|
|
|
if (i === path.points.length - 1 && !path.closed) break;
|
|
|
|
const p1Screen = this.imageToScreenCoords(p1);
|
|
const p2Screen = this.imageToScreenCoords(p2);
|
|
|
|
if (p1.handleOut && p2.handleIn) {
|
|
const h1 = this.imageToScreenCoords(this.getAbsoluteHandlePos(p1, 'out'));
|
|
const h2 = this.imageToScreenCoords(this.getAbsoluteHandlePos(p2, 'in'));
|
|
this.ctx.bezierCurveTo(h1.x, h1.y, h2.x, h2.y, p2Screen.x, p2Screen.y);
|
|
} else if (p1.handleOut) {
|
|
const h1 = this.imageToScreenCoords(this.getAbsoluteHandlePos(p1, 'out'));
|
|
this.ctx.quadraticCurveTo(h1.x, h1.y, p2Screen.x, p2Screen.y);
|
|
} else if (p2.handleIn) {
|
|
const h2 = this.imageToScreenCoords(this.getAbsoluteHandlePos(p2, 'in'));
|
|
this.ctx.quadraticCurveTo(h2.x, h2.y, p2Screen.x, p2Screen.y);
|
|
} else {
|
|
this.ctx.lineTo(p2Screen.x, p2Screen.y);
|
|
}
|
|
}
|
|
|
|
if (path.closed && path.isFilled) {
|
|
const fillColor = path.fillColor || this.penFillColor;
|
|
const fillOpacity = (path.fillOpacity ?? this.penFillOpacity) / 100;
|
|
this.ctx.fillStyle = fillColor;
|
|
this.ctx.globalAlpha = fillOpacity;
|
|
this.ctx.fill();
|
|
this.ctx.globalAlpha = 1;
|
|
} else if (path === this.currentPath && this.penCursorPosImage) {
|
|
// Rubber band from last point
|
|
const lastPoint = path.points[path.points.length - 1];
|
|
const lastScreen = this.imageToScreenCoords(lastPoint);
|
|
const cursorScreen = this.imageToScreenCoords(this.penCursorPosImage);
|
|
this.ctx.lineTo(cursorScreen.x, cursorScreen.y);
|
|
}
|
|
|
|
this.ctx.strokeStyle = '#facc15';
|
|
this.ctx.lineWidth = lineWidth;
|
|
this.ctx.setLineDash([dashLength, dashLength]);
|
|
this.ctx.lineDashOffset = -dashOffset;
|
|
this.ctx.stroke();
|
|
this.ctx.setLineDash([]);
|
|
|
|
// Draw controls for current path
|
|
if (path === this.currentPath) {
|
|
for (let i = 0; i < path.points.length; i++) {
|
|
const p = path.points[i];
|
|
const isActive = i === this.activePointIndex;
|
|
this.drawAnchorPoint(p, isActive);
|
|
|
|
if (isActive) {
|
|
if (p.handleIn) this.drawHandle(p, 'in');
|
|
if (p.handleOut) this.drawHandle(p, 'out');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.ctx.restore();
|
|
}
|
|
|
|
drawAnchorPoint(point, isActive) {
|
|
const imageRect = this.getImageRect();
|
|
const zoom = Math.abs((imageRect?.width || this.currentImage?.width || 1) / (this.currentImage?.width || 1)) || 1;
|
|
const size = 6 / zoom;
|
|
const pos = this.imageToScreenCoords(point);
|
|
this.ctx.fillStyle = isActive ? '#facc15' : '#ffffff';
|
|
this.ctx.strokeStyle = '#000000';
|
|
this.ctx.lineWidth = 1 / zoom;
|
|
this.ctx.fillRect(pos.x - size/2, pos.y - size/2, size, size);
|
|
this.ctx.strokeRect(pos.x - size/2, pos.y - size/2, size, size);
|
|
}
|
|
|
|
drawHandle(point, type) {
|
|
const handlePos = this.imageToScreenCoords(this.getAbsoluteHandlePos(point, type));
|
|
const pointPos = this.imageToScreenCoords(point);
|
|
const imageRect = this.getImageRect();
|
|
const zoom = Math.abs((imageRect?.width || this.currentImage?.width || 1) / (this.currentImage?.width || 1)) || 1;
|
|
const size = 4 / zoom;
|
|
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(pointPos.x, pointPos.y);
|
|
this.ctx.lineTo(handlePos.x, handlePos.y);
|
|
this.ctx.strokeStyle = '#facc15';
|
|
this.ctx.lineWidth = 1 / zoom;
|
|
this.ctx.stroke();
|
|
|
|
this.ctx.fillStyle = '#facc15';
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(handlePos.x, handlePos.y, size, 0, Math.PI * 2);
|
|
this.ctx.fill();
|
|
}
|
|
|
|
applyPixelEffectsRegion(ctx, x, y, width, height) {
|
|
const p = this.params;
|
|
const totalNoise = Math.max(0, (p.noise || 0) + (p.grain || 0));
|
|
const needsProcessing = totalNoise > 0 || this.hasHSLAdjustments() || p.clarity !== 0 || p.dehaze !== 0 || p.highlight !== 0 || p.shadow !== 0 || p.vibrance !== 0 || (this.curveEditor?.hasAdjustments() ?? false);
|
|
if (!needsProcessing) return;
|
|
if (width <= 0 || height <= 0) return;
|
|
|
|
const startX = Math.max(0, Math.floor(x));
|
|
const startY = Math.max(0, Math.floor(y));
|
|
const endX = Math.min(ctx.canvas.width, Math.ceil(x + width));
|
|
const endY = Math.min(ctx.canvas.height, Math.ceil(y + height));
|
|
const regionW = endX - startX;
|
|
const regionH = endY - startY;
|
|
if (regionW <= 0 || regionH <= 0) return;
|
|
|
|
let imageData;
|
|
try {
|
|
imageData = ctx.getImageData(startX, startY, regionW, regionH);
|
|
} catch (err) {
|
|
console.warn("ImageEditor: unable to read pixels for adjustments", err);
|
|
return;
|
|
}
|
|
|
|
const data = imageData.data;
|
|
const curvePack = this.curveEditor?.getLUTPack?.();
|
|
const curvesActive = curvePack?.hasAdjustments;
|
|
const curveRGB = curvesActive ? curvePack.rgb : null;
|
|
const curveR = curvesActive ? curvePack.r : null;
|
|
const curveG = curvesActive ? curvePack.g : null;
|
|
const curveB = curvesActive ? curvePack.b : null;
|
|
const clarityStrength = (p.clarity || 0) / 200;
|
|
const dehazeStrength = (p.dehaze || 0) / 200;
|
|
const highlightStrength = (p.highlight || 0) / 100;
|
|
const shadowStrength = (p.shadow || 0) / 100;
|
|
const noiseStrength = totalNoise / 100 * 30;
|
|
const vibranceStrength = (p.vibrance || 0) / 100;
|
|
const applyVibrance = vibranceStrength !== 0;
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
let r = data[i];
|
|
let g = data[i + 1];
|
|
let b = data[i + 2];
|
|
let { h, s, l } = rgbToHsl(r, g, b);
|
|
|
|
const adjustment = this.getHSLAdjustmentForHue(h);
|
|
const hueShift = (adjustment.h || 0) / 360;
|
|
const satAdjust = (adjustment.s || 0) / 100;
|
|
const lightAdjust = (adjustment.l || 0) / 100;
|
|
|
|
if (hueShift) {
|
|
h = (h + hueShift) % 1;
|
|
if (h < 0) h += 1;
|
|
}
|
|
if (satAdjust) {
|
|
if (satAdjust > 0) {
|
|
s = clamp01(s + (1 - s) * satAdjust);
|
|
} else {
|
|
s = clamp01(s + s * satAdjust);
|
|
}
|
|
}
|
|
if (lightAdjust) {
|
|
if (lightAdjust > 0) {
|
|
l = clamp01(l + (1 - l) * lightAdjust);
|
|
} else {
|
|
l = clamp01(l + l * lightAdjust);
|
|
}
|
|
}
|
|
if (clarityStrength) {
|
|
const delta = (l - 0.5) * clarityStrength;
|
|
l = clamp01(l + delta);
|
|
}
|
|
if (dehazeStrength) {
|
|
if (dehazeStrength > 0) {
|
|
l = clamp01(l - (l - 0.4) * Math.abs(dehazeStrength));
|
|
s = clamp01(s + (1 - s) * Math.abs(dehazeStrength) * 0.8);
|
|
} else {
|
|
const haze = Math.abs(dehazeStrength);
|
|
l = clamp01(l + (1 - l) * haze * 0.5);
|
|
s = clamp01(s - s * haze * 0.5);
|
|
}
|
|
}
|
|
if (highlightStrength && l > 0.5) {
|
|
const influence = (l - 0.5) * 2;
|
|
l = clamp01(l + influence * highlightStrength);
|
|
}
|
|
if (shadowStrength && l < 0.5) {
|
|
const influence = (0.5 - l) * 2;
|
|
l = clamp01(l + influence * shadowStrength);
|
|
}
|
|
if (applyVibrance) {
|
|
const midToneFactor = 0.5 + (1 - Math.abs(2 * l - 1)) * 0.5;
|
|
if (vibranceStrength > 0) {
|
|
s = clamp01(s + (1 - s) * vibranceStrength * midToneFactor);
|
|
} else {
|
|
s = clamp01(s + s * vibranceStrength * 0.8);
|
|
}
|
|
}
|
|
|
|
({ r, g, b } = hslToRgb(h, s, l));
|
|
|
|
if (curvesActive) {
|
|
if (curveR) r = curveR[r];
|
|
if (curveG) g = curveG[g];
|
|
if (curveB) b = curveB[b];
|
|
if (curveRGB) {
|
|
r = curveRGB[r];
|
|
g = curveRGB[g];
|
|
b = curveRGB[b];
|
|
}
|
|
}
|
|
|
|
if (noiseStrength > 0) {
|
|
const rand = (Math.random() - 0.5) * 2 * noiseStrength;
|
|
r = clamp255(r + rand);
|
|
g = clamp255(g + rand);
|
|
b = clamp255(b + rand);
|
|
}
|
|
|
|
data[i] = r;
|
|
data[i + 1] = g;
|
|
data[i + 2] = b;
|
|
}
|
|
|
|
ctx.putImageData(imageData, startX, startY);
|
|
}
|
|
|
|
getHSLAdjustmentForHue(hueValue) {
|
|
const adjustments = this.hslAdjustments || {};
|
|
const result = { h: 0, s: 0, l: 0 };
|
|
|
|
HSL_COLORS.forEach(color => {
|
|
const adj = adjustments[color.id];
|
|
if (!adj || color.center === null) return;
|
|
const dist = hueDistance(hueValue, color.center);
|
|
const width = color.width || 0.08;
|
|
const maxDist = width * 2;
|
|
if (dist >= maxDist) return;
|
|
const normalized = dist / width;
|
|
const influence = Math.exp(-normalized * normalized * 1.5);
|
|
if (influence <= 0) return;
|
|
result.h += adj.h * influence;
|
|
result.s += adj.s * influence;
|
|
result.l += adj.l * influence;
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
// --- Crop Logic ---
|
|
handleCropStart(e) {
|
|
const rect = this.container.getBoundingClientRect();
|
|
const clientX = e.clientX - rect.left;
|
|
const clientY = e.clientY - rect.top;
|
|
|
|
// Check if clicking on a handle
|
|
if (e.target.classList.contains('apix-crop-handle')) {
|
|
this.activeHandle = e.target.dataset.handle;
|
|
this.cropStart = { x: clientX, y: clientY }; // Reference for drag
|
|
// Store initial rect state for resizing
|
|
const style = window.getComputedStyle(this.cropBox);
|
|
this.initialCropRect = {
|
|
left: parseFloat(style.left),
|
|
top: parseFloat(style.top),
|
|
width: parseFloat(style.width),
|
|
height: parseFloat(style.height)
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Check if clicking inside existing crop box (Move)
|
|
if (this.cropRect) {
|
|
const style = window.getComputedStyle(this.cropBox);
|
|
const left = parseFloat(style.left);
|
|
const top = parseFloat(style.top);
|
|
const width = parseFloat(style.width);
|
|
const height = parseFloat(style.height);
|
|
|
|
if (clientX >= left && clientX <= left + width && clientY >= top && clientY <= top + height) {
|
|
this.activeHandle = 'move';
|
|
this.cropStart = { x: clientX, y: clientY };
|
|
this.initialCropRect = { left, top, width, height };
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Start new crop
|
|
// Convert to image coordinates to check bounds
|
|
const w = this.container.clientWidth;
|
|
const h = this.container.clientHeight;
|
|
const imgW = this.currentImage.width * this.zoom;
|
|
const imgH = this.currentImage.height * this.zoom;
|
|
const centerX = w / 2 + this.pan.x;
|
|
const centerY = h / 2 + this.pan.y;
|
|
const imgX = centerX - imgW / 2;
|
|
const imgY = centerY - imgH / 2;
|
|
|
|
// Check if click is within image
|
|
if (clientX < imgX || clientX > imgX + imgW || clientY < imgY || clientY > imgY + imgH) return;
|
|
|
|
this.cropStart = { x: clientX, y: clientY };
|
|
this.cropBox.style.display = 'block';
|
|
this.activeHandle = 'new';
|
|
this.updateCropBox(clientX, clientY, 0, 0);
|
|
}
|
|
|
|
handleCropMove(e) {
|
|
if (!this.cropStart) return;
|
|
|
|
const rect = this.container.getBoundingClientRect();
|
|
const clientX = e.clientX - rect.left;
|
|
const clientY = e.clientY - rect.top;
|
|
|
|
if (this.activeHandle === 'new') {
|
|
const w = clientX - this.cropStart.x;
|
|
const h = clientY - this.cropStart.y;
|
|
this.updateCropBox(this.cropStart.x, this.cropStart.y, w, h);
|
|
} else if (this.activeHandle === 'move') {
|
|
const dx = clientX - this.cropStart.x;
|
|
const dy = clientY - this.cropStart.y;
|
|
this.cropBox.style.left = (this.initialCropRect.left + dx) + 'px';
|
|
this.cropBox.style.top = (this.initialCropRect.top + dy) + 'px';
|
|
} else if (this.activeHandle) {
|
|
// Resize logic
|
|
const dx = clientX - this.cropStart.x;
|
|
const dy = clientY - this.cropStart.y;
|
|
let newLeft = this.initialCropRect.left;
|
|
let newTop = this.initialCropRect.top;
|
|
let newWidth = this.initialCropRect.width;
|
|
let newHeight = this.initialCropRect.height;
|
|
|
|
if (this.activeHandle.includes('l')) {
|
|
newLeft += dx;
|
|
newWidth -= dx;
|
|
}
|
|
if (this.activeHandle.includes('r')) {
|
|
newWidth += dx;
|
|
}
|
|
if (this.activeHandle.includes('t')) {
|
|
newTop += dy;
|
|
newHeight -= dy;
|
|
}
|
|
if (this.activeHandle.includes('b')) {
|
|
newHeight += dy;
|
|
}
|
|
|
|
// Enforce Aspect Ratio if set
|
|
const aspectSelect = this.overlay.querySelector("#crop-aspect");
|
|
if (aspectSelect.value !== 'free') {
|
|
const ratio = parseFloat(aspectSelect.value);
|
|
// Simple aspect enforcement (width dominant)
|
|
if (this.activeHandle.includes('l') || this.activeHandle.includes('r')) {
|
|
newHeight = newWidth / ratio;
|
|
} else {
|
|
newWidth = newHeight * ratio;
|
|
}
|
|
}
|
|
|
|
if (newWidth > 10 && newHeight > 10) {
|
|
this.cropBox.style.left = newLeft + 'px';
|
|
this.cropBox.style.top = newTop + 'px';
|
|
this.cropBox.style.width = newWidth + 'px';
|
|
this.cropBox.style.height = newHeight + 'px';
|
|
}
|
|
}
|
|
}
|
|
|
|
handleCropEnd() {
|
|
// Finalize crop box dimensions
|
|
const style = window.getComputedStyle(this.cropBox);
|
|
this.cropRect = {
|
|
x: parseFloat(style.left),
|
|
y: parseFloat(style.top),
|
|
w: parseFloat(style.width),
|
|
h: parseFloat(style.height)
|
|
};
|
|
this.cropStart = null;
|
|
this.activeHandle = null;
|
|
}
|
|
|
|
updateCropBox(x, y, w, h) {
|
|
let left = w < 0 ? x + w : x;
|
|
let top = h < 0 ? y + h : y;
|
|
let width = Math.abs(w);
|
|
let height = Math.abs(h);
|
|
|
|
// Constrain to aspect ratio if selected
|
|
const aspectSelect = this.overlay.querySelector("#crop-aspect");
|
|
if (aspectSelect.value !== 'free') {
|
|
const ratio = parseFloat(aspectSelect.value);
|
|
if (width / height > ratio) {
|
|
width = height * ratio;
|
|
} else {
|
|
height = width / ratio;
|
|
}
|
|
}
|
|
|
|
this.cropBox.style.left = left + 'px';
|
|
this.cropBox.style.top = top + 'px';
|
|
this.cropBox.style.width = width + 'px';
|
|
this.cropBox.style.height = height + 'px';
|
|
}
|
|
|
|
applyCrop() {
|
|
if (!this.cropRect || this.cropRect.w < 10) return;
|
|
|
|
// Convert screen coords to image coords
|
|
const w = this.container.clientWidth;
|
|
const h = this.container.clientHeight;
|
|
const imgW = this.currentImage.width * this.zoom;
|
|
const imgH = this.currentImage.height * this.zoom;
|
|
const centerX = w / 2 + this.pan.x;
|
|
const centerY = h / 2 + this.pan.y;
|
|
const imgX = centerX - imgW / 2;
|
|
const imgY = centerY - imgH / 2;
|
|
|
|
const relativeX = (this.cropRect.x - imgX) / this.zoom;
|
|
const relativeY = (this.cropRect.y - imgY) / this.zoom;
|
|
const relativeW = this.cropRect.w / this.zoom;
|
|
const relativeH = this.cropRect.h / this.zoom;
|
|
|
|
// Create new cropped image
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = relativeW;
|
|
canvas.height = relativeH;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(this.currentImage, relativeX, relativeY, relativeW, relativeH, 0, 0, relativeW, relativeH);
|
|
|
|
const newImg = new Image();
|
|
newImg.onload = () => {
|
|
this.currentImage = newImg;
|
|
this.toggleMode('adjust');
|
|
this.fitCanvas();
|
|
this.pushHistory();
|
|
};
|
|
newImg.src = canvas.toDataURL();
|
|
}
|
|
|
|
cloneBrushStrokes(source = []) {
|
|
return source.map(stroke => ({
|
|
...stroke,
|
|
points: (stroke.points || []).map(p => ({ x: p.x, y: p.y })),
|
|
}));
|
|
}
|
|
|
|
clonePenPath(path) {
|
|
if (!path) return null;
|
|
return {
|
|
points: (path.points || []).map(p => ({
|
|
x: p.x,
|
|
y: p.y,
|
|
handleIn: p.handleIn ? { ...p.handleIn } : null,
|
|
handleOut: p.handleOut ? { ...p.handleOut } : null,
|
|
})),
|
|
closed: !!path.closed,
|
|
fillColor: path.fillColor,
|
|
fillOpacity: path.fillOpacity,
|
|
isFilled: !!path.isFilled,
|
|
};
|
|
}
|
|
|
|
clonePenPaths(source = []) {
|
|
return source.map(p => this.clonePenPath(p)).filter(Boolean);
|
|
}
|
|
|
|
// --- History ---
|
|
pushHistory() {
|
|
// Remove future states if we are in middle of stack
|
|
if (this.historyIndex < this.history.length - 1) {
|
|
this.history = this.history.slice(0, this.historyIndex + 1);
|
|
}
|
|
|
|
// Save state
|
|
this.history.push({
|
|
params: { ...this.params },
|
|
hslAdjustments: this.cloneHSLAdjustments(),
|
|
activeHSLColor: this.activeHSLColor,
|
|
curves: this.curveEditor ? this.curveEditor.getState() : null,
|
|
image: this.currentImage.src, // Save image source (base64) if changed by crop
|
|
brushStrokes: this.cloneBrushStrokes(this.brushStrokes),
|
|
penPaths: this.clonePenPaths(this.penPaths),
|
|
currentPath: this.clonePenPath(this.currentPath),
|
|
});
|
|
this.historyIndex++;
|
|
this.updateHistoryButtons();
|
|
}
|
|
|
|
undo() {
|
|
if (this.historyIndex > 0) {
|
|
this.historyIndex--;
|
|
this.restoreState(this.history[this.historyIndex]);
|
|
}
|
|
}
|
|
|
|
redo() {
|
|
if (this.historyIndex < this.history.length - 1) {
|
|
this.historyIndex++;
|
|
this.restoreState(this.history[this.historyIndex]);
|
|
}
|
|
}
|
|
|
|
restoreState(state) {
|
|
this.params = { ...state.params };
|
|
this.hslAdjustments = state.hslAdjustments ? this.cloneHSLAdjustments(state.hslAdjustments) : this.getDefaultHSLAdjustments();
|
|
this.activeHSLColor = state.activeHSLColor || HSL_COLORS[0]?.id || null;
|
|
this.syncHSLSliders();
|
|
this.updateHSLUI();
|
|
if (this.curveEditor) {
|
|
if (state.curves) {
|
|
this.curveEditor.setState(state.curves);
|
|
} else {
|
|
this.curveEditor.resetAll(false);
|
|
}
|
|
}
|
|
// Update UI
|
|
Object.keys(this.params).forEach(key => {
|
|
const el = this.overlay.querySelector(`#param-${key}`);
|
|
if (el) {
|
|
el.value = this.params[key];
|
|
this.overlay.querySelector(`#val-${key}`).textContent = this.params[key];
|
|
}
|
|
});
|
|
|
|
// Update Image if changed (crop)
|
|
if (state.image !== this.currentImage.src) {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
this.currentImage = img;
|
|
this.brushStrokes = this.cloneBrushStrokes(state.brushStrokes || []);
|
|
this.currentStroke = null;
|
|
this.penPaths = this.clonePenPaths(state.penPaths || []);
|
|
this.currentPath = this.clonePenPath(state.currentPath);
|
|
this.pendingClosePath = false;
|
|
this.activePointIndex = -1;
|
|
this.brushCursorPosImage = null;
|
|
this.stopPenAnimation();
|
|
if (this.hasPenSelection()) this.ensurePenAnimation();
|
|
this.requestRender();
|
|
};
|
|
img.src = state.image;
|
|
} else {
|
|
this.brushStrokes = this.cloneBrushStrokes(state.brushStrokes || []);
|
|
this.currentStroke = null;
|
|
this.penPaths = this.clonePenPaths(state.penPaths || []);
|
|
this.currentPath = this.clonePenPath(state.currentPath);
|
|
this.pendingClosePath = false;
|
|
this.activePointIndex = -1;
|
|
this.brushCursorPosImage = null;
|
|
this.stopPenAnimation();
|
|
if (this.hasPenSelection()) this.ensurePenAnimation();
|
|
this.requestRender();
|
|
}
|
|
this.updateHistoryButtons();
|
|
}
|
|
|
|
updateHistoryButtons() {
|
|
this.overlay.querySelector("#action-undo").disabled = this.historyIndex <= 0;
|
|
this.overlay.querySelector("#action-redo").disabled = this.historyIndex >= this.history.length - 1;
|
|
}
|
|
|
|
reset() {
|
|
// Reset to initial state (index 0)
|
|
if (this.history.length > 0) {
|
|
this.historyIndex = 0;
|
|
this.restoreState(this.history[0]);
|
|
// Clear future
|
|
this.history = [this.history[0]];
|
|
this.updateHistoryButtons();
|
|
}
|
|
}
|
|
|
|
async renderEditedBlob() {
|
|
// 1. Create a high-res canvas
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = this.currentImage.width;
|
|
canvas.height = this.currentImage.height;
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
// 2. Apply filters
|
|
const p = this.params;
|
|
const clarityBoost = 1 + (p.clarity || 0) / 200;
|
|
const dehazeBoost = 1 + Math.max(0, p.dehaze || 0) / 200;
|
|
const brightness = 100 + p.exposure;
|
|
const contrast = Math.max(0, (100 + p.contrast) * clarityBoost * dehazeBoost);
|
|
let saturate = 100 + p.saturation;
|
|
if (p.dehaze > 0) {
|
|
saturate += p.dehaze * 0.3;
|
|
}
|
|
const hue = p.hue;
|
|
const blur = p.blur / 5; // Scale appropriately for full res?
|
|
// Note: CSS blur is px based, canvas filter blur is also px based.
|
|
// If image is large, blur needs to be scaled up to look same as preview.
|
|
// Preview zoom = this.zoom.
|
|
// Real blur = p.blur / 5 / this.zoom (approx)
|
|
|
|
let filterString = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturate}%) hue-rotate(${hue}deg)`;
|
|
if (blur > 0) filterString += ` blur(${blur}px)`;
|
|
|
|
ctx.filter = filterString;
|
|
ctx.drawImage(this.currentImage, 0, 0);
|
|
ctx.filter = 'none';
|
|
|
|
// 3. Apply Overlays
|
|
if (p.temp !== 0 || p.tint !== 0) {
|
|
ctx.globalCompositeOperation = 'overlay';
|
|
if (p.temp !== 0) {
|
|
ctx.fillStyle = p.temp > 0 ? `rgba(255, 160, 0, ${p.temp / 200})` : `rgba(0, 100, 255, ${Math.abs(p.temp) / 200})`;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
if (p.tint !== 0) {
|
|
ctx.fillStyle = p.tint > 0 ? `rgba(255, 0, 255, ${p.tint / 200})` : `rgba(0, 255, 0, ${Math.abs(p.tint) / 200})`;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
}
|
|
|
|
if (this.shouldApplyPixelEffects()) {
|
|
this.applyPixelEffectsRegion(ctx, 0, 0, canvas.width, canvas.height);
|
|
}
|
|
|
|
// 4. Apply brush strokes to the final image
|
|
if (this.brushStrokes.length > 0) {
|
|
// Calculate how the image was displayed in the preview canvas
|
|
const previewW = this.container.clientWidth;
|
|
const previewH = this.container.clientHeight;
|
|
const imgW = this.currentImage.width * this.zoom;
|
|
const imgH = this.currentImage.height * this.zoom;
|
|
const centerX = previewW / 2 + this.pan.x;
|
|
const centerY = previewH / 2 + this.pan.y;
|
|
const imgX = centerX - imgW / 2;
|
|
const imgY = centerY - imgH / 2;
|
|
|
|
// Scale from preview to original image
|
|
const scaleX = this.currentImage.width / imgW;
|
|
const scaleY = this.currentImage.height / imgH;
|
|
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
|
|
for (const stroke of this.brushStrokes) {
|
|
if (!stroke.points || stroke.points.length === 0) continue;
|
|
|
|
ctx.strokeStyle = stroke.color;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.globalAlpha = stroke.opacity;
|
|
|
|
const useImageSpace = stroke.space === 'image';
|
|
const lineWidth = useImageSpace ? stroke.size : stroke.size * Math.min(scaleX, scaleY);
|
|
ctx.lineWidth = lineWidth;
|
|
|
|
ctx.beginPath();
|
|
|
|
for (let i = 0; i < stroke.points.length; i++) {
|
|
const point = stroke.points[i];
|
|
|
|
// Convert from canvas coordinates to image coordinates when needed
|
|
const imageX = useImageSpace ? point.x : (point.x - imgX) * scaleX;
|
|
const imageY = useImageSpace ? point.y : (point.y - imgY) * scaleY;
|
|
|
|
if (i === 0) {
|
|
ctx.moveTo(imageX, imageY);
|
|
} else {
|
|
ctx.lineTo(imageX, imageY);
|
|
}
|
|
}
|
|
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// 5. Apply Pen Paths to the final image
|
|
if (this.penPaths.length > 0) {
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
|
|
for (const path of this.penPaths) {
|
|
if (path.points.length < 2) continue;
|
|
|
|
ctx.beginPath();
|
|
|
|
const start = path.points[0];
|
|
ctx.moveTo(start.x, start.y);
|
|
|
|
for (let i = 0; i < path.points.length; i++) {
|
|
const p1 = path.points[i];
|
|
const p2 = path.points[(i + 1) % path.points.length];
|
|
|
|
if (i === path.points.length - 1 && !path.closed) break;
|
|
|
|
if (p1.handleOut && p2.handleIn) {
|
|
const h1 = this.getAbsoluteHandlePos(p1, 'out');
|
|
const h2 = this.getAbsoluteHandlePos(p2, 'in');
|
|
ctx.bezierCurveTo(
|
|
h1.x, h1.y,
|
|
h2.x, h2.y,
|
|
p2.x, p2.y
|
|
);
|
|
} else if (p1.handleOut) {
|
|
const h1 = this.getAbsoluteHandlePos(p1, 'out');
|
|
ctx.quadraticCurveTo(
|
|
h1.x, h1.y,
|
|
p2.x, p2.y
|
|
);
|
|
} else if (p2.handleIn) {
|
|
const h2 = this.getAbsoluteHandlePos(p2, 'in');
|
|
ctx.quadraticCurveTo(
|
|
h2.x, h2.y,
|
|
p2.x, p2.y
|
|
);
|
|
} else {
|
|
ctx.lineTo(p2.x, p2.y);
|
|
}
|
|
}
|
|
|
|
if (path.closed && path.isFilled) {
|
|
ctx.fillStyle = path.fillColor;
|
|
ctx.globalAlpha = path.fillOpacity / 100;
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (blob) {
|
|
resolve(blob);
|
|
} else {
|
|
reject(new Error("Unable to export edited image."));
|
|
}
|
|
},
|
|
"image/png",
|
|
0.95
|
|
);
|
|
});
|
|
}
|
|
|
|
// --- Save ---
|
|
async save() {
|
|
try {
|
|
const blob = await this.renderEditedBlob();
|
|
if (this.saveCallback) {
|
|
await this.saveCallback(blob);
|
|
}
|
|
this.close();
|
|
} catch (err) {
|
|
console.error("[SDVN.ImageEditor] Failed to save image", err);
|
|
}
|
|
}
|
|
|
|
async download() {
|
|
try {
|
|
const blob = await this.renderEditedBlob();
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = this.buildDownloadFilename();
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
} catch (err) {
|
|
console.error("[SDVN.ImageEditor] Failed to download image", err);
|
|
}
|
|
}
|
|
|
|
buildDownloadFilename() {
|
|
const fallback = "sdvn_image.png";
|
|
if (!this.imageSrc) return fallback;
|
|
try {
|
|
const url = new URL(this.imageSrc, window.location.origin);
|
|
const paramName = url.searchParams.get("filename");
|
|
const pathName = url.pathname.split("/").pop();
|
|
const base = (paramName || pathName || "sdvn_image").replace(/\.[^.]+$/, "");
|
|
return `${base || "sdvn_image"}_edited.png`;
|
|
} catch {
|
|
const sanitized = this.imageSrc.split("/").pop()?.split("?")[0] ?? "sdvn_image";
|
|
const base = sanitized.replace(/\.[^.]+$/, "");
|
|
return `${base || "sdvn_image"}_edited.png`;
|
|
}
|
|
}
|
|
|
|
close() {
|
|
this.curveEditor?.destroy?.();
|
|
document.body.removeChild(this.overlay);
|
|
}
|
|
}
|