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 = `
+
+
+
+
+
+
+
+
+
+
+
+ 100%
+
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(this.overlay);
+ this.bindEvents();
+ }
+
+ renderSlider(label, id, min, max, val) {
+ return `
+
+
+
${label}
+
+ ${val}
+
+
+
+
+
+
+
+ `;
+ }
+
+ renderCurvePanel() {
+ return `
+
+ `;
+ }
+
+ 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 = `
+
+
+
+
+
+
+
+
+
+
+
+ 100%
+
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(this.overlay);
+ this.bindEvents();
+ }
+
+ renderSlider(label, id, min, max, val) {
+ return `
+
+
+
${label}
+
+ ${val}
+
+
+
+
+
+
+
+ `;
+ }
+
+ renderCurvePanel() {
+ return `
+
+ `;
+ }
+
+ 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;