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 = `
Image Editor
100%
`; document.body.appendChild(this.overlay); this.bindEvents(); } renderSlider(label, id, min, max, val) { return `
${label}
${val}
`; } renderCurvePanel() { return `
Adjust
`; } renderHSLSection() { if (!this.activeHSLColor && HSL_COLORS.length) { this.activeHSLColor = HSL_COLORS[0].id; } const swatches = HSL_COLORS.map(color => ` `).join(""); return `
${swatches}
${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)}
${this.getActiveHSLLabel()}
`; } renderHSLSlider(label, key, min, max, val) { return `
${label}
${val}
`; } 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); } }