/** * 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'; import { parseCubeLut, generateCubeLut } from './cube-lut.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' }, { type: 'invert', label: 'Invert' }, { type: 'posterize', label: 'Posterize' }, { type: 'threshold', label: 'Threshold' }, { type: 'photo-filter', label: 'Photo Filter' }, { type: 'channel-mixer', label: 'Channel Mixer' }, { type: 'color-lookup', label: 'Color Lookup' }, ]; 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 = `
Camera-Raw-style Basic panel: white balance, exposure and tone, then clarity, vibrance and saturation.
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. //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) => `Inverts every color channel in the source layer.
`; } else if (type === 'posterize') { body.innerHTML = ` ${sliderRow('levels', 'Levels', 2, 255, Math.round(p.levels ?? 4), '')}Reduces each channel to a limited number of tonal steps.
`; } else if (type === 'threshold') { body.innerHTML = ` ${sliderRow('level', 'Threshold', 0, 255, Math.round(p.level ?? 128), '')}Pixels brighter than the threshold become white, the rest black.
`; } else if (type === 'photo-filter') { body.innerHTML = `Mixes source channels into the selected output channel.
`; body.querySelector('.ge-adj-cm-channel-select')?.addEventListener('change', event => { popEl._cmMixChannel = event.target.value; body.innerHTML = ''; buildAdjBody(layer, type, body, popEl); }); body.querySelector('[data-cm-monochrome]')?.addEventListener('change', event => { p.monochrome = event.target.checked; scheduleAdjRefresh(layer); }); } else if (type === 'color-lookup') { const presets = ['none', 'teal_orange', 'cold', 'warm', 'high_contrast', 'sepia']; const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' }; const strength = p.strength == null ? 100 : p.strength; const hasCube = !!(p.cube && p.cube.size && p.cube.data); const lutName = hasCube ? `Loaded LUT · ${p.cube.size}³` : 'No .cube loaded'; body.innerHTML = `Import an Adobe .cube 3D LUT (or pick a built-in look). Strength blends the LUT with the original pixels.
`; body.querySelector('.ge-adj-lut-select')?.addEventListener('change', event => { p.preset = event.target.value; scheduleAdjRefresh(layer); }); const fileInput = body.querySelector('.ge-adj-lut-load input[type="file"]'); fileInput?.addEventListener('change', async () => { const file = fileInput.files && fileInput.files[0]; if (!file) return; try { const text = await file.text(); const lut = parseCubeLut(text); p.cube = { size: lut.size, domainMin: Array.from(lut.domainMin), domainMax: Array.from(lut.domainMax), data: Array.from(lut.data), }; if (p.strength == null) p.strength = 100; const nameEl = body.querySelector('.ge-adj-lut-name'); if (nameEl) nameEl.textContent = `Loaded ${file.name} · ${lut.size}³`; const exp = body.querySelector('.ge-adj-lut-export'); if (exp) exp.disabled = false; scheduleAdjRefresh(layer); } catch (err) { const nameEl = body.querySelector('.ge-adj-lut-name'); if (nameEl) nameEl.textContent = 'Could not read .cube: ' + (err?.message || err); } }); body.querySelector('.ge-adj-lut-export')?.addEventListener('click', () => { const cube = p.cube && p.cube.size ? p.cube : null; if (!cube) return; const text = generateCubeLut({ size: cube.size, domainMin: cube.domainMin || [0, 0, 0], domainMax: cube.domainMax || [1, 1, 1], data: cube.data, }); const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'look.cube'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }); } // 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', 'posterize', 'threshold', 'photo-filter', 'color-lookup'].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 === 'channel-mixer') { const [ch, comp] = key.split('-'); if (comp === 'c') p.constant[ch] = defaults.constant[ch]; else p[ch][comp] = defaults[ch][comp]; } 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; const row = sl.closest('.ge-adj-row'); if (row && row.hasAttribute('data-default')) { row.classList.toggle('is-modified', Number(sl.value) !== Number(row.getAttribute('data-default'))); } 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 === 'develop') { if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; } else { p[key] = raw; } } else if (type === 'color-lookup') { p.strength = raw; display = raw + '%'; } else if (type === 'white-balance') { 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 + '%'; } else if (type === 'posterize') { p.levels = raw; } else if (type === 'threshold') { p.level = raw; } else if (type === 'photo-filter') { p.density = raw; display = raw + '%'; } else if (type === 'channel-mixer') { const [ch, comp] = key.split('-'); if (comp === 'c') p.constant[ch] = raw; else p[ch][comp] = raw; display = raw + (comp === 'c' ? '' : '%'); } 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, }; }