/**
* FX / adjustment-popup machinery — the per-layer Brightness/Contrast,
* Hue/Saturation, Levels, and Color-Balance editor.
*
* Self-contained subsystem with three external touchpoints:
*
* - `composite()` redraw the canvas after every staged change
* - `saveState(label)` push an undo entry on Apply
* - `renderLayerPanel()` refresh the layer panel after add/edit
*
* Lifecycle:
*
* FX button on layer row → openFxPopup(layer, anchor)
* → small chooser menu (B/C, H/S, Levels, Color Balance)
* → openAdjPopup(layer, type, anchor[, existingAdj])
* → buildAdjBody renders the type-specific sliders + histogram
* → sliders / histogram handles mutate `layer._stagedAdj.params`
* → composite() previews live via the adjLayers stack
* → Apply commits to layer.adjLayers + saveState() + renderLayerPanel()
* → Cancel / Esc drops the staged state
*
* Popups can be minimised → modalManager dock chip → click chip to
* restore. Re-opening a committed sub-layer (from the layer panel's
* adj-row click) calls `editAdjLayer` which re-opens openAdjPopup
* with the existing sub-layer's params staged for editing.
*
* @param {{
* composite: () => void,
* saveState: (label?: string) => void,
* renderLayerPanel: () => void,
* }} deps
*
* @returns {{
* openFxPopup, openAdjPopup, editAdjLayer,
* closeFxPopup, closeFxMenu, closeAdjPopup,
* ensureFxDock, ensureAdjustments,
* syncFxPanelToActiveLayerIfPresent,
* minimiseAdjPopup,
* }}
*/
import { state } from '../state.js';
import modalManager from '../../modalManager.js';
import {
ADJ_ICONS,
adjLayerLabel,
adjustmentPresetOptions,
adjustmentPresetParams,
defaultAdjParams,
} from '../layer-helpers.js';
import { drawHistogram } from './histogram.js';
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
function suppressLayerGhostTap() {
window.__geSuppressLayerTapUntil = Date.now() + 650;
}
function closeFxPopup() {
if (state.fxPopupEl) {
state.fxPopupEl.remove();
state.fxPopupEl = null;
state.fxPopupLayerId = null;
}
}
function ensureAdjustments(layer) {
// Older layers (loaded from saved projects) may be missing the
// adjustments structure entirely. Pad with identity values.
if (!layer.adjustments) layer.adjustments = {};
const a = layer.adjustments;
if (a.brightness === undefined) a.brightness = 1;
if (a.contrast === undefined) a.contrast = 1;
if (a.saturation === undefined) a.saturation = 1;
if (a.hue === undefined) a.hue = 0;
if (!a.levels) a.levels = { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 };
if (!a.colorBalance) a.colorBalance = {
shadows: { r: 0, g: 0, b: 0 },
midtones: { r: 0, g: 0, b: 0 },
highlights: { r: 0, g: 0, b: 0 },
};
return a;
}
// Floating dock for minimised FX popups — lives at bottom-right.
function ensureFxDock() {
let dock = document.getElementById('ge-fx-dock');
if (!dock) {
dock = document.createElement('div');
dock.id = 'ge-fx-dock';
document.body.appendChild(dock);
}
return dock;
}
function closeFxMenu() {
if (state.fxMenuEl) {
if (state.fxMenuEl._escHandler) {
document.removeEventListener('keydown', state.fxMenuEl._escHandler, true);
}
if (state.fxMenuEl._awayHandler) {
document.removeEventListener('pointerdown', state.fxMenuEl._awayHandler, true);
}
state.fxMenuEl.remove();
state.fxMenuEl = null;
}
document.getElementById('ge-fx-menu-backdrop')?.remove();
}
function openFxPopup(layer, anchorEl) {
if (layer?.kind === 'adjustment') {
openAdjPopup(layer, layer.adjustment?.type || 'levels', anchorEl, layer.adjustment);
return;
}
// Toggle off ONLY if a menu for this layer is genuinely on-screen.
// `state` is a shared singleton that survives editor close/reopen,
// so a stale `fxMenuEl` from a previous session (whose detached
// element still carries a now-recycled `_layerId`) used to make
// this guard fire and silently swallow the first click. Verify the
// element is still in the document before treating it as "open".
if (state.fxMenuEl && document.body.contains(state.fxMenuEl) &&
state.fxMenuEl._layerId === layer.id) { closeFxMenu(); return; }
closeFxMenu();
if (!layer.adjLayers) layer.adjLayers = [];
const backdrop = document.createElement('div');
backdrop.id = 'ge-fx-menu-backdrop';
backdrop.style.cssText = 'position:fixed;inset:0;z-index:10001;background:transparent;pointer-events:auto;touch-action:none;';
document.body.appendChild(backdrop);
backdrop.addEventListener('pointerdown', (ev) => {
ev.preventDefault();
ev.stopPropagation();
closeFxMenu();
}, true);
backdrop.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
}, true);
const menu = document.createElement('div');
menu.className = 'ge-fx-menu ge-frosted';
menu._layerId = layer.id;
menu._ignoreActivationUntil = Date.now() + 350;
menu.style.zIndex = '10002';
menu.style.pointerEvents = 'auto';
const items = [
{ type: 'brightness-contrast', label: 'Brightness / Contrast' },
{ type: 'exposure', label: 'Exposure' },
{ type: 'white-balance', label: 'White Balance' },
{ type: 'hue-saturation', label: 'Hue / Saturation' },
{ type: 'vibrance', label: 'Vibrance' },
{ type: 'black-white', label: 'Black & White' },
{ type: 'shadows-highlights', label: 'Shadows / Highlights' },
{ type: 'levels', label: 'Levels' },
{ type: 'curves', label: 'Curves' },
{ type: 'color-balance', label: 'Color Balance' },
{ type: 'selective-color', label: 'Selective Color' },
{ type: 'gradient-map', label: 'Gradient Map' },
];
menu.innerHTML = items.map(i =>
``
).join('');
document.body.appendChild(menu);
state.fxMenuEl = menu;
const activateMenuItem = (btn, ev) => {
ev?.preventDefault?.();
ev?.stopPropagation?.();
if (Date.now() < (menu._ignoreActivationUntil || 0)) return;
if (!btn || btn.dataset.opening === '1') return;
btn.dataset.opening = '1';
const type = btn.dataset.fxType;
closeFxMenu();
openAdjPopup(layer, type, anchorEl);
};
menu.addEventListener('pointerdown', (ev) => {
ev.stopPropagation();
}, true);
menu.addEventListener('pointerup', (ev) => {
const btn = ev.target.closest('.ge-fx-menu-item');
if (btn) activateMenuItem(btn, ev);
else ev.stopPropagation();
}, true);
menu.addEventListener('click', (ev) => {
const btn = ev.target.closest('.ge-fx-menu-item');
if (btn) activateMenuItem(btn, ev);
else ev.stopPropagation();
}, true);
const isMobile = window.matchMedia('(max-width: 820px)').matches;
const r = isMobile ? null : anchorEl?.getBoundingClientRect?.();
if (isMobile) {
menu.style.left = '';
menu.style.top = '';
menu.style.right = '';
menu.style.bottom = '';
} else if (r) {
const menuW = 220;
const menuH = menu.offsetHeight || 200;
const rightX = r.right + 4;
const leftX = r.left - menuW - 4;
const fitsRight = rightX + menuW <= window.innerWidth - 8;
let left = fitsRight ? rightX : Math.max(8, leftX);
left = Math.min(window.innerWidth - menuW - 8, Math.max(8, left));
menu.style.left = left + 'px';
let top = r.top;
if (top + menuH > window.innerHeight - 8) top = r.bottom - menuH;
top = Math.min(window.innerHeight - menuH - 8, Math.max(8, top));
menu.style.top = top + 'px';
}
menu.querySelectorAll('.ge-fx-menu-item').forEach(btn => {
const activate = (ev) => {
activateMenuItem(btn, ev);
};
btn.addEventListener('pointerup', activate);
btn.addEventListener('click', activate);
});
// Esc closes the menu, capture-phase + stopPropagation so the
// gallery modal's own Esc handler doesn't fire too.
const onKey = (ev) => {
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
closeFxMenu();
document.removeEventListener('keydown', onKey, true);
}
};
document.addEventListener('keydown', onKey, true);
menu._escHandler = onKey;
}
// Hide an adj popup and drop a chip into the FX dock. Click the chip
// to restore the popup in its previous position with staged state
// intact (we do NOT clear staged on minimise).
function minimiseAdjPopup(pop) {
if (!pop) return;
const type = pop._type;
const r = pop.getBoundingClientRect();
pop._stashLeft = r.left;
pop._stashTop = r.top;
pop.style.display = 'none';
if (state.adjPopupEl === pop) state.adjPopupEl = null;
const popupId = pop._modalId || `ge-fx-popup-${Math.random().toString(36).slice(2, 8)}`;
pop._modalId = popupId;
modalManager.register(popupId, {
label: adjLayerLabel(type),
icon: ADJ_ICONS[type] || '',
restoreFn: () => {
pop.style.left = pop._stashLeft + 'px';
pop.style.top = pop._stashTop + 'px';
pop.style.display = '';
if (state.adjPopupEl && state.adjPopupEl !== pop) {
const other = state.adjPopupEl;
state.adjPopupEl = other;
closeAdjPopup();
}
state.adjPopupEl = pop;
},
closeFn: () => {
state.adjPopupEl = pop;
closeAdjPopup();
modalManager.unregister(popupId);
},
});
modalManager.minimize(popupId);
}
// Re-open an existing committed adjustment sub-layer for editing.
// Pre-loads its params as the staged state; Apply updates in place.
function editAdjLayer(layer, adj, anchorEl) {
openAdjPopup(layer, adj.type, anchorEl, adj);
}
function closeAdjPopup() {
if (state.adjPopupEl) {
suppressLayerGhostTap();
const layer = state.adjPopupEl._layer;
if (layer) {
if (state.adjPreviewLayer === layer) state.adjPreviewLayer = null;
layer._adjPreview = false;
layer._adjCompare = false;
if (layer._stagedAdj) layer._stagedAdj = null;
if (layer._newAdjustmentLayer) {
const index = state.layers.findIndex(item => item.id === layer.id);
if (index >= 0) state.layers.splice(index, 1);
state.layerOffsets.delete(layer.id);
if (Number.isInteger(layer._newAdjustmentUndoLength) && state.undoStack.length > layer._newAdjustmentUndoLength) {
state.undoStack.length = layer._newAdjustmentUndoLength;
}
state.activeLayerId = state.layers[state.layers.length - 1]?.id || null;
layer._newAdjustmentLayer = false;
renderLayerPanel();
}
if (layer._editingAdjId) layer._editingAdjId = null;
layer._adjFinalKey = null;
composite();
}
if (state.adjPopupEl._escHandler) {
document.removeEventListener('keydown', state.adjPopupEl._escHandler, true);
}
if (state.adjPopupEl._modalId) {
try { modalManager.unregister(state.adjPopupEl._modalId); } catch {}
}
state.adjPopupEl.remove();
state.adjPopupEl = null;
}
}
function openAdjPopup(layer, type, anchorEl, existingAdj) {
closeAdjPopup();
// Editing an existing sub-layer? Pre-load its params as the staged
// preview and mark the popup so Apply updates instead of appending.
const firstClass = layer?.kind === 'adjustment';
const editing = !!existingAdj;
const presetOptions = adjustmentPresetOptions(type);
const startParams = editing
? JSON.parse(JSON.stringify(existingAdj.params))
: defaultAdjParams(type);
layer._stagedAdj = { type, params: startParams };
layer._adjPreview = true;
state.adjPreviewLayer = layer;
if (editing) {
// Hide the existing sub-layer from the render stack so the
// staged preview shows correctly without doubling the effect.
layer._editingAdjId = existingAdj.id;
layer._adjFinalKey = null;
}
const pop = document.createElement('div');
pop.className = 'ge-adj-popup ge-frosted';
pop.style.zIndex = '10003';
pop._layer = layer;
pop._type = type;
pop._anchorEl = anchorEl;
pop._existingAdj = existingAdj || null;
pop.innerHTML = `
${ADJ_ICONS[type] || ''}
${adjLayerLabel(type)}
−
Preset
Custom
${presetOptions.map(name => `${name} `).join('')}
`;
document.body.appendChild(pop);
state.adjPopupEl = pop;
const r = anchorEl?.getBoundingClientRect?.();
const pw = type === 'color-balance' ? 340 : 320;
// Prefer right of anchor; fall back to left if no room.
let left;
if (r) {
const rightX = r.right + 8;
const leftX = r.left - pw - 8;
const fitsRight = rightX + pw <= window.innerWidth - 8;
left = fitsRight ? rightX : Math.max(8, leftX);
} else {
left = (window.innerWidth - pw) / 2;
}
const top = r ? Math.max(8, r.top - 20) : 60;
pop.style.left = left + 'px';
pop.style.top = top + 'px';
const body = pop.querySelector('[data-adj-body]');
buildAdjBody(layer, type, body, pop);
body.addEventListener('input', () => {
const presetSelect = pop.querySelector('.ge-adj-preset-select');
if (presetSelect) presetSelect.value = 'custom';
});
const popupRect = pop.getBoundingClientRect();
if (!window.matchMedia('(max-width: 820px)').matches) {
const clampedTop = Math.max(8, Math.min(Number.parseFloat(pop.style.top) || 8, window.innerHeight - popupRect.height - 8));
pop.style.top = `${clampedTop}px`;
}
pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);
pop.querySelector('.ge-adj-min')?.addEventListener('click', () => minimiseAdjPopup(pop));
// Drag by head — anywhere except buttons. Mobile pins via !important
// rules; setProperty with 'important' lets inline styles win during drag.
const head = pop.querySelector('[data-adj-drag]');
if (head) {
const isMobile = window.matchMedia('(max-width: 820px)').matches;
const setPos = (x, y) => {
if (isMobile) {
pop.style.setProperty('left', x + 'px', 'important');
pop.style.setProperty('top', y + 'px', 'important');
pop.style.setProperty('right', 'auto', 'important');
pop.style.setProperty('bottom', 'auto', 'important');
pop.style.setProperty('width', 'auto', 'important');
pop.style.setProperty('max-width', 'calc(100vw - 16px)', 'important');
} else {
pop.style.left = x + 'px';
pop.style.top = y + 'px';
}
};
head.style.touchAction = 'none';
head.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
e.preventDefault();
const startX = e.clientX, startY = e.clientY;
const r0 = pop.getBoundingClientRect();
head.setPointerCapture(e.pointerId);
head.style.cursor = 'grabbing';
const onMove = (ev) => {
const nx = Math.max(0, Math.min(window.innerWidth - 60, r0.left + (ev.clientX - startX)));
const ny = Math.max(0, Math.min(window.innerHeight - 30, r0.top + (ev.clientY - startY)));
setPos(nx, ny);
};
const onUp = () => {
head.releasePointerCapture(e.pointerId);
head.style.cursor = '';
head.removeEventListener('pointermove', onMove);
head.removeEventListener('pointerup', onUp);
};
head.addEventListener('pointermove', onMove);
head.addEventListener('pointerup', onUp);
});
}
// Esc closes; capture-phase + stopPropagation so the gallery modal's
// own Esc handler doesn't fire too.
const onKey = (ev) => {
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
closeAdjPopup();
document.removeEventListener('keydown', onKey, true);
}
};
document.addEventListener('keydown', onKey, true);
pop._escHandler = onKey;
pop.querySelector('[data-adj-action="cancel"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
closeAdjPopup();
});
pop.querySelector('[data-adj-action="reset"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
layer._stagedAdj.params = defaultAdjParams(type);
const presetSelect = pop.querySelector('.ge-adj-preset-select');
if (presetSelect) presetSelect.value = 'Default';
body.innerHTML = '';
buildAdjBody(layer, type, body, pop);
scheduleAdjRefresh(layer);
});
pop.querySelector('.ge-adj-preset-select')?.addEventListener('change', (e) => {
e.preventDefault();
e.stopPropagation();
const preset = e.currentTarget.value;
layer._stagedAdj.params = adjustmentPresetParams(type, preset);
layer._adjFinalKey = null;
body.innerHTML = '';
buildAdjBody(layer, type, body, pop);
scheduleAdjRefresh(layer);
});
pop.querySelector('[data-adj-action="compare"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
layer._adjCompare = !layer._adjCompare;
e.currentTarget.setAttribute('aria-pressed', layer._adjCompare ? 'true' : 'false');
e.currentTarget.classList.toggle('is-active', layer._adjCompare);
layer._adjFinalKey = null;
composite();
});
pop.querySelector('[data-adj-action="ok"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
suppressLayerGhostTap();
if (!layer._newAdjustmentLayer) {
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
}
const params = layer._stagedAdj.params;
layer._stagedAdj = null;
layer._adjPreview = false;
layer._adjCompare = false;
if (firstClass) {
layer.adjustment = { type, params };
layer.name = layer.name || adjLayerLabel(type);
layer._newAdjustmentLayer = false;
layer._newAdjustmentUndoLength = null;
} else if (editing) {
const existing = (layer.adjLayers || []).find(a => a.id === existingAdj.id);
if (existing) existing.params = params;
layer._editingAdjId = null;
} else {
if (!layer.adjLayers) layer.adjLayers = [];
layer.adjLayers.push({
id: 'adj-' + Math.random().toString(36).slice(2, 9),
type,
name: adjLayerLabel(type),
visible: true,
opacity: 1,
params,
});
}
layer._adjFinalKey = null;
composite();
renderLayerPanel();
closeAdjPopup();
});
}
// rAF-throttled live preview while sliders are dragged.
function scheduleAdjRefresh(layer) {
if (state.adjRafPending) return;
state.adjRafPending = true;
requestAnimationFrame(() => {
state.adjRafPending = false;
if (state.adjPreviewLayer !== layer || !layer._stagedAdj) return;
layer._adjFinalKey = null;
composite();
});
}
function buildAdjBody(layer, type, body, popEl) {
const p = layer._stagedAdj.params;
const levelValues = () => {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
if (channel === 'rgb') return p;
if (!p.channels) p.channels = {};
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
return p.channels[channel];
};
const revertIcon = ' ';
const sliderRow = (key, label, min, max, value, suffix) => `
${label}
${value}${suffix || ''}
${revertIcon}
`;
if (type === 'brightness-contrast') {
const bSlider = Math.round((p.brightness - 1) * 100);
const cSlider = Math.round((p.contrast - 1) * 100);
body.innerHTML = `
${sliderRow('brightness', 'Brightness', -100, 100, bSlider, '')}
${sliderRow('contrast', 'Contrast', -100, 100, cSlider, '')}
`;
} else if (type === 'exposure') {
body.innerHTML = `
${sliderRow('exposure', 'Exposure', -500, 500, Math.round(p.exposure * 100), ' EV')}
${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')}
${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')}
`;
} else if (type === 'white-balance') {
body.innerHTML = `
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')}
${sliderRow('tint', 'Tint', -100, 100, Math.round(p.tint), '')}
`;
} else if (type === 'hue-saturation') {
const hSlider = Math.round(p.hue);
const sSlider = Math.round((p.saturation - 1) * 100);
body.innerHTML = `
${sliderRow('hue', 'Hue', -180, 180, hSlider, ' °')}
${sliderRow('saturation', 'Saturation', -100, 100, sSlider, '')}
${sliderRow('lightness', 'Lightness', -100, 100, Math.round(p.lightness || 0), '')}
`;
} else if (type === 'vibrance') {
body.innerHTML = `
${sliderRow('vibrance', 'Vibrance', -100, 100, Math.round(p.vibrance || 0), '')}
Boosts muted colors more than already-saturated colors.
`;
} else if (type === 'black-white') {
body.innerHTML = `
${sliderRow('red', 'Red', 0, 100, Math.round(p.red ?? 30), '%')}
${sliderRow('green', 'Green', 0, 100, Math.round(p.green ?? 59), '%')}
${sliderRow('blue', 'Blue', 0, 100, Math.round(p.blue ?? 11), '%')}
${sliderRow('constant', 'Constant', -100, 100, Math.round(p.constant || 0), '')}
Mixes the source channels into a neutral grayscale result.
`;
} else if (type === 'shadows-highlights') {
body.innerHTML = `
${sliderRow('shadows', 'Shadows', -100, 100, Math.round(p.shadows || 0), '')}
${sliderRow('highlights', 'Highlights', -100, 100, Math.round(p.highlights || 0), '')}
Recover detail in dark and bright tones without shifting midtones.
`;
} else if (type === 'levels') {
const values = levelValues();
// Histogram canvas + sliders. Histogram is computed from the
// layer's pixel data (after any adjLayers below this one) so
// the user is matching levels against what they're really seeing.
// wrapper is collapsed by default on mobile to save
// vertical space; open by default on desktop.
const isMobile = window.matchMedia('(max-width: 820px)').matches;
body.innerHTML = `
Channel
RGB
Red
Green
Blue
Histogram
${sliderRow('inBlack', 'Input black', 0, 254, values.inBlack, '')}
${sliderRow('inWhite', 'Input white', 1, 255, values.inWhite, '')}
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((values.gamma || 1) * 100), 'γ')}
${sliderRow('outBlack', 'Output black', 0, 255, values.outBlack, '')}
${sliderRow('outWhite', 'Output white', 0, 255, values.outWhite, '')}
`;
const hist = body.querySelector('.ge-adj-histogram');
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
: layer;
const clipStatus = body.querySelector('.ge-adj-clip-status');
const renderHistogram = () => {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
const stats = drawHistogram(hist, histogramLayer, channel);
if (clipStatus) {
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
}
};
renderHistogram();
wireHistogramHandles(body, layer, type);
// Redraw histogram when the user opens the disclosure (canvas
// dimensions are layout-dependent).
body.querySelector('.ge-adj-hist-details')?.addEventListener('toggle', (e) => {
if (e.target.open) renderHistogram();
});
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
p.channel = event.target.value;
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'curves') {
if (!p.points) p.points = defaultAdjParams('curves').points;
if (!p.channel) p.channel = 'rgb';
body.innerHTML = `
Channel
RGB
Red
Green
Blue
Click the curve to add a point. Drag to adjust. Double-click a point to remove it.
`;
const canvas = body.querySelector('.ge-curves-canvas');
wireCurveEditor(canvas, layer);
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
p.channel = event.target.value;
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'color-balance') {
// Color-tinted slider ends so the user sees what direction does what.
const cbRow = (key, leftCol, rightCol, label, value) => `
${value}
${revertIcon}
`;
// Tone picker: one tone group visible at a time. Remember the
// last picked tone on the popup so re-renders (revert button
// etc.) keep it.
const tone = popEl._cbTone || 'shadows';
popEl._cbTone = tone;
const toneSliders = (t) => `
${cbRow(`${t}-r`, '#00d2d2', '#ff5555', 'Cyan ↔ Red', p[t].r)}
${cbRow(`${t}-g`, '#d855d8', '#55d855', 'Magenta ↔ Green', p[t].g)}
${cbRow(`${t}-b`, '#e6e64a', '#4a78ff', 'Yellow ↔ Blue', p[t].b)}
`;
body.innerHTML = `
Shadows
Midtones
Highlights
${toneSliders(tone)}
`;
body.querySelector('.ge-adj-cb-tone-select')?.addEventListener('change', (e) => {
popEl._cbTone = e.target.value;
body.innerHTML = '';
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'selective-color') {
if (!p.range) p.range = 'reds';
if (!p.ranges?.[p.range]) p.ranges = defaultAdjParams(type).ranges;
const values = p.ranges[p.range];
const ranges = ['reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'neutrals', 'blacks'];
body.innerHTML = `
Colors
${ranges.map(name => `${name[0].toUpperCase() + name.slice(1)} `).join('')}
${sliderRow('cyan', 'Cyan', -100, 100, values.cyan, '')}
${sliderRow('magenta', 'Magenta', -100, 100, values.magenta, '')}
${sliderRow('yellow', 'Yellow', -100, 100, values.yellow, '')}
${sliderRow('black', 'Black', -100, 100, values.black, '')}
`;
body.querySelector('.ge-adj-selective-range')?.addEventListener('change', event => {
p.range = event.target.value;
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'gradient-map') {
body.innerHTML = `
Shadows
Highlights
${sliderRow('midpoint', 'Midpoint', 1, 99, Math.round(p.midpoint), '%')}
Reverse
`;
body.querySelectorAll('[data-gradient-key]').forEach(input => {
input.addEventListener('input', () => {
p[input.dataset.gradientKey] = input.value;
const preview = body.querySelector('.ge-gradient-map-preview');
preview?.style.setProperty('--ge-gradient-start', p.shadows);
preview?.style.setProperty('--ge-gradient-end', p.highlights);
scheduleAdjRefresh(layer);
});
});
body.querySelector('[data-gradient-reverse]')?.addEventListener('change', event => {
p.reverse = event.target.checked;
scheduleAdjRefresh(layer);
});
}
// Wire all sliders.
body.querySelectorAll('input[type="range"]').forEach(sl => {
sl.addEventListener('input', () => onAdjSliderInput(layer, type, sl));
});
// Per-slider revert buttons.
body.querySelectorAll('.ge-adj-revert').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const key = btn.dataset.revertKey;
revertAdjKey(layer, type, key);
// Rebuild body so values + histogram refresh.
body.innerHTML = '';
buildAdjBody(layer, type, body, popEl);
});
});
}
// Reset a single slider key back to identity. Updates staged params
// and triggers a composite refresh.
function revertAdjKey(layer, type, key) {
const defaults = defaultAdjParams(type);
const p = layer._stagedAdj.params;
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map'].includes(type)) {
p[key] = defaults[key];
} else if (type === 'levels') {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
const values = channel === 'rgb' ? p : p.channels[channel];
const defaultValues = channel === 'rgb' ? defaults : defaults.channels[channel];
values[key] = defaultValues[key];
} else if (type === 'color-balance') {
const [tone, ch] = key.split('-');
p[tone][ch] = defaults[tone][ch];
} else if (type === 'selective-color') {
p.ranges[p.range][key] = defaults.ranges[p.range][key];
}
layer._adjFinalKey = null;
composite();
}
function onAdjSliderInput(layer, type, sl) {
const key = sl.dataset.key;
const raw = parseInt(sl.value, 10);
const valEl = sl.parentElement.querySelector('.ge-adj-value');
const p = layer._stagedAdj.params;
let display = String(raw);
if (type === 'brightness-contrast' || type === 'hue-saturation') {
if (key === 'brightness' || key === 'contrast' || key === 'saturation') {
p[key] = 1 + raw / 100;
} else if (key === 'hue') {
p.hue = raw; display = raw + ' °';
} else if (key === 'lightness') {
p.lightness = raw;
}
} else if (type === 'exposure') {
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
else if (key === 'offset') { p.offset = raw / 100; display = (raw / 100).toFixed(2); }
else if (key === 'gamma') { p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ'; }
} else if (type === 'white-balance') {
p[key] = raw;
} else if (type === 'vibrance') {
p[key] = raw;
} else if (type === 'black-white') {
p[key] = raw;
} else if (type === 'shadows-highlights') {
p[key] = raw;
} else if (type === 'levels') {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
const values = channel === 'rgb' ? p : p.channels[channel];
if (key === 'gamma') {
values.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
} else {
values[key] = raw;
}
} else if (type === 'color-balance') {
const [tone, ch] = key.split('-');
p[tone][ch] = raw;
} else if (type === 'selective-color') {
p.ranges[p.range][key] = raw;
} else if (type === 'gradient-map') {
p.midpoint = raw;
display = raw + '%';
}
if (valEl) valEl.textContent = display;
scheduleAdjRefresh(layer);
}
function wireCurveEditor(canvas, layer) {
if (!canvas) return;
const params = layer._stagedAdj.params;
const channelColor = { rgb: '#f1f5f9', red: '#ff6b78', green: '#57d68d', blue: '#69a7ff' };
const points = () => params.points[params.channel] || params.points.rgb;
const canvasPoint = event => {
const rect = canvas.getBoundingClientRect();
return {
x: Math.max(0, Math.min(255, ((event.clientX - rect.left) / rect.width) * 255)),
y: Math.max(0, Math.min(255, (1 - (event.clientY - rect.top) / rect.height) * 255)),
};
};
const draw = () => {
const source = layer.kind === 'adjustment' && getAdjustmentSource
? getAdjustmentSource(layer)
: layer.canvas;
drawHistogram(canvas, { canvas: source });
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
ctx.strokeStyle = 'rgba(255,255,255,.12)';
ctx.lineWidth = 1;
for (let index = 1; index < 4; index += 1) {
const x = Math.round(width * index / 4) + .5;
const y = Math.round(height * index / 4) + .5;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke();
}
const list = points();
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
ctx.lineWidth = 2;
ctx.beginPath();
list.forEach((point, index) => {
const x = point[0] / 255 * width;
const y = height - point[1] / 255 * height;
if (index) ctx.lineTo(x, y); else ctx.moveTo(x, y);
});
ctx.stroke();
for (const point of list) {
const x = point[0] / 255 * width;
const y = height - point[1] / 255 * height;
ctx.fillStyle = '#0b0d10';
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
};
const nearestIndex = event => {
const point = canvasPoint(event);
let nearest = -1;
let distance = 14;
points().forEach((candidate, index) => {
const next = Math.hypot(candidate[0] - point.x, candidate[1] - point.y);
if (next < distance) { distance = next; nearest = index; }
});
return nearest;
};
canvas.addEventListener('pointerdown', event => {
event.preventDefault();
const list = points();
let index = nearestIndex(event);
if (index < 0) {
const point = canvasPoint(event);
list.push([Math.round(point.x), Math.round(point.y)]);
list.sort((a, b) => a[0] - b[0]);
index = list.findIndex(item => item[0] === Math.round(point.x) && item[1] === Math.round(point.y));
}
canvas.setPointerCapture(event.pointerId);
const move = moveEvent => {
const next = canvasPoint(moveEvent);
const current = list[index];
const endpoint = index === 0 || index === list.length - 1;
current[0] = endpoint
? (index === 0 ? 0 : 255)
: Math.round(Math.max(list[index - 1][0] + 1, Math.min(list[index + 1][0] - 1, next.x)));
current[1] = Math.round(next.y);
draw();
scheduleAdjRefresh(layer);
};
const up = () => {
canvas.releasePointerCapture(event.pointerId);
canvas.removeEventListener('pointermove', move);
canvas.removeEventListener('pointerup', up);
};
canvas.addEventListener('pointermove', move);
canvas.addEventListener('pointerup', up);
draw();
scheduleAdjRefresh(layer);
});
canvas.addEventListener('dblclick', event => {
const index = nearestIndex(event);
const list = points();
if (index <= 0 || index >= list.length - 1) return;
list.splice(index, 1);
draw();
scheduleAdjRefresh(layer);
});
draw();
}
// Position the three histogram triangle handles by current staged
// values + wire pointer drags.
function wireHistogramHandles(bodyEl, layer, type) {
const wrap = bodyEl.querySelector('.ge-adj-hist-wrap');
const canvas = bodyEl.querySelector('.ge-adj-histogram');
if (!wrap || !canvas) return;
const handles = bodyEl.querySelectorAll('.ge-adj-hist-handle');
const levelParams = () => {
const root = layer._stagedAdj.params;
const channel = ['red', 'green', 'blue'].includes(root.channel) ? root.channel : 'rgb';
return channel === 'rgb' ? root : root.channels[channel];
};
const placeHandles = () => {
const w = canvas.getBoundingClientRect().width;
const p = levelParams();
const xB = (p.inBlack / 255) * w;
const xW = (p.inWhite / 255) * w;
// Gamma handle sits at a fraction of the (xB..xW) span, mapped
// from gamma's log scale (1 = midpoint, 0.1 = far right, 10 = far left).
const gammaT = 1 - (Math.log(p.gamma || 1) / Math.log(10) * 0.5 + 0.5);
const xG = xB + (xW - xB) * gammaT;
const set = (sel, x) => {
const el = bodyEl.querySelector(sel);
if (el) el.style.left = (x - 6) + 'px';
};
set('.hist-h-black', xB);
set('.hist-h-gamma', xG);
set('.hist-h-white', xW);
};
placeHandles();
handles.forEach(h => {
h.addEventListener('pointerdown', (e) => {
e.preventDefault();
e.stopPropagation();
h.setPointerCapture(e.pointerId);
const which = h.dataset.handle;
const rect = canvas.getBoundingClientRect();
const onMove = (ev) => {
const x = Math.max(0, Math.min(rect.width, ev.clientX - rect.left));
const v = Math.round((x / rect.width) * 255);
const p = levelParams();
if (which === 'inBlack') {
p.inBlack = Math.min(p.inWhite - 1, v);
} else if (which === 'inWhite') {
p.inWhite = Math.max(p.inBlack + 1, v);
} else if (which === 'gamma') {
const xB = (p.inBlack / 255) * rect.width;
const xW = (p.inWhite / 255) * rect.width;
const span = Math.max(1, xW - xB);
let t = (x - xB) / span;
t = Math.max(0.01, Math.min(0.99, t));
// Invert the placeHandles mapping: t = 1 - (log10(g)*0.5+0.5).
const log10g = -((t - 0.5) * 2);
p.gamma = Math.pow(10, log10g);
}
placeHandles();
// Update visible slider rows + value labels.
const updateRow = (key, displayVal) => {
const sl = bodyEl.querySelector(`input[type="range"][data-key="${key}"]`);
const values = levelParams();
if (sl) sl.value = String(key === 'gamma' ? Math.round(values.gamma * 100) : values[key]);
const val = sl?.parentElement.querySelector('.ge-adj-value');
if (val) val.textContent = displayVal;
};
const values = levelParams();
if (which === 'inBlack') updateRow('inBlack', String(values.inBlack));
if (which === 'inWhite') updateRow('inWhite', String(values.inWhite));
if (which === 'gamma') updateRow('gamma', values.gamma.toFixed(2) + 'γ');
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
: layer;
const channel = ['red', 'green', 'blue'].includes(layer._stagedAdj.params.channel)
? layer._stagedAdj.params.channel : 'rgb';
const stats = drawHistogram(canvas, histogramLayer, channel);
const clipStatus = bodyEl.querySelector('.ge-adj-clip-status');
if (clipStatus) {
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
}
scheduleAdjRefresh(layer);
};
const onUp = () => {
h.releasePointerCapture(e.pointerId);
h.removeEventListener('pointermove', onMove);
h.removeEventListener('pointerup', onUp);
};
h.addEventListener('pointermove', onMove);
h.addEventListener('pointerup', onUp);
});
});
}
// Legacy sidebar-FX panel sync — FX now lives in a per-layer popup;
// stubbed so any stale callers don't error.
function syncFxPanelToActiveLayerIfPresent() { /* no-op */ }
return {
openFxPopup, openAdjPopup, editAdjLayer,
closeFxPopup, closeFxMenu, closeAdjPopup,
ensureFxDock, ensureAdjustments,
syncFxPanelToActiveLayerIfPresent,
minimiseAdjPopup,
};
}