diff --git a/.DS_Store b/.DS_Store index b7754e0..13272b1 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Image_editor_example/image_editor.js b/Image_editor_example/image_editor.js new file mode 100644 index 0000000..8129d31 --- /dev/null +++ b/Image_editor_example/image_editor.js @@ -0,0 +1,7 @@ +import { app } from "/scripts/app.js"; +import { api } from "/scripts/api.js"; +import { injectImageEditorStyles } from "./image_editor_modules/styles.js"; +import { registerImageEditorExtension } from "./image_editor_modules/extension.js"; + +injectImageEditorStyles(); +registerImageEditorExtension(app, api); diff --git a/Image_editor_example/image_editor_modules/color.js b/Image_editor_example/image_editor_modules/color.js new file mode 100644 index 0000000..2c50838 --- /dev/null +++ b/Image_editor_example/image_editor_modules/color.js @@ -0,0 +1,71 @@ +export function clamp01(value) { + return Math.min(1, Math.max(0, value)); +} + +export function clamp255(value) { + return Math.min(255, Math.max(0, value)); +} + +export function rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h; + let s; + const l = (max + min) / 2; + if (max === min) { + h = 0; + s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + default: + h = (r - g) / d + 4; + } + h /= 6; + } + return { h, s, l }; +} + +export function hslToRgb(h, s, l) { + let r; + let g; + let b; + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255) + }; +} + +export function hueDistance(a, b) { + let diff = Math.abs(a - b); + diff = Math.min(diff, 1 - diff); + return diff; +} diff --git a/Image_editor_example/image_editor_modules/constants.js b/Image_editor_example/image_editor_modules/constants.js new file mode 100644 index 0000000..e74dc54 --- /dev/null +++ b/Image_editor_example/image_editor_modules/constants.js @@ -0,0 +1,24 @@ +export const ICONS = { + crop: ``, + adjust: ``, + undo: ``, + redo: ``, + reset: ``, + chevronDown: ``, + close: ``, + flipH: ``, + flipV: ``, + rotate: `` +}; + +export const HSL_COLORS = [ + { id: "red", label: "Red", color: "#ff4b4b", center: 0 / 360, width: 0.12 }, + { id: "orange", label: "Orange", color: "#ff884d", center: 30 / 360, width: 0.12 }, + { id: "yellow", label: "Yellow", color: "#ffd84d", center: 50 / 360, width: 0.12 }, + { id: "green", label: "Green", color: "#45d98e", center: 120 / 360, width: 0.12 }, + { id: "cyan", label: "Cyan", color: "#30c4ff", center: 180 / 360, width: 0.12 }, + { id: "blue", label: "Blue", color: "#2f7bff", center: 220 / 360, width: 0.12 }, + { id: "magenta", label: "Magenta", color: "#c95bff", center: 300 / 360, width: 0.12 } +]; + +export const IMAGE_EDITOR_SUBFOLDER = "image_editor"; diff --git a/Image_editor_example/image_editor_modules/curve.js b/Image_editor_example/image_editor_modules/curve.js new file mode 100644 index 0000000..eb4d7d8 --- /dev/null +++ b/Image_editor_example/image_editor_modules/curve.js @@ -0,0 +1,493 @@ +import { clamp01 } from "./color.js"; + +const CHANNEL_COLORS = { + rgb: "#ffffff", + r: "#ff7070", + g: "#70ffa0", + b: "#72a0ff" +}; + +export class CurveEditor { + constructor({ canvas, channelButtons = [], resetButton, onChange, onCommit }) { + this.canvas = canvas; + this.ctx = canvas.getContext("2d"); + this.channelButtons = channelButtons; + this.resetButton = resetButton; + this.onChange = onChange; + this.onCommit = onCommit; + + this.channels = ["rgb", "r", "g", "b"]; + this.activeChannel = "rgb"; + this.curves = this.createDefaultCurves(); + this.curveTangents = {}; + this.channels.forEach(channel => (this.curveTangents[channel] = [])); + this.luts = this.buildAllLUTs(); + this.isDragging = false; + this.dragIndex = null; + this.curveDirty = false; + this.displayWidth = this.canvas.clientWidth || 240; + this.displayHeight = this.canvas.clientHeight || 240; + + this.resizeObserver = null; + this.handleResize = this.handleResize.bind(this); + this.onPointerDown = this.onPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + this.onDoubleClick = this.onDoubleClick.bind(this); + + window.addEventListener("resize", this.handleResize); + this.canvas.addEventListener("mousedown", this.onPointerDown); + window.addEventListener("mousemove", this.onPointerMove); + window.addEventListener("mouseup", this.onPointerUp); + this.canvas.addEventListener("dblclick", this.onDoubleClick); + + this.attachChannelButtons(); + this.attachResetButton(); + this.handleResize(); + if (window.ResizeObserver) { + this.resizeObserver = new ResizeObserver(() => this.handleResize()); + this.resizeObserver.observe(this.canvas); + } + this.draw(); + } + + destroy() { + this.resizeObserver?.disconnect(); + window.removeEventListener("resize", this.handleResize); + this.canvas.removeEventListener("mousedown", this.onPointerDown); + window.removeEventListener("mousemove", this.onPointerMove); + window.removeEventListener("mouseup", this.onPointerUp); + this.canvas.removeEventListener("dblclick", this.onDoubleClick); + } + + attachChannelButtons() { + this.channelButtons.forEach(btn => { + btn.addEventListener("click", () => { + const channel = btn.dataset.curveChannel; + if (channel && this.channels.includes(channel)) { + this.activeChannel = channel; + this.updateChannelButtons(); + this.draw(); + } + }); + }); + this.updateChannelButtons(); + } + + attachResetButton() { + if (!this.resetButton) return; + this.resetButton.addEventListener("click", () => { + this.resetChannel(this.activeChannel); + this.notifyChange(); + this.notifyCommit(); + }); + } + + updateChannelButtons() { + this.channelButtons.forEach(btn => { + const channel = btn.dataset.curveChannel; + btn.classList.toggle("active", channel === this.activeChannel); + }); + } + + handleResize() { + const rect = this.canvas.getBoundingClientRect(); + const width = Math.max(1, rect.width || 240); + const height = Math.max(1, rect.height || 240); + this.displayWidth = width; + this.displayHeight = height; + const dpr = window.devicePixelRatio || 1; + this.canvas.width = width * dpr; + this.canvas.height = height * dpr; + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.scale(dpr, dpr); + this.draw(); + } + + createDefaultCurve() { + return [ + { x: 0, y: 0 }, + { x: 1, y: 1 } + ]; + } + + createDefaultCurves() { + return { + rgb: this.createDefaultCurve().map(p => ({ ...p })), + r: this.createDefaultCurve().map(p => ({ ...p })), + g: this.createDefaultCurve().map(p => ({ ...p })), + b: this.createDefaultCurve().map(p => ({ ...p })) + }; + } + + cloneCurves(source = this.curves) { + const clone = {}; + this.channels.forEach(channel => { + clone[channel] = (source[channel] || this.createDefaultCurve()).map(p => ({ x: p.x, y: p.y })); + }); + return clone; + } + + setState(state) { + if (!state) return; + const incoming = this.cloneCurves(state.curves || {}); + this.curves = incoming; + if (state.activeChannel && this.channels.includes(state.activeChannel)) { + this.activeChannel = state.activeChannel; + } + this.rebuildAllLUTs(); + this.updateChannelButtons(); + this.draw(); + } + + getState() { + return { + curves: this.cloneCurves(), + activeChannel: this.activeChannel + }; + } + + resetChannel(channel, emit = false) { + if (!this.channels.includes(channel)) return; + this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p })); + this.rebuildChannelLUT(channel); + this.draw(); + if (emit) { + this.notifyChange(); + this.notifyCommit(); + this.curveDirty = false; + } + } + + resetAll(emit = true) { + this.channels.forEach(channel => { + this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p })); + }); + this.rebuildAllLUTs(); + this.draw(); + if (emit) { + this.notifyChange(); + this.notifyCommit(); + this.curveDirty = false; + } + } + + hasAdjustments() { + return this.channels.some(channel => !this.isDefaultCurve(this.curves[channel])); + } + + isDefaultCurve(curve) { + if (!curve || curve.length !== 2) return false; + const [start, end] = curve; + const epsilon = 0.0001; + return Math.abs(start.x) < epsilon && Math.abs(start.y) < epsilon && + Math.abs(end.x - 1) < epsilon && Math.abs(end.y - 1) < epsilon; + } + + notifyChange() { + this.onChange?.(); + } + + notifyCommit() { + this.onCommit?.(); + } + + getLUTPack() { + return { + rgb: this.isDefaultCurve(this.curves.rgb) ? null : this.luts.rgb, + r: this.isDefaultCurve(this.curves.r) ? null : this.luts.r, + g: this.isDefaultCurve(this.curves.g) ? null : this.luts.g, + b: this.isDefaultCurve(this.curves.b) ? null : this.luts.b, + hasAdjustments: this.hasAdjustments() + }; + } + + buildAllLUTs() { + const result = {}; + this.channels.forEach(channel => { + const curve = this.curves[channel]; + const tangents = this.computeTangents(curve); + this.curveTangents[channel] = tangents; + result[channel] = this.buildCurveLUT(curve, tangents); + }); + return result; + } + + rebuildAllLUTs() { + this.luts = this.buildAllLUTs(); + } + + rebuildChannelLUT(channel) { + const curve = this.curves[channel]; + const tangents = this.computeTangents(curve); + this.curveTangents[channel] = tangents; + this.luts[channel] = this.buildCurveLUT(curve, tangents); + } + + buildCurveLUT(curve, tangents = null) { + const curveTangents = tangents || this.computeTangents(curve); + const lut = new Uint8ClampedArray(256); + for (let i = 0; i < 256; i++) { + const pos = i / 255; + lut[i] = Math.round(clamp01(this.sampleSmoothCurve(curve, pos, curveTangents)) * 255); + } + return lut; + } + + computeTangents(curve) { + const n = curve.length; + if (n < 2) return new Array(n).fill(0); + const tangents = new Array(n).fill(0); + const delta = new Array(n - 1).fill(0); + const dx = new Array(n - 1).fill(0); + for (let i = 0; i < n - 1; i++) { + dx[i] = Math.max(1e-6, curve[i + 1].x - curve[i].x); + delta[i] = (curve[i + 1].y - curve[i].y) / dx[i]; + } + tangents[0] = delta[0]; + tangents[n - 1] = delta[n - 2]; + for (let i = 1; i < n - 1; i++) { + if (delta[i - 1] * delta[i] <= 0) { + tangents[i] = 0; + } else { + const w1 = 2 * dx[i] + dx[i - 1]; + const w2 = dx[i] + 2 * dx[i - 1]; + tangents[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]); + } + } + for (let i = 0; i < n - 1; i++) { + if (Math.abs(delta[i]) < 1e-6) { + tangents[i] = 0; + tangents[i + 1] = 0; + } else { + let alpha = tangents[i] / delta[i]; + let beta = tangents[i + 1] / delta[i]; + const sum = alpha * alpha + beta * beta; + if (sum > 9) { + const tau = 3 / Math.sqrt(sum); + alpha *= tau; + beta *= tau; + tangents[i] = alpha * delta[i]; + tangents[i + 1] = beta * delta[i]; + } + } + } + return tangents; + } + + sampleSmoothCurve(curve, t, tangents) { + if (!curve || curve.length === 0) return t; + const n = curve.length; + if (!tangents || tangents.length !== n) { + tangents = this.computeTangents(curve); + } + if (t <= curve[0].x) return curve[0].y; + if (t >= curve[n - 1].x) return curve[n - 1].y; + let idx = 1; + for (; idx < n; idx++) { + if (t <= curve[idx].x) break; + } + const p0 = curve[idx - 1]; + const p1 = curve[idx]; + const m0 = tangents[idx - 1] ?? 0; + const m1 = tangents[idx] ?? 0; + const span = p1.x - p0.x || 1e-6; + const u = (t - p0.x) / span; + const h00 = (2 * u ** 3) - (3 * u ** 2) + 1; + const h10 = u ** 3 - 2 * u ** 2 + u; + const h01 = (-2 * u ** 3) + (3 * u ** 2); + const h11 = u ** 3 - u ** 2; + const value = h00 * p0.y + h10 * span * m0 + h01 * p1.y + h11 * span * m1; + return clamp01(value); + } + + getActiveCurve() { + return this.curves[this.activeChannel]; + } + + addPoint(x, y) { + const points = this.getActiveCurve(); + let insertIndex = points.findIndex(point => x < point.x); + if (insertIndex === -1) { + points.push({ x, y }); + insertIndex = points.length - 1; + } else { + points.splice(insertIndex, 0, { x, y }); + } + this.rebuildChannelLUT(this.activeChannel); + this.draw(); + this.curveDirty = true; + this.notifyChange(); + return insertIndex; + } + + updatePoint(index, x, y) { + const points = this.getActiveCurve(); + const point = points[index]; + if (!point) return; + const originalX = point.x; + const originalY = point.y; + if (index === 0) { + point.x = 0; + point.y = clamp01(y); + } else if (index === points.length - 1) { + point.x = 1; + point.y = clamp01(y); + } else { + const minX = points[index - 1].x + 0.01; + const maxX = points[index + 1].x - 0.01; + point.x = clamp01(Math.min(Math.max(x, minX), maxX)); + point.y = clamp01(y); + } + if (Math.abs(originalX - point.x) < 0.0001 && Math.abs(originalY - point.y) < 0.0001) { + return; + } + this.rebuildChannelLUT(this.activeChannel); + this.draw(); + this.curveDirty = true; + this.notifyChange(); + } + + removePoint(index) { + const points = this.getActiveCurve(); + if (index <= 0 || index >= points.length - 1) return; + points.splice(index, 1); + this.rebuildChannelLUT(this.activeChannel); + this.draw(); + this.notifyChange(); + this.notifyCommit(); + this.curveDirty = false; + } + + getPointerPosition(event) { + const rect = this.canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + const x = clamp01((event.clientX - rect.left) / rect.width); + const y = clamp01(1 - (event.clientY - rect.top) / rect.height); + return { x, y }; + } + + findPointIndex(pos, threshold = 10) { + if (!pos) return -1; + const points = this.getActiveCurve(); + const targetX = pos.x * this.displayWidth; + const targetY = (1 - pos.y) * this.displayHeight; + for (let i = 0; i < points.length; i++) { + const pt = points[i]; + const px = pt.x * this.displayWidth; + const py = (1 - pt.y) * this.displayHeight; + const dist = Math.hypot(px - targetX, py - targetY); + if (dist <= threshold) return i; + } + return -1; + } + + onPointerDown(event) { + if (event.button !== 0) return; + const pos = this.getPointerPosition(event); + if (!pos) return; + event.preventDefault(); + let idx = this.findPointIndex(pos); + if (idx === -1) { + idx = this.addPoint(pos.x, pos.y); + } + this.dragIndex = idx; + this.isDragging = true; + this.updatePoint(idx, pos.x, pos.y); + } + + onPointerMove(event) { + if (!this.isDragging || this.dragIndex === null) return; + const pos = this.getPointerPosition(event); + if (!pos) return; + event.preventDefault(); + this.updatePoint(this.dragIndex, pos.x, pos.y); + } + + onPointerUp() { + if (!this.isDragging) return; + this.isDragging = false; + this.dragIndex = null; + if (this.curveDirty) { + this.curveDirty = false; + this.notifyCommit(); + } + } + + onDoubleClick(event) { + const pos = this.getPointerPosition(event); + if (!pos) return; + const idx = this.findPointIndex(pos, 8); + if (idx > 0 && idx < this.getActiveCurve().length - 1) { + this.removePoint(idx); + } + } + + getChannelColor() { + return CHANNEL_COLORS[this.activeChannel] || "#ffffff"; + } + + draw() { + if (!this.ctx) return; + const ctx = this.ctx; + const w = this.displayWidth; + const h = this.displayHeight; + ctx.clearRect(0, 0, w, h); + this.drawGrid(ctx, w, h); + this.drawCurve(ctx, w, h); + this.drawPoints(ctx, w, h); + } + + drawGrid(ctx, w, h) { + ctx.fillStyle = "rgba(0,0,0,0.5)"; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.lineWidth = 1; + for (let i = 1; i < 4; i++) { + const x = (w / 4) * i; + const y = (h / 4) * i; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + } + + drawCurve(ctx, w, h) { + const points = this.getActiveCurve(); + if (!points?.length) return; + const tangents = this.curveTangents[this.activeChannel] || this.computeTangents(points); + ctx.strokeStyle = this.getChannelColor(); + ctx.lineWidth = 2; + ctx.beginPath(); + const steps = 128; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const value = this.sampleSmoothCurve(points, t, tangents); + const x = t * w; + const y = (1 - value) * h; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + drawPoints(ctx, w, h) { + const points = this.getActiveCurve(); + ctx.fillStyle = "#000"; + ctx.lineWidth = 2; + ctx.strokeStyle = this.getChannelColor(); + points.forEach(pt => { + const x = pt.x * w; + const y = (1 - pt.y) * h; + ctx.beginPath(); + ctx.arc(x, y, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + }); + } +} diff --git a/Image_editor_example/image_editor_modules/editor.js b/Image_editor_example/image_editor_modules/editor.js new file mode 100644 index 0000000..994941c --- /dev/null +++ b/Image_editor_example/image_editor_modules/editor.js @@ -0,0 +1,1287 @@ +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.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'); + + // Crop Actions + this.overlay.querySelector("#crop-apply").onclick = () => this.applyCrop(); + this.overlay.querySelector("#crop-cancel").onclick = () => this.toggleMode('adjust'); + + // 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 { + this.isDragging = true; + this.lastMousePos = { x: e.clientX, y: e.clientY }; + this.container.style.cursor = 'grabbing'; + } + }); + + window.addEventListener('mousemove', (e) => { + 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); + } + }); + + window.addEventListener('mouseup', () => { + this.isDragging = false; + this.container.style.cursor = this.isCropping ? 'crosshair' : 'grab'; + if (this.isCropping) this.handleCropEnd(); + }); + + // 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(); + } + + 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) { + this.isCropping = mode === 'crop'; + + // Update UI + this.overlay.querySelectorAll(".apix-mode-btn").forEach(b => b.classList.remove("active")); + this.overlay.querySelector(`#tool-${mode}`).classList.add("active"); + + // Show/Hide Crop Controls + const cropPanel = this.overlay.querySelector("#panel-crop-controls"); + // FIXED: Do not hide parent element, just toggle the panel content + + if (mode === 'crop') { + cropPanel.classList.remove("hidden"); + cropPanel.scrollIntoView({ behavior: 'smooth' }); + } else { + cropPanel.classList.add("hidden"); + } + + this.container.style.cursor = this.isCropping ? 'crosshair' : 'grab'; + this.cropBox.style.display = 'none'; + this.cropStart = null; + this.cropRect = null; + } + + // --- 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 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; + + // 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); + } + + 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(); + } + + 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(); + } + + // --- 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 + }); + 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.requestRender(); + }; + img.src = state.image; + } else { + 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); + } + + 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); + } +} diff --git a/Image_editor_example/image_editor_modules/extension.js b/Image_editor_example/image_editor_modules/extension.js new file mode 100644 index 0000000..a29e79e --- /dev/null +++ b/Image_editor_example/image_editor_modules/extension.js @@ -0,0 +1,132 @@ +import { ImageEditor } from "./editor.js"; +import { IMAGE_EDITOR_SUBFOLDER } from "./constants.js"; +import { + parseImageWidgetValue, + extractFilenameFromSrc, + buildEditorFilename, + buildImageReference, + updateWidgetWithRef, + createImageURLFromRef, + setImageSource, + refreshComboLists, +} from "./reference.js"; + +export function registerImageEditorExtension(app, api) { + app.registerExtension({ + name: "SDVN.ImageEditor", + async beforeRegisterNodeDef(nodeType) { + const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (_, options) { + if (this.imgs && this.imgs.length > 0) { + options.push({ + content: "🎨 Image Editor", + callback: () => { + const img = this.imgs[this.imgs.length - 1]; + let src = null; + if (img && img.src) src = img.src; + else if (img && img.image) src = img.image.src; + + if (src) { + new ImageEditor(src, async (blob) => { + const formData = new FormData(); + const inferredName = extractFilenameFromSrc(src); + const editorName = buildEditorFilename(inferredName); + formData.append("image", blob, editorName); + formData.append("overwrite", "false"); + formData.append("type", "input"); + formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER); + + try { + const resp = await api.fetchApi("/upload/image", { + method: "POST", + body: formData, + }); + const data = await resp.json(); + const ref = buildImageReference(data, { + type: "input", + subfolder: IMAGE_EDITOR_SUBFOLDER, + filename: editorName, + }); + const imageWidget = this.widgets?.find?.( + (w) => w.name === "image" || w.type === "image" + ); + if (imageWidget) { + updateWidgetWithRef(this, imageWidget, ref); + } + const newSrc = createImageURLFromRef(api, ref); + if (newSrc) { + setImageSource(img, newSrc); + app.graph.setDirtyCanvas(true); + } + await refreshComboLists(app); + console.info("[SDVN.ImageEditor] Image saved to input folder:", data?.name || editorName); + } catch (e) { + console.error("[SDVN.ImageEditor] Upload failed", e); + } + }); + } + }, + }); + } else if (this.widgets) { + const imageWidget = this.widgets.find((w) => w.name === "image" || w.type === "image"); + if (imageWidget && imageWidget.value) { + options.push({ + content: "🎨 Image Editor", + callback: () => { + const parsed = parseImageWidgetValue(imageWidget.value); + if (!parsed.filename) { + console.warn("[SDVN.ImageEditor] Image not available for editing."); + return; + } + const src = api.apiURL( + `/view?filename=${encodeURIComponent(parsed.filename)}&type=${parsed.type}&subfolder=${encodeURIComponent( + parsed.subfolder + )}` + ); + + new ImageEditor(src, async (blob) => { + const formData = new FormData(); + const newName = buildEditorFilename(parsed.filename); + formData.append("image", blob, newName); + formData.append("overwrite", "false"); + formData.append("type", "input"); + formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER); + + try { + const resp = await api.fetchApi("/upload/image", { + method: "POST", + body: formData, + }); + const data = await resp.json(); + const ref = buildImageReference(data, { + type: "input", + subfolder: IMAGE_EDITOR_SUBFOLDER, + filename: newName, + }); + + if (imageWidget) { + updateWidgetWithRef(this, imageWidget, ref); + } + + const newSrc = createImageURLFromRef(api, ref); + + if (this.imgs && this.imgs.length > 0) { + this.imgs.forEach((img) => setImageSource(img, newSrc)); + } + + this.setDirtyCanvas?.(true, true); + app.graph.setDirtyCanvas(true, true); + await refreshComboLists(app); + } catch (e) { + console.error("[SDVN.ImageEditor] Upload failed", e); + } + }); + }, + }); + } + } + return getExtraMenuOptions?.apply(this, arguments); + }; + }, + }); +} diff --git a/Image_editor_example/image_editor_modules/reference.js b/Image_editor_example/image_editor_modules/reference.js new file mode 100644 index 0000000..1d93aad --- /dev/null +++ b/Image_editor_example/image_editor_modules/reference.js @@ -0,0 +1,149 @@ +export function buildImageReference(data, fallback = {}) { + const ref = { + filename: data?.name || data?.filename || fallback.filename, + subfolder: data?.subfolder ?? fallback.subfolder ?? "", + type: data?.type || fallback.type || "input", + }; + if (!ref.filename) { + return null; + } + return ref; +} + +export function buildAnnotatedLabel(ref) { + if (!ref?.filename) return ""; + const path = ref.subfolder ? `${ref.subfolder}/${ref.filename}` : ref.filename; + return `${path} [${ref.type || "input"}]`; +} + +export function parseImageWidgetValue(value) { + const defaults = { filename: null, subfolder: "", type: "input" }; + if (!value) return defaults; + if (typeof value === "object") { + return { + filename: value.filename || null, + subfolder: value.subfolder || "", + type: value.type || "input", + }; + } + + const raw = value.toString().trim(); + let type = "input"; + let path = raw; + const match = raw.match(/\[([^\]]+)\]\s*$/); + if (match) { + type = match[1].trim() || "input"; + path = raw.slice(0, match.index).trim(); + } + path = path.replace(/^[\\/]+/, ""); + const parts = path.split(/[\\/]/).filter(Boolean); + const filename = parts.pop() || null; + const subfolder = parts.join("/") || ""; + return { filename, subfolder, type }; +} + +export function sanitizeFilenamePart(part) { + return (part || "") + .replace(/[\\/]/g, "_") + .replace(/[<>:"|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, "_"); +} + +export function buildEditorFilename(sourceName) { + let name = sourceName ? sourceName.toString() : ""; + name = name.split(/[\\/]/).pop() || ""; + name = name.replace(/\.[^.]+$/, ""); + name = sanitizeFilenamePart(name); + if (!name) name = `image_${Date.now()}`; + return `${name}.png`; +} + +export function extractFilenameFromSrc(src) { + if (!src) return null; + try { + const url = new URL(src, window.location.origin); + return url.searchParams.get("filename"); + } catch { + return null; + } +} + +export function formatWidgetValueFromRef(ref, currentValue) { + if (currentValue && typeof currentValue === "object") { + return { + ...currentValue, + filename: ref.filename, + subfolder: ref.subfolder, + type: ref.type, + }; + } + return buildAnnotatedLabel(ref); +} + +export function updateWidgetWithRef(node, widget, ref) { + if (!node || !widget || !ref) return; + const annotatedLabel = buildAnnotatedLabel(ref); + const storedValue = formatWidgetValueFromRef(ref, widget.value); + widget.value = storedValue; + widget.callback?.(storedValue); + if (widget.inputEl) { + widget.inputEl.value = annotatedLabel; + } + + if (Array.isArray(node.widgets_values)) { + const idx = node.widgets?.indexOf?.(widget) ?? -1; + if (idx >= 0) { + node.widgets_values[idx] = annotatedLabel; + } + } + + if (Array.isArray(node.inputs)) { + node.inputs.forEach(input => { + if (!input?.widget) return; + if (input.widget === widget || (widget.name && input.widget.name === widget.name)) { + input.widget.value = annotatedLabel; + if (input.widget.inputEl) { + input.widget.inputEl.value = annotatedLabel; + } + } + }); + } + + if (typeof annotatedLabel === "string" && widget.options?.values) { + const values = widget.options.values; + if (Array.isArray(values) && !values.includes(annotatedLabel)) { + values.push(annotatedLabel); + } + } +} + +export function createImageURLFromRef(api, ref) { + if (!ref?.filename) return null; + const params = new URLSearchParams(); + params.set("filename", ref.filename); + params.set("type", ref.type || "input"); + params.set("subfolder", ref.subfolder || ""); + params.set("t", Date.now().toString()); + return api.apiURL(`/view?${params.toString()}`); +} + +export function setImageSource(target, newSrc) { + if (!target || !newSrc) return; + if (target instanceof Image) { + target.src = newSrc; + } else if (target.image instanceof Image) { + target.image.src = newSrc; + } else if (target.img instanceof Image) { + target.img.src = newSrc; + } +} + +export async function refreshComboLists(app) { + if (typeof app.refreshComboInNodes === "function") { + try { + await app.refreshComboInNodes(); + } catch (err) { + console.warn("SDVN.ImageEditor: refreshComboInNodes failed", err); + } + } +} diff --git a/Image_editor_example/image_editor_modules/styles.js b/Image_editor_example/image_editor_modules/styles.js new file mode 100644 index 0000000..e273990 --- /dev/null +++ b/Image_editor_example/image_editor_modules/styles.js @@ -0,0 +1,435 @@ +const STYLE_ID = "sdvn-image-editor-style"; + +const IMAGE_EDITOR_CSS = ` + :root { + --apix-bg: #0f0f0f; + --apix-panel: #1a1a1a; + --apix-border: #2a2a2a; + --apix-text: #e0e0e0; + --apix-text-dim: #888; + --apix-accent: #f5c518; /* Yellow accent from apix */ + --apix-accent-hover: #ffd54f; + --apix-danger: #ff4444; + } + .apix-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--apix-bg); + z-index: 10000; + display: flex; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + color: var(--apix-text); + overflow: hidden; + user-select: none; + } + + /* Left Sidebar (Tools) */ + .apix-sidebar-left { + width: 60px; + background: var(--apix-panel); + border-right: 1px solid var(--apix-border); + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 15px; + z-index: 10; + } + + /* Main Canvas Area */ + .apix-main-area { + flex: 1; + display: flex; + flex-direction: column; + position: relative; + background: #000; + overflow: hidden; + } + .apix-header { + height: 50px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + background: var(--apix-panel); + border-bottom: 1px solid var(--apix-border); + } + .apix-header-title { + font-weight: 700; + color: var(--apix-accent); + font-size: 18px; + display: flex; + align-items: center; + gap: 10px; + } + + .apix-canvas-container { + flex: 1; + position: relative; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + cursor: grab; + } + .apix-canvas-container:active { + cursor: grabbing; + } + + /* Bottom Bar (Zoom) */ + .apix-bottom-bar { + height: 40px; + background: var(--apix-panel); + border-top: 1px solid var(--apix-border); + display: flex; + align-items: center; + justify-content: center; + gap: 15px; + font-size: 12px; + } + +/* Right Sidebar (Adjustments) */ +.apix-sidebar-right { + width: 320px; + background: var(--apix-panel); + border-left: 1px solid var(--apix-border); + display: flex; + flex-direction: column; + z-index: 10; + height: 100vh; + max-height: 100vh; + overflow: hidden; +} +.apix-sidebar-scroll { + flex: 1; + overflow-y: auto; + padding-bottom: 20px; + scrollbar-width: thin; + scrollbar-color: var(--apix-accent) transparent; +} +.apix-sidebar-scroll::-webkit-scrollbar { + width: 6px; +} +.apix-sidebar-scroll::-webkit-scrollbar-thumb { + background: var(--apix-accent); + border-radius: 3px; +} +.apix-sidebar-scroll::-webkit-scrollbar-track { + background: transparent; +} + + /* UI Components */ + .apix-tool-btn { + width: 40px; + height: 40px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--apix-text-dim); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + } + .apix-tool-btn:hover { + color: var(--apix-text); + background: rgba(255,255,255,0.05); + } +.apix-tool-btn.active { + color: #000; + background: var(--apix-accent); +} +.apix-tool-btn.icon-only svg { + width: 18px; + height: 18px; +} +.apix-sidebar-divider { + width: 24px; + height: 1px; + background: var(--apix-border); + margin: 12px 0; +} + + .apix-panel-section { + border-bottom: 1px solid var(--apix-border); + } +.apix-panel-header { + padding: 15px; + font-weight: 600; + font-size: 13px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(255,255,255,0.02); + user-select: none; +} +.apix-panel-header span:first-child { + color: #8d8d8d; + font-weight: 700; + letter-spacing: 0.3px; +} +.apix-panel-header:hover { + background: rgba(255,255,255,0.05); +} + .apix-panel-content { + padding: 15px; + display: flex; + flex-direction: column; + gap: 15px; + } + .apix-panel-content.hidden { + display: none; + } + + .apix-control-row { + display: flex; + flex-direction: column; + gap: 8px; + } +.apix-control-label { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--apix-text-dim); + letter-spacing: 0.2px; + font-weight: 600; +} +.apix-slider-meta { + display: flex; + align-items: center; + justify-content: flex-end; +} +.apix-slider-meta span { + min-width: 36px; + text-align: right; + font-variant-numeric: tabular-nums; +} +.apix-slider-wrapper { + position: relative; + width: 100%; + padding-right: 26px; +} +.apix-slider-reset { + border: none; + background: transparent; + color: var(--apix-text-dim); + cursor: pointer; + width: 22px; + height: 22px; + position: absolute; + right: 0; + top: 56%; + transform: translateY(-50%); + opacity: 0.4; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s, color 0.2s; +} +.apix-slider-reset:hover { + opacity: 1; + color: var(--apix-accent); +} +.apix-slider-reset svg { + width: 12px; + height: 12px; + pointer-events: none; +} + +.apix-curve-panel { + display: flex; + flex-direction: column; + gap: 10px; +} +.apix-curve-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: var(--apix-text-dim); + gap: 8px; +} +.apix-curve-channel-buttons { + display: flex; + gap: 6px; +} +.apix-curve-channel-btn { + border: 1px solid var(--apix-border); + background: transparent; + color: var(--apix-text-dim); + font-size: 10px; + padding: 2px 8px; + border-radius: 999px; + cursor: pointer; + transition: all 0.2s; +} +.apix-curve-channel-btn.active { + background: var(--apix-accent); + color: #000; + border-color: var(--apix-accent); +} +.apix-curve-reset { + border: none; + background: transparent; + color: var(--apix-accent); + font-size: 11px; + cursor: pointer; + padding: 0 4px; +} +.apix-curve-stage { + width: 100%; + height: 240px; + border: 1px solid var(--apix-border); + border-radius: 8px; + background: linear-gradient(180deg, rgba(255,255,255,0.05) 0%, rgba(0,0,0,0.25) 100%); + position: relative; + overflow: hidden; +} +.apix-curve-stage canvas { + width: 100%; + height: 100%; + display: block; +} + + .apix-slider { + -webkit-appearance: none; + width: 100%; + height: 4px; + background: #333; + border-radius: 2px; + outline: none; + } + .apix-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--apix-accent); + cursor: pointer; + border: 2px solid #1a1a1a; + transition: transform 0.1s; + } + .apix-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); + } + + .apix-btn { + padding: 8px 16px; + border-radius: 6px; + border: none; + font-weight: 600; + cursor: pointer; + font-size: 12px; + transition: all 0.2s; + } +.apix-btn-primary { + background: var(--apix-accent); + color: #000; +} +.apix-btn-primary:hover { + background: var(--apix-accent-hover); +} +.apix-btn-secondary { + background: #333; + color: #fff; +} +.apix-btn-secondary:hover { + background: #444; +} +.apix-btn-toggle.active { + background: var(--apix-accent); + color: #000; +} +.apix-hsl-swatches { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.apix-hsl-chip { + width: 26px; + height: 26px; + border-radius: 50%; + border: 2px solid transparent; + background: var(--chip-color, #fff); + cursor: pointer; + transition: transform 0.2s, border 0.2s; +} +.apix-hsl-chip.active { + border-color: var(--apix-accent); + transform: scale(1.05); +} +.apix-hsl-slider .apix-slider-meta span { + font-size: 11px; + color: var(--apix-text-dim); +} +.apix-hsl-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + align-items: center; + font-size: 11px; + color: var(--apix-text-dim); +} +.apix-hsl-reset { + border: none; + background: transparent; + color: var(--apix-accent); + cursor: pointer; + font-size: 11px; +} + + .apix-sidebar-right { + position: relative; + } + .apix-footer { + padding: 20px; + border-top: 1px solid var(--apix-border); + display: flex; + justify-content: flex-end; + gap: 10px; + background: var(--apix-panel); + } + + /* Crop Overlay */ + .apix-crop-overlay { + position: absolute; + border: 1px solid rgba(255, 255, 255, 0.5); + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.7); + pointer-events: none; + display: none; + } + .apix-crop-handle { + position: absolute; + width: 12px; + height: 12px; + background: var(--apix-accent); + border: 1px solid #000; + pointer-events: auto; + z-index: 100; + } + /* Handle positions */ + .handle-tl { top: -6px; left: -6px; cursor: nw-resize; } + .handle-tr { top: -6px; right: -6px; cursor: ne-resize; } + .handle-bl { bottom: -6px; left: -6px; cursor: sw-resize; } + .handle-br { bottom: -6px; right: -6px; cursor: se-resize; } + /* Edges */ + .handle-t { top: -6px; left: 50%; transform: translateX(-50%); cursor: n-resize; } + .handle-b { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: s-resize; } + .handle-l { left: -6px; top: 50%; transform: translateY(-50%); cursor: w-resize; } + .handle-r { right: -6px; top: 50%; transform: translateY(-50%); cursor: e-resize; } +`; + +export function injectImageEditorStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = IMAGE_EDITOR_CSS; + document.head.appendChild(style); +} diff --git a/static/image_editor_modules/color.js b/static/image_editor_modules/color.js new file mode 100644 index 0000000..2c50838 --- /dev/null +++ b/static/image_editor_modules/color.js @@ -0,0 +1,71 @@ +export function clamp01(value) { + return Math.min(1, Math.max(0, value)); +} + +export function clamp255(value) { + return Math.min(255, Math.max(0, value)); +} + +export function rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h; + let s; + const l = (max + min) / 2; + if (max === min) { + h = 0; + s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + default: + h = (r - g) / d + 4; + } + h /= 6; + } + return { h, s, l }; +} + +export function hslToRgb(h, s, l) { + let r; + let g; + let b; + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255) + }; +} + +export function hueDistance(a, b) { + let diff = Math.abs(a - b); + diff = Math.min(diff, 1 - diff); + return diff; +} diff --git a/static/image_editor_modules/constants.js b/static/image_editor_modules/constants.js new file mode 100644 index 0000000..48b88ba --- /dev/null +++ b/static/image_editor_modules/constants.js @@ -0,0 +1,26 @@ +export const ICONS = { + crop: ``, + adjust: ``, + undo: ``, + redo: ``, + reset: ``, + chevronDown: ``, + close: ``, + flipH: ``, + flipV: ``, + rotate: ``, + brush: ``, + pen: `` +}; + +export const HSL_COLORS = [ + { id: "red", label: "Red", color: "#ff4b4b", center: 0 / 360, width: 0.12 }, + { id: "orange", label: "Orange", color: "#ff884d", center: 30 / 360, width: 0.12 }, + { id: "yellow", label: "Yellow", color: "#ffd84d", center: 50 / 360, width: 0.12 }, + { id: "green", label: "Green", color: "#45d98e", center: 120 / 360, width: 0.12 }, + { id: "cyan", label: "Cyan", color: "#30c4ff", center: 180 / 360, width: 0.12 }, + { id: "blue", label: "Blue", color: "#2f7bff", center: 220 / 360, width: 0.12 }, + { id: "magenta", label: "Magenta", color: "#c95bff", center: 300 / 360, width: 0.12 } +]; + +export const IMAGE_EDITOR_SUBFOLDER = "image_editor"; diff --git a/static/image_editor_modules/curve.js b/static/image_editor_modules/curve.js new file mode 100644 index 0000000..eb4d7d8 --- /dev/null +++ b/static/image_editor_modules/curve.js @@ -0,0 +1,493 @@ +import { clamp01 } from "./color.js"; + +const CHANNEL_COLORS = { + rgb: "#ffffff", + r: "#ff7070", + g: "#70ffa0", + b: "#72a0ff" +}; + +export class CurveEditor { + constructor({ canvas, channelButtons = [], resetButton, onChange, onCommit }) { + this.canvas = canvas; + this.ctx = canvas.getContext("2d"); + this.channelButtons = channelButtons; + this.resetButton = resetButton; + this.onChange = onChange; + this.onCommit = onCommit; + + this.channels = ["rgb", "r", "g", "b"]; + this.activeChannel = "rgb"; + this.curves = this.createDefaultCurves(); + this.curveTangents = {}; + this.channels.forEach(channel => (this.curveTangents[channel] = [])); + this.luts = this.buildAllLUTs(); + this.isDragging = false; + this.dragIndex = null; + this.curveDirty = false; + this.displayWidth = this.canvas.clientWidth || 240; + this.displayHeight = this.canvas.clientHeight || 240; + + this.resizeObserver = null; + this.handleResize = this.handleResize.bind(this); + this.onPointerDown = this.onPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + this.onDoubleClick = this.onDoubleClick.bind(this); + + window.addEventListener("resize", this.handleResize); + this.canvas.addEventListener("mousedown", this.onPointerDown); + window.addEventListener("mousemove", this.onPointerMove); + window.addEventListener("mouseup", this.onPointerUp); + this.canvas.addEventListener("dblclick", this.onDoubleClick); + + this.attachChannelButtons(); + this.attachResetButton(); + this.handleResize(); + if (window.ResizeObserver) { + this.resizeObserver = new ResizeObserver(() => this.handleResize()); + this.resizeObserver.observe(this.canvas); + } + this.draw(); + } + + destroy() { + this.resizeObserver?.disconnect(); + window.removeEventListener("resize", this.handleResize); + this.canvas.removeEventListener("mousedown", this.onPointerDown); + window.removeEventListener("mousemove", this.onPointerMove); + window.removeEventListener("mouseup", this.onPointerUp); + this.canvas.removeEventListener("dblclick", this.onDoubleClick); + } + + attachChannelButtons() { + this.channelButtons.forEach(btn => { + btn.addEventListener("click", () => { + const channel = btn.dataset.curveChannel; + if (channel && this.channels.includes(channel)) { + this.activeChannel = channel; + this.updateChannelButtons(); + this.draw(); + } + }); + }); + this.updateChannelButtons(); + } + + attachResetButton() { + if (!this.resetButton) return; + this.resetButton.addEventListener("click", () => { + this.resetChannel(this.activeChannel); + this.notifyChange(); + this.notifyCommit(); + }); + } + + updateChannelButtons() { + this.channelButtons.forEach(btn => { + const channel = btn.dataset.curveChannel; + btn.classList.toggle("active", channel === this.activeChannel); + }); + } + + handleResize() { + const rect = this.canvas.getBoundingClientRect(); + const width = Math.max(1, rect.width || 240); + const height = Math.max(1, rect.height || 240); + this.displayWidth = width; + this.displayHeight = height; + const dpr = window.devicePixelRatio || 1; + this.canvas.width = width * dpr; + this.canvas.height = height * dpr; + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.scale(dpr, dpr); + this.draw(); + } + + createDefaultCurve() { + return [ + { x: 0, y: 0 }, + { x: 1, y: 1 } + ]; + } + + createDefaultCurves() { + return { + rgb: this.createDefaultCurve().map(p => ({ ...p })), + r: this.createDefaultCurve().map(p => ({ ...p })), + g: this.createDefaultCurve().map(p => ({ ...p })), + b: this.createDefaultCurve().map(p => ({ ...p })) + }; + } + + cloneCurves(source = this.curves) { + const clone = {}; + this.channels.forEach(channel => { + clone[channel] = (source[channel] || this.createDefaultCurve()).map(p => ({ x: p.x, y: p.y })); + }); + return clone; + } + + setState(state) { + if (!state) return; + const incoming = this.cloneCurves(state.curves || {}); + this.curves = incoming; + if (state.activeChannel && this.channels.includes(state.activeChannel)) { + this.activeChannel = state.activeChannel; + } + this.rebuildAllLUTs(); + this.updateChannelButtons(); + this.draw(); + } + + getState() { + return { + curves: this.cloneCurves(), + activeChannel: this.activeChannel + }; + } + + resetChannel(channel, emit = false) { + if (!this.channels.includes(channel)) return; + this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p })); + this.rebuildChannelLUT(channel); + this.draw(); + if (emit) { + this.notifyChange(); + this.notifyCommit(); + this.curveDirty = false; + } + } + + resetAll(emit = true) { + this.channels.forEach(channel => { + this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p })); + }); + this.rebuildAllLUTs(); + this.draw(); + if (emit) { + this.notifyChange(); + this.notifyCommit(); + this.curveDirty = false; + } + } + + hasAdjustments() { + return this.channels.some(channel => !this.isDefaultCurve(this.curves[channel])); + } + + isDefaultCurve(curve) { + if (!curve || curve.length !== 2) return false; + const [start, end] = curve; + const epsilon = 0.0001; + return Math.abs(start.x) < epsilon && Math.abs(start.y) < epsilon && + Math.abs(end.x - 1) < epsilon && Math.abs(end.y - 1) < epsilon; + } + + notifyChange() { + this.onChange?.(); + } + + notifyCommit() { + this.onCommit?.(); + } + + getLUTPack() { + return { + rgb: this.isDefaultCurve(this.curves.rgb) ? null : this.luts.rgb, + r: this.isDefaultCurve(this.curves.r) ? null : this.luts.r, + g: this.isDefaultCurve(this.curves.g) ? null : this.luts.g, + b: this.isDefaultCurve(this.curves.b) ? null : this.luts.b, + hasAdjustments: this.hasAdjustments() + }; + } + + buildAllLUTs() { + const result = {}; + this.channels.forEach(channel => { + const curve = this.curves[channel]; + const tangents = this.computeTangents(curve); + this.curveTangents[channel] = tangents; + result[channel] = this.buildCurveLUT(curve, tangents); + }); + return result; + } + + rebuildAllLUTs() { + this.luts = this.buildAllLUTs(); + } + + rebuildChannelLUT(channel) { + const curve = this.curves[channel]; + const tangents = this.computeTangents(curve); + this.curveTangents[channel] = tangents; + this.luts[channel] = this.buildCurveLUT(curve, tangents); + } + + buildCurveLUT(curve, tangents = null) { + const curveTangents = tangents || this.computeTangents(curve); + const lut = new Uint8ClampedArray(256); + for (let i = 0; i < 256; i++) { + const pos = i / 255; + lut[i] = Math.round(clamp01(this.sampleSmoothCurve(curve, pos, curveTangents)) * 255); + } + return lut; + } + + computeTangents(curve) { + const n = curve.length; + if (n < 2) return new Array(n).fill(0); + const tangents = new Array(n).fill(0); + const delta = new Array(n - 1).fill(0); + const dx = new Array(n - 1).fill(0); + for (let i = 0; i < n - 1; i++) { + dx[i] = Math.max(1e-6, curve[i + 1].x - curve[i].x); + delta[i] = (curve[i + 1].y - curve[i].y) / dx[i]; + } + tangents[0] = delta[0]; + tangents[n - 1] = delta[n - 2]; + for (let i = 1; i < n - 1; i++) { + if (delta[i - 1] * delta[i] <= 0) { + tangents[i] = 0; + } else { + const w1 = 2 * dx[i] + dx[i - 1]; + const w2 = dx[i] + 2 * dx[i - 1]; + tangents[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]); + } + } + for (let i = 0; i < n - 1; i++) { + if (Math.abs(delta[i]) < 1e-6) { + tangents[i] = 0; + tangents[i + 1] = 0; + } else { + let alpha = tangents[i] / delta[i]; + let beta = tangents[i + 1] / delta[i]; + const sum = alpha * alpha + beta * beta; + if (sum > 9) { + const tau = 3 / Math.sqrt(sum); + alpha *= tau; + beta *= tau; + tangents[i] = alpha * delta[i]; + tangents[i + 1] = beta * delta[i]; + } + } + } + return tangents; + } + + sampleSmoothCurve(curve, t, tangents) { + if (!curve || curve.length === 0) return t; + const n = curve.length; + if (!tangents || tangents.length !== n) { + tangents = this.computeTangents(curve); + } + if (t <= curve[0].x) return curve[0].y; + if (t >= curve[n - 1].x) return curve[n - 1].y; + let idx = 1; + for (; idx < n; idx++) { + if (t <= curve[idx].x) break; + } + const p0 = curve[idx - 1]; + const p1 = curve[idx]; + const m0 = tangents[idx - 1] ?? 0; + const m1 = tangents[idx] ?? 0; + const span = p1.x - p0.x || 1e-6; + const u = (t - p0.x) / span; + const h00 = (2 * u ** 3) - (3 * u ** 2) + 1; + const h10 = u ** 3 - 2 * u ** 2 + u; + const h01 = (-2 * u ** 3) + (3 * u ** 2); + const h11 = u ** 3 - u ** 2; + const value = h00 * p0.y + h10 * span * m0 + h01 * p1.y + h11 * span * m1; + return clamp01(value); + } + + getActiveCurve() { + return this.curves[this.activeChannel]; + } + + addPoint(x, y) { + const points = this.getActiveCurve(); + let insertIndex = points.findIndex(point => x < point.x); + if (insertIndex === -1) { + points.push({ x, y }); + insertIndex = points.length - 1; + } else { + points.splice(insertIndex, 0, { x, y }); + } + this.rebuildChannelLUT(this.activeChannel); + this.draw(); + this.curveDirty = true; + this.notifyChange(); + return insertIndex; + } + + updatePoint(index, x, y) { + const points = this.getActiveCurve(); + const point = points[index]; + if (!point) return; + const originalX = point.x; + const originalY = point.y; + if (index === 0) { + point.x = 0; + point.y = clamp01(y); + } else if (index === points.length - 1) { + point.x = 1; + point.y = clamp01(y); + } else { + const minX = points[index - 1].x + 0.01; + const maxX = points[index + 1].x - 0.01; + point.x = clamp01(Math.min(Math.max(x, minX), maxX)); + point.y = clamp01(y); + } + if (Math.abs(originalX - point.x) < 0.0001 && Math.abs(originalY - point.y) < 0.0001) { + return; + } + this.rebuildChannelLUT(this.activeChannel); + this.draw(); + this.curveDirty = true; + this.notifyChange(); + } + + removePoint(index) { + const points = this.getActiveCurve(); + if (index <= 0 || index >= points.length - 1) return; + points.splice(index, 1); + this.rebuildChannelLUT(this.activeChannel); + this.draw(); + this.notifyChange(); + this.notifyCommit(); + this.curveDirty = false; + } + + getPointerPosition(event) { + const rect = this.canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + const x = clamp01((event.clientX - rect.left) / rect.width); + const y = clamp01(1 - (event.clientY - rect.top) / rect.height); + return { x, y }; + } + + findPointIndex(pos, threshold = 10) { + if (!pos) return -1; + const points = this.getActiveCurve(); + const targetX = pos.x * this.displayWidth; + const targetY = (1 - pos.y) * this.displayHeight; + for (let i = 0; i < points.length; i++) { + const pt = points[i]; + const px = pt.x * this.displayWidth; + const py = (1 - pt.y) * this.displayHeight; + const dist = Math.hypot(px - targetX, py - targetY); + if (dist <= threshold) return i; + } + return -1; + } + + onPointerDown(event) { + if (event.button !== 0) return; + const pos = this.getPointerPosition(event); + if (!pos) return; + event.preventDefault(); + let idx = this.findPointIndex(pos); + if (idx === -1) { + idx = this.addPoint(pos.x, pos.y); + } + this.dragIndex = idx; + this.isDragging = true; + this.updatePoint(idx, pos.x, pos.y); + } + + onPointerMove(event) { + if (!this.isDragging || this.dragIndex === null) return; + const pos = this.getPointerPosition(event); + if (!pos) return; + event.preventDefault(); + this.updatePoint(this.dragIndex, pos.x, pos.y); + } + + onPointerUp() { + if (!this.isDragging) return; + this.isDragging = false; + this.dragIndex = null; + if (this.curveDirty) { + this.curveDirty = false; + this.notifyCommit(); + } + } + + onDoubleClick(event) { + const pos = this.getPointerPosition(event); + if (!pos) return; + const idx = this.findPointIndex(pos, 8); + if (idx > 0 && idx < this.getActiveCurve().length - 1) { + this.removePoint(idx); + } + } + + getChannelColor() { + return CHANNEL_COLORS[this.activeChannel] || "#ffffff"; + } + + draw() { + if (!this.ctx) return; + const ctx = this.ctx; + const w = this.displayWidth; + const h = this.displayHeight; + ctx.clearRect(0, 0, w, h); + this.drawGrid(ctx, w, h); + this.drawCurve(ctx, w, h); + this.drawPoints(ctx, w, h); + } + + drawGrid(ctx, w, h) { + ctx.fillStyle = "rgba(0,0,0,0.5)"; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.lineWidth = 1; + for (let i = 1; i < 4; i++) { + const x = (w / 4) * i; + const y = (h / 4) * i; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + } + + drawCurve(ctx, w, h) { + const points = this.getActiveCurve(); + if (!points?.length) return; + const tangents = this.curveTangents[this.activeChannel] || this.computeTangents(points); + ctx.strokeStyle = this.getChannelColor(); + ctx.lineWidth = 2; + ctx.beginPath(); + const steps = 128; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const value = this.sampleSmoothCurve(points, t, tangents); + const x = t * w; + const y = (1 - value) * h; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + drawPoints(ctx, w, h) { + const points = this.getActiveCurve(); + ctx.fillStyle = "#000"; + ctx.lineWidth = 2; + ctx.strokeStyle = this.getChannelColor(); + points.forEach(pt => { + const x = pt.x * w; + const y = (1 - pt.y) * h; + ctx.beginPath(); + ctx.arc(x, y, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + }); + } +} diff --git a/static/image_editor_modules/editor.js b/static/image_editor_modules/editor.js new file mode 100644 index 0000000..0896c83 --- /dev/null +++ b/static/image_editor_modules/editor.js @@ -0,0 +1,2220 @@ +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); + } +} diff --git a/static/image_editor_modules/extension.js b/static/image_editor_modules/extension.js new file mode 100644 index 0000000..a29e79e --- /dev/null +++ b/static/image_editor_modules/extension.js @@ -0,0 +1,132 @@ +import { ImageEditor } from "./editor.js"; +import { IMAGE_EDITOR_SUBFOLDER } from "./constants.js"; +import { + parseImageWidgetValue, + extractFilenameFromSrc, + buildEditorFilename, + buildImageReference, + updateWidgetWithRef, + createImageURLFromRef, + setImageSource, + refreshComboLists, +} from "./reference.js"; + +export function registerImageEditorExtension(app, api) { + app.registerExtension({ + name: "SDVN.ImageEditor", + async beforeRegisterNodeDef(nodeType) { + const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (_, options) { + if (this.imgs && this.imgs.length > 0) { + options.push({ + content: "🎨 Image Editor", + callback: () => { + const img = this.imgs[this.imgs.length - 1]; + let src = null; + if (img && img.src) src = img.src; + else if (img && img.image) src = img.image.src; + + if (src) { + new ImageEditor(src, async (blob) => { + const formData = new FormData(); + const inferredName = extractFilenameFromSrc(src); + const editorName = buildEditorFilename(inferredName); + formData.append("image", blob, editorName); + formData.append("overwrite", "false"); + formData.append("type", "input"); + formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER); + + try { + const resp = await api.fetchApi("/upload/image", { + method: "POST", + body: formData, + }); + const data = await resp.json(); + const ref = buildImageReference(data, { + type: "input", + subfolder: IMAGE_EDITOR_SUBFOLDER, + filename: editorName, + }); + const imageWidget = this.widgets?.find?.( + (w) => w.name === "image" || w.type === "image" + ); + if (imageWidget) { + updateWidgetWithRef(this, imageWidget, ref); + } + const newSrc = createImageURLFromRef(api, ref); + if (newSrc) { + setImageSource(img, newSrc); + app.graph.setDirtyCanvas(true); + } + await refreshComboLists(app); + console.info("[SDVN.ImageEditor] Image saved to input folder:", data?.name || editorName); + } catch (e) { + console.error("[SDVN.ImageEditor] Upload failed", e); + } + }); + } + }, + }); + } else if (this.widgets) { + const imageWidget = this.widgets.find((w) => w.name === "image" || w.type === "image"); + if (imageWidget && imageWidget.value) { + options.push({ + content: "🎨 Image Editor", + callback: () => { + const parsed = parseImageWidgetValue(imageWidget.value); + if (!parsed.filename) { + console.warn("[SDVN.ImageEditor] Image not available for editing."); + return; + } + const src = api.apiURL( + `/view?filename=${encodeURIComponent(parsed.filename)}&type=${parsed.type}&subfolder=${encodeURIComponent( + parsed.subfolder + )}` + ); + + new ImageEditor(src, async (blob) => { + const formData = new FormData(); + const newName = buildEditorFilename(parsed.filename); + formData.append("image", blob, newName); + formData.append("overwrite", "false"); + formData.append("type", "input"); + formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER); + + try { + const resp = await api.fetchApi("/upload/image", { + method: "POST", + body: formData, + }); + const data = await resp.json(); + const ref = buildImageReference(data, { + type: "input", + subfolder: IMAGE_EDITOR_SUBFOLDER, + filename: newName, + }); + + if (imageWidget) { + updateWidgetWithRef(this, imageWidget, ref); + } + + const newSrc = createImageURLFromRef(api, ref); + + if (this.imgs && this.imgs.length > 0) { + this.imgs.forEach((img) => setImageSource(img, newSrc)); + } + + this.setDirtyCanvas?.(true, true); + app.graph.setDirtyCanvas(true, true); + await refreshComboLists(app); + } catch (e) { + console.error("[SDVN.ImageEditor] Upload failed", e); + } + }); + }, + }); + } + } + return getExtraMenuOptions?.apply(this, arguments); + }; + }, + }); +} diff --git a/static/image_editor_modules/reference.js b/static/image_editor_modules/reference.js new file mode 100644 index 0000000..1d93aad --- /dev/null +++ b/static/image_editor_modules/reference.js @@ -0,0 +1,149 @@ +export function buildImageReference(data, fallback = {}) { + const ref = { + filename: data?.name || data?.filename || fallback.filename, + subfolder: data?.subfolder ?? fallback.subfolder ?? "", + type: data?.type || fallback.type || "input", + }; + if (!ref.filename) { + return null; + } + return ref; +} + +export function buildAnnotatedLabel(ref) { + if (!ref?.filename) return ""; + const path = ref.subfolder ? `${ref.subfolder}/${ref.filename}` : ref.filename; + return `${path} [${ref.type || "input"}]`; +} + +export function parseImageWidgetValue(value) { + const defaults = { filename: null, subfolder: "", type: "input" }; + if (!value) return defaults; + if (typeof value === "object") { + return { + filename: value.filename || null, + subfolder: value.subfolder || "", + type: value.type || "input", + }; + } + + const raw = value.toString().trim(); + let type = "input"; + let path = raw; + const match = raw.match(/\[([^\]]+)\]\s*$/); + if (match) { + type = match[1].trim() || "input"; + path = raw.slice(0, match.index).trim(); + } + path = path.replace(/^[\\/]+/, ""); + const parts = path.split(/[\\/]/).filter(Boolean); + const filename = parts.pop() || null; + const subfolder = parts.join("/") || ""; + return { filename, subfolder, type }; +} + +export function sanitizeFilenamePart(part) { + return (part || "") + .replace(/[\\/]/g, "_") + .replace(/[<>:"|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, "_"); +} + +export function buildEditorFilename(sourceName) { + let name = sourceName ? sourceName.toString() : ""; + name = name.split(/[\\/]/).pop() || ""; + name = name.replace(/\.[^.]+$/, ""); + name = sanitizeFilenamePart(name); + if (!name) name = `image_${Date.now()}`; + return `${name}.png`; +} + +export function extractFilenameFromSrc(src) { + if (!src) return null; + try { + const url = new URL(src, window.location.origin); + return url.searchParams.get("filename"); + } catch { + return null; + } +} + +export function formatWidgetValueFromRef(ref, currentValue) { + if (currentValue && typeof currentValue === "object") { + return { + ...currentValue, + filename: ref.filename, + subfolder: ref.subfolder, + type: ref.type, + }; + } + return buildAnnotatedLabel(ref); +} + +export function updateWidgetWithRef(node, widget, ref) { + if (!node || !widget || !ref) return; + const annotatedLabel = buildAnnotatedLabel(ref); + const storedValue = formatWidgetValueFromRef(ref, widget.value); + widget.value = storedValue; + widget.callback?.(storedValue); + if (widget.inputEl) { + widget.inputEl.value = annotatedLabel; + } + + if (Array.isArray(node.widgets_values)) { + const idx = node.widgets?.indexOf?.(widget) ?? -1; + if (idx >= 0) { + node.widgets_values[idx] = annotatedLabel; + } + } + + if (Array.isArray(node.inputs)) { + node.inputs.forEach(input => { + if (!input?.widget) return; + if (input.widget === widget || (widget.name && input.widget.name === widget.name)) { + input.widget.value = annotatedLabel; + if (input.widget.inputEl) { + input.widget.inputEl.value = annotatedLabel; + } + } + }); + } + + if (typeof annotatedLabel === "string" && widget.options?.values) { + const values = widget.options.values; + if (Array.isArray(values) && !values.includes(annotatedLabel)) { + values.push(annotatedLabel); + } + } +} + +export function createImageURLFromRef(api, ref) { + if (!ref?.filename) return null; + const params = new URLSearchParams(); + params.set("filename", ref.filename); + params.set("type", ref.type || "input"); + params.set("subfolder", ref.subfolder || ""); + params.set("t", Date.now().toString()); + return api.apiURL(`/view?${params.toString()}`); +} + +export function setImageSource(target, newSrc) { + if (!target || !newSrc) return; + if (target instanceof Image) { + target.src = newSrc; + } else if (target.image instanceof Image) { + target.image.src = newSrc; + } else if (target.img instanceof Image) { + target.img.src = newSrc; + } +} + +export async function refreshComboLists(app) { + if (typeof app.refreshComboInNodes === "function") { + try { + await app.refreshComboInNodes(); + } catch (err) { + console.warn("SDVN.ImageEditor: refreshComboInNodes failed", err); + } + } +} diff --git a/static/image_editor_modules/styles.js b/static/image_editor_modules/styles.js new file mode 100644 index 0000000..e273990 --- /dev/null +++ b/static/image_editor_modules/styles.js @@ -0,0 +1,435 @@ +const STYLE_ID = "sdvn-image-editor-style"; + +const IMAGE_EDITOR_CSS = ` + :root { + --apix-bg: #0f0f0f; + --apix-panel: #1a1a1a; + --apix-border: #2a2a2a; + --apix-text: #e0e0e0; + --apix-text-dim: #888; + --apix-accent: #f5c518; /* Yellow accent from apix */ + --apix-accent-hover: #ffd54f; + --apix-danger: #ff4444; + } + .apix-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--apix-bg); + z-index: 10000; + display: flex; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + color: var(--apix-text); + overflow: hidden; + user-select: none; + } + + /* Left Sidebar (Tools) */ + .apix-sidebar-left { + width: 60px; + background: var(--apix-panel); + border-right: 1px solid var(--apix-border); + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 15px; + z-index: 10; + } + + /* Main Canvas Area */ + .apix-main-area { + flex: 1; + display: flex; + flex-direction: column; + position: relative; + background: #000; + overflow: hidden; + } + .apix-header { + height: 50px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + background: var(--apix-panel); + border-bottom: 1px solid var(--apix-border); + } + .apix-header-title { + font-weight: 700; + color: var(--apix-accent); + font-size: 18px; + display: flex; + align-items: center; + gap: 10px; + } + + .apix-canvas-container { + flex: 1; + position: relative; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + cursor: grab; + } + .apix-canvas-container:active { + cursor: grabbing; + } + + /* Bottom Bar (Zoom) */ + .apix-bottom-bar { + height: 40px; + background: var(--apix-panel); + border-top: 1px solid var(--apix-border); + display: flex; + align-items: center; + justify-content: center; + gap: 15px; + font-size: 12px; + } + +/* Right Sidebar (Adjustments) */ +.apix-sidebar-right { + width: 320px; + background: var(--apix-panel); + border-left: 1px solid var(--apix-border); + display: flex; + flex-direction: column; + z-index: 10; + height: 100vh; + max-height: 100vh; + overflow: hidden; +} +.apix-sidebar-scroll { + flex: 1; + overflow-y: auto; + padding-bottom: 20px; + scrollbar-width: thin; + scrollbar-color: var(--apix-accent) transparent; +} +.apix-sidebar-scroll::-webkit-scrollbar { + width: 6px; +} +.apix-sidebar-scroll::-webkit-scrollbar-thumb { + background: var(--apix-accent); + border-radius: 3px; +} +.apix-sidebar-scroll::-webkit-scrollbar-track { + background: transparent; +} + + /* UI Components */ + .apix-tool-btn { + width: 40px; + height: 40px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--apix-text-dim); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + } + .apix-tool-btn:hover { + color: var(--apix-text); + background: rgba(255,255,255,0.05); + } +.apix-tool-btn.active { + color: #000; + background: var(--apix-accent); +} +.apix-tool-btn.icon-only svg { + width: 18px; + height: 18px; +} +.apix-sidebar-divider { + width: 24px; + height: 1px; + background: var(--apix-border); + margin: 12px 0; +} + + .apix-panel-section { + border-bottom: 1px solid var(--apix-border); + } +.apix-panel-header { + padding: 15px; + font-weight: 600; + font-size: 13px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(255,255,255,0.02); + user-select: none; +} +.apix-panel-header span:first-child { + color: #8d8d8d; + font-weight: 700; + letter-spacing: 0.3px; +} +.apix-panel-header:hover { + background: rgba(255,255,255,0.05); +} + .apix-panel-content { + padding: 15px; + display: flex; + flex-direction: column; + gap: 15px; + } + .apix-panel-content.hidden { + display: none; + } + + .apix-control-row { + display: flex; + flex-direction: column; + gap: 8px; + } +.apix-control-label { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--apix-text-dim); + letter-spacing: 0.2px; + font-weight: 600; +} +.apix-slider-meta { + display: flex; + align-items: center; + justify-content: flex-end; +} +.apix-slider-meta span { + min-width: 36px; + text-align: right; + font-variant-numeric: tabular-nums; +} +.apix-slider-wrapper { + position: relative; + width: 100%; + padding-right: 26px; +} +.apix-slider-reset { + border: none; + background: transparent; + color: var(--apix-text-dim); + cursor: pointer; + width: 22px; + height: 22px; + position: absolute; + right: 0; + top: 56%; + transform: translateY(-50%); + opacity: 0.4; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s, color 0.2s; +} +.apix-slider-reset:hover { + opacity: 1; + color: var(--apix-accent); +} +.apix-slider-reset svg { + width: 12px; + height: 12px; + pointer-events: none; +} + +.apix-curve-panel { + display: flex; + flex-direction: column; + gap: 10px; +} +.apix-curve-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: var(--apix-text-dim); + gap: 8px; +} +.apix-curve-channel-buttons { + display: flex; + gap: 6px; +} +.apix-curve-channel-btn { + border: 1px solid var(--apix-border); + background: transparent; + color: var(--apix-text-dim); + font-size: 10px; + padding: 2px 8px; + border-radius: 999px; + cursor: pointer; + transition: all 0.2s; +} +.apix-curve-channel-btn.active { + background: var(--apix-accent); + color: #000; + border-color: var(--apix-accent); +} +.apix-curve-reset { + border: none; + background: transparent; + color: var(--apix-accent); + font-size: 11px; + cursor: pointer; + padding: 0 4px; +} +.apix-curve-stage { + width: 100%; + height: 240px; + border: 1px solid var(--apix-border); + border-radius: 8px; + background: linear-gradient(180deg, rgba(255,255,255,0.05) 0%, rgba(0,0,0,0.25) 100%); + position: relative; + overflow: hidden; +} +.apix-curve-stage canvas { + width: 100%; + height: 100%; + display: block; +} + + .apix-slider { + -webkit-appearance: none; + width: 100%; + height: 4px; + background: #333; + border-radius: 2px; + outline: none; + } + .apix-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--apix-accent); + cursor: pointer; + border: 2px solid #1a1a1a; + transition: transform 0.1s; + } + .apix-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); + } + + .apix-btn { + padding: 8px 16px; + border-radius: 6px; + border: none; + font-weight: 600; + cursor: pointer; + font-size: 12px; + transition: all 0.2s; + } +.apix-btn-primary { + background: var(--apix-accent); + color: #000; +} +.apix-btn-primary:hover { + background: var(--apix-accent-hover); +} +.apix-btn-secondary { + background: #333; + color: #fff; +} +.apix-btn-secondary:hover { + background: #444; +} +.apix-btn-toggle.active { + background: var(--apix-accent); + color: #000; +} +.apix-hsl-swatches { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.apix-hsl-chip { + width: 26px; + height: 26px; + border-radius: 50%; + border: 2px solid transparent; + background: var(--chip-color, #fff); + cursor: pointer; + transition: transform 0.2s, border 0.2s; +} +.apix-hsl-chip.active { + border-color: var(--apix-accent); + transform: scale(1.05); +} +.apix-hsl-slider .apix-slider-meta span { + font-size: 11px; + color: var(--apix-text-dim); +} +.apix-hsl-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + align-items: center; + font-size: 11px; + color: var(--apix-text-dim); +} +.apix-hsl-reset { + border: none; + background: transparent; + color: var(--apix-accent); + cursor: pointer; + font-size: 11px; +} + + .apix-sidebar-right { + position: relative; + } + .apix-footer { + padding: 20px; + border-top: 1px solid var(--apix-border); + display: flex; + justify-content: flex-end; + gap: 10px; + background: var(--apix-panel); + } + + /* Crop Overlay */ + .apix-crop-overlay { + position: absolute; + border: 1px solid rgba(255, 255, 255, 0.5); + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.7); + pointer-events: none; + display: none; + } + .apix-crop-handle { + position: absolute; + width: 12px; + height: 12px; + background: var(--apix-accent); + border: 1px solid #000; + pointer-events: auto; + z-index: 100; + } + /* Handle positions */ + .handle-tl { top: -6px; left: -6px; cursor: nw-resize; } + .handle-tr { top: -6px; right: -6px; cursor: ne-resize; } + .handle-bl { bottom: -6px; left: -6px; cursor: sw-resize; } + .handle-br { bottom: -6px; right: -6px; cursor: se-resize; } + /* Edges */ + .handle-t { top: -6px; left: 50%; transform: translateX(-50%); cursor: n-resize; } + .handle-b { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: s-resize; } + .handle-l { left: -6px; top: 50%; transform: translateY(-50%); cursor: w-resize; } + .handle-r { right: -6px; top: 50%; transform: translateY(-50%); cursor: e-resize; } +`; + +export function injectImageEditorStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = IMAGE_EDITOR_CSS; + document.head.appendChild(style); +} diff --git a/static/modules/referenceSlots.js b/static/modules/referenceSlots.js index 74fc6b3..9e1c7a5 100644 --- a/static/modules/referenceSlots.js +++ b/static/modules/referenceSlots.js @@ -1,4 +1,6 @@ import { dataUrlToBlob, withCacheBuster } from './utils.js'; +import { ImageEditor } from '../image_editor_modules/editor.js'; +import { injectImageEditorStyles } from '../image_editor_modules/styles.js'; export function createReferenceSlotManager(imageInputGrid, options = {}) { const MAX_IMAGE_SLOTS = 16; @@ -6,6 +8,9 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) { const onChange = options.onChange; const imageSlotState = []; let cachedReferenceImages = []; + + // Inject image editor styles once + injectImageEditorStyles(); function initialize(initialCached = []) { cachedReferenceImages = Array.isArray(initialCached) ? initialCached : []; @@ -75,6 +80,13 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) { preview.alt = 'Uploaded reference'; slot.appendChild(preview); + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'slot-edit hidden'; + editBtn.setAttribute('aria-label', 'Edit image'); + editBtn.innerHTML = ''; + slot.appendChild(editBtn); + const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'slot-remove hidden'; @@ -89,7 +101,7 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) { slot.appendChild(input); slot.addEventListener('click', event => { - if (event.target === removeBtn) return; + if (event.target === removeBtn || event.target === editBtn) return; input.click(); }); @@ -129,6 +141,11 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) { } }); + editBtn.addEventListener('click', event => { + event.stopPropagation(); + handleEditImage(index); + }); + removeBtn.addEventListener('click', event => { event.stopPropagation(); clearSlot(index); @@ -201,12 +218,14 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) { const slot = slotRecord.slot; const placeholder = slot.querySelector('.slot-placeholder'); const preview = slot.querySelector('.slot-preview'); + const editBtn = slot.querySelector('.slot-edit'); const removeBtn = slot.querySelector('.slot-remove'); if (slotRecord.data && slotRecord.data.preview) { preview.src = slotRecord.data.preview; preview.classList.remove('hidden'); placeholder.classList.add('hidden'); + editBtn.classList.remove('hidden'); removeBtn.classList.remove('hidden'); slot.classList.add('filled'); slot.classList.remove('empty'); @@ -214,6 +233,7 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) { preview.src = ''; preview.classList.add('hidden'); placeholder.classList.remove('hidden'); + editBtn.classList.add('hidden'); removeBtn.classList.add('hidden'); slot.classList.add('empty'); slot.classList.remove('filled'); @@ -230,6 +250,23 @@ export function createReferenceSlotManager(imageInputGrid, options = {}) { onChange?.(); } + function handleEditImage(index) { + const slotRecord = imageSlotState[index]; + if (!slotRecord || !slotRecord.data || !slotRecord.data.preview) return; + + const imageSrc = slotRecord.data.preview; + + new ImageEditor(imageSrc, async (blob) => { + // Convert blob to file + const fileName = slotRecord.data.file?.name || slotRecord.data.cached?.name || `edited-${index + 1}.png`; + const file = new File([blob], fileName, { type: blob.type || 'image/png' }); + + // Update the slot with the edited image + // Treat edited images as new uploads so they are sent as files (not paths) + handleSlotFile(index, file, null); + }); + } + function maybeAddSlot() { const hasEmpty = imageSlotState.some(record => !record.data); if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) { diff --git a/static/style.css b/static/style.css index 7e1e82b..e95f8f7 100644 --- a/static/style.css +++ b/static/style.css @@ -55,7 +55,7 @@ a:hover { /* Sidebar */ .sidebar { - width: 320px; + width: 450px; background: var(--panel-backdrop); background-image: radial-gradient(circle at 20% -20%, rgba(251, 191, 36, 0.15), transparent 45%); /* border-right: 1px solid var(--border-color); */ @@ -786,11 +786,38 @@ body.theme-amin { --bd-bg: linear-gradient(to right, #4A00E0, #8E2DE2); } } .slot-preview.hidden, +.slot-edit.hidden, .slot-remove.hidden, .slot-placeholder.hidden { display: none; } +.slot-edit { + position: absolute; + top: 0.35rem; + right: 2.25rem; + background: rgba(15, 23, 42, 0.85); + border: none; + border-radius: 999px; + color: var(--text-primary); + width: 1.75rem; + height: 1.75rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4); + z-index: 3; + font-size: 0.875rem; + transition: all 0.2s ease; +} + +.slot-edit:hover { + background: rgba(251, 191, 36, 0.25); + color: var(--accent-color); + transform: scale(1.05); +} + .slot-remove { position: absolute; top: 0.35rem;