mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 09:45:24 -04:00
Odysseus v1.0
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
/**
|
||||
* 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,
|
||||
defaultAdjParams,
|
||||
} from '../layer-helpers.js';
|
||||
import { drawHistogram } from './histogram.js';
|
||||
|
||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel }) {
|
||||
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) {
|
||||
// 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: 'hue-saturation', label: 'Hue / Saturation' },
|
||||
{ type: 'levels', label: 'Levels' },
|
||||
{ type: 'color-balance', label: 'Color Balance' },
|
||||
];
|
||||
menu.innerHTML = items.map(i =>
|
||||
`<button class="ge-fx-menu-item" data-fx-type="${i.type}"><span class="ge-fx-menu-icon">${ADJ_ICONS[i.type] || ''}</span><span>${i.label}</span></button>`
|
||||
).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 (layer._stagedAdj) layer._stagedAdj = null;
|
||||
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 editing = !!existingAdj;
|
||||
const startParams = editing
|
||||
? JSON.parse(JSON.stringify(existingAdj.params))
|
||||
: defaultAdjParams(type);
|
||||
layer._stagedAdj = { type, params: startParams };
|
||||
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 = `
|
||||
<div class="ge-adj-head" data-adj-drag>
|
||||
<span class="ge-adj-icon">${ADJ_ICONS[type] || ''}</span>
|
||||
<span class="ge-adj-title">${adjLayerLabel(type)}</span>
|
||||
<span class="ge-head-btns">
|
||||
<button class="ge-adj-min" type="button" title="Minimise">−</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-adj-body" data-adj-body></div>
|
||||
<div class="ge-adj-foot">
|
||||
<button class="ge-btn ge-btn-sm ge-adj-cancel-btn" data-adj-action="cancel">Cancel</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-primary ge-adj-apply-btn" data-adj-action="ok">Apply</button>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
|
||||
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="ok"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressLayerGhostTap();
|
||||
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
|
||||
const params = layer._stagedAdj.params;
|
||||
layer._stagedAdj = null;
|
||||
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;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
});
|
||||
}
|
||||
|
||||
function buildAdjBody(layer, type, body, popEl) {
|
||||
const p = layer._stagedAdj.params;
|
||||
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
||||
const sliderRow = (key, label, min, max, value, suffix) => `
|
||||
<div class="ge-adj-row" data-adj-key="${key}">
|
||||
<label>${label}</label>
|
||||
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
|
||||
<span class="ge-adj-value">${value}${suffix || ''}</span>
|
||||
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
|
||||
</div>
|
||||
`;
|
||||
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 === '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, '')}
|
||||
`;
|
||||
} else if (type === 'levels') {
|
||||
// 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.
|
||||
// <details> 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 = `
|
||||
<details class="ge-adj-hist-details"${isMobile ? '' : ' open'}>
|
||||
<summary>Histogram</summary>
|
||||
<div class="ge-adj-hist-wrap">
|
||||
<canvas class="ge-adj-histogram" width="280" height="80"></canvas>
|
||||
<div class="ge-adj-hist-handles">
|
||||
<div class="ge-adj-hist-handle hist-h-black" data-handle="inBlack" title="Input black — drag"></div>
|
||||
<div class="ge-adj-hist-handle hist-h-gamma" data-handle="gamma" title="Gamma — drag"></div>
|
||||
<div class="ge-adj-hist-handle hist-h-white" data-handle="inWhite" title="Input white — drag"></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
${sliderRow('inBlack', 'Input black', 0, 254, p.inBlack, '')}
|
||||
${sliderRow('inWhite', 'Input white', 1, 255, p.inWhite, '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((p.gamma || 1) * 100), 'γ')}
|
||||
${sliderRow('outBlack', 'Output black', 0, 255, p.outBlack, '')}
|
||||
${sliderRow('outWhite', 'Output white', 0, 255, p.outWhite, '')}
|
||||
`;
|
||||
const hist = body.querySelector('.ge-adj-histogram');
|
||||
drawHistogram(hist, layer);
|
||||
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) drawHistogram(hist, layer);
|
||||
});
|
||||
} else if (type === 'color-balance') {
|
||||
// Color-tinted slider ends so the user sees what direction does what.
|
||||
const cbRow = (key, leftCol, rightCol, label, value) => `
|
||||
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
|
||||
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
|
||||
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
|
||||
<span class="ge-adj-cb-dot" style="background:${rightCol}"></span>
|
||||
<span class="ge-adj-value">${value}</span>
|
||||
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
|
||||
</div>
|
||||
`;
|
||||
// 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 = `
|
||||
<div class="ge-adj-cb-tone-picker">
|
||||
<select class="ge-adj-cb-tone-select">
|
||||
<option value="shadows"${tone === 'shadows' ? ' selected' : ''}>Shadows</option>
|
||||
<option value="midtones"${tone === 'midtones' ? ' selected' : ''}>Midtones</option>
|
||||
<option value="highlights"${tone === 'highlights' ? ' selected' : ''}>Highlights</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-adj-cb-sliders" data-cb-tone="${tone}">
|
||||
${toneSliders(tone)}
|
||||
</div>
|
||||
`;
|
||||
body.querySelector('.ge-adj-cb-tone-select')?.addEventListener('change', (e) => {
|
||||
popEl._cbTone = e.target.value;
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
}
|
||||
// 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 (type === 'brightness-contrast' || type === 'hue-saturation') {
|
||||
p[key] = defaults[key];
|
||||
} else if (type === 'levels') {
|
||||
p[key] = defaults[key];
|
||||
} else if (type === 'color-balance') {
|
||||
const [tone, ch] = key.split('-');
|
||||
p[tone][ch] = defaults[tone][ch];
|
||||
}
|
||||
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 (type === 'levels') {
|
||||
if (key === 'gamma') {
|
||||
p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
|
||||
} else {
|
||||
p[key] = raw;
|
||||
}
|
||||
} else if (type === 'color-balance') {
|
||||
const [tone, ch] = key.split('-');
|
||||
p[tone][ch] = raw;
|
||||
}
|
||||
if (valEl) valEl.textContent = display;
|
||||
scheduleAdjRefresh(layer);
|
||||
}
|
||||
|
||||
// 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 placeHandles = () => {
|
||||
const w = canvas.getBoundingClientRect().width;
|
||||
const p = layer._stagedAdj.params;
|
||||
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 = layer._stagedAdj.params;
|
||||
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}"]`);
|
||||
if (sl) sl.value = String(key === 'gamma' ? Math.round(layer._stagedAdj.params.gamma * 100) : layer._stagedAdj.params[key]);
|
||||
const val = sl?.parentElement.querySelector('.ge-adj-value');
|
||||
if (val) val.textContent = displayVal;
|
||||
};
|
||||
if (which === 'inBlack') updateRow('inBlack', String(layer._stagedAdj.params.inBlack));
|
||||
if (which === 'inWhite') updateRow('inWhite', String(layer._stagedAdj.params.inWhite));
|
||||
if (which === 'gamma') updateRow('gamma', layer._stagedAdj.params.gamma.toFixed(2) + 'γ');
|
||||
drawHistogram(canvas, layer);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Pure helpers that translate between the editor's adjustment-slider
|
||||
* UI and CSS `filter` strings / canvas-filter multipliers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a CSS `filter` string from a layer's `adjustments` object.
|
||||
* Returns '' when every value is at identity so the composite path
|
||||
* can skip the filter entirely.
|
||||
*
|
||||
* @param {{
|
||||
* brightness?: number, contrast?: number,
|
||||
* saturation?: number, hue?: number,
|
||||
* }|null|undefined} adj
|
||||
*/
|
||||
export function layerFilterString(adj) {
|
||||
if (!adj) return '';
|
||||
const parts = [];
|
||||
if (adj.brightness !== undefined && adj.brightness !== 1) parts.push(`brightness(${adj.brightness})`);
|
||||
if (adj.contrast !== undefined && adj.contrast !== 1) parts.push(`contrast(${adj.contrast})`);
|
||||
if (adj.saturation !== undefined && adj.saturation !== 1) parts.push(`saturate(${adj.saturation})`);
|
||||
if (adj.hue !== undefined && adj.hue !== 0) parts.push(`hue-rotate(${adj.hue}deg)`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert a stored filter multiplier (brightness/contrast/saturation
|
||||
* are 0..2 with 1.0 = identity; hue is degrees, -180..+180) into the
|
||||
* UI slider's -100..+100 (or -180..+180 for hue) range.
|
||||
*/
|
||||
export function fxFilterToSlider(key, value) {
|
||||
if (key === 'brightness' || key === 'contrast' || key === 'saturation') {
|
||||
return Math.round(((value ?? 1) - 1) * 100);
|
||||
}
|
||||
if (key === 'hue') return Math.round(value ?? 0);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Draw a luminance histogram of a layer's pixels onto the given
|
||||
* canvas. Sampling is capped at ~400×400 so the call stays cheap on
|
||||
* very large images.
|
||||
*
|
||||
* If the layer has a staged Levels adjustment
|
||||
* (`layer._stagedAdj.params` with `inBlack` / `inWhite`), the two
|
||||
* endpoint markers are drawn over the bars.
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas The histogram canvas to render into.
|
||||
* @param {{
|
||||
* canvas: HTMLCanvasElement,
|
||||
* _stagedAdj?: {params?: {inBlack?: number, inWhite?: number}}
|
||||
* }} layer Source layer.
|
||||
*/
|
||||
export function drawHistogram(canvas, layer) {
|
||||
if (!canvas) return;
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Down-sample huge images so the histogram stays interactive on 8k+
|
||||
// photos. ~400×400 is enough to characterise the distribution.
|
||||
const src = layer.canvas;
|
||||
const sw = src.width, sh = src.height;
|
||||
const maxSamples = 400;
|
||||
const sampleW = Math.min(maxSamples, sw);
|
||||
const sampleH = Math.min(maxSamples, sh);
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = sampleW; tmp.height = sampleH;
|
||||
const tctx = tmp.getContext('2d');
|
||||
tctx.drawImage(src, 0, 0, sampleW, sampleH);
|
||||
const img = tctx.getImageData(0, 0, sampleW, sampleH).data;
|
||||
|
||||
const hist = new Uint32Array(256);
|
||||
for (let i = 0; i < img.length; i += 4) {
|
||||
if (img[i + 3] < 8) continue; // skip near-transparent
|
||||
// Rec. 709 luminance — common choice for histograms in photo editors.
|
||||
const Y = (0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2]) | 0;
|
||||
hist[Math.min(255, Y)]++;
|
||||
}
|
||||
let peak = 1;
|
||||
for (let i = 0; i < 256; i++) if (hist[i] > peak) peak = hist[i];
|
||||
|
||||
// Background.
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Bars. sqrt-scaled so the long tails (specular highlights, deep
|
||||
// shadows) stay visible even when the central mass dominates.
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = (i / 256) * w;
|
||||
const bh = Math.pow(hist[i] / peak, 0.5) * h;
|
||||
ctx.fillRect(x, h - bh, w / 256 + 0.5, bh);
|
||||
}
|
||||
|
||||
// Endpoint markers (input black / input white) from a staged Levels
|
||||
// adjustment, if one is in flight.
|
||||
const p = layer._stagedAdj?.params;
|
||||
if (p) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.9)';
|
||||
ctx.fillRect((p.inBlack / 256) * w, 0, 1, h);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.fillRect((p.inWhite / 256) * w, 0, 1, h);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Apply a Brightness/Contrast, Hue/Saturation, Levels, or Color Balance
|
||||
* adjustment to a source canvas and return a fresh canvas with the
|
||||
* result. Pure pixel math — no DOM, no module state.
|
||||
*
|
||||
* Used by the editor's per-layer FX stack: each `adjLayer` calls
|
||||
* `applyAdjustment(prevCanvas, adjLayer)` and the result feeds the
|
||||
* next layer in the stack.
|
||||
*
|
||||
* Adjustment shape:
|
||||
* { type: 'brightness-contrast', params: { brightness, contrast } }
|
||||
* { type: 'hue-saturation', params: { hue, saturation } }
|
||||
* { type: 'levels', params: { inBlack, inWhite, gamma, outBlack, outWhite } }
|
||||
* { type: 'color-balance', params: { shadows, midtones, highlights } }
|
||||
*/
|
||||
export function applyAdjustment(srcCanvas, adj) {
|
||||
const w = srcCanvas.width, h = srcCanvas.height;
|
||||
const out = document.createElement('canvas');
|
||||
out.width = w; out.height = h;
|
||||
const octx = out.getContext('2d');
|
||||
|
||||
// B/C and H/S can use the fast browser-native CSS filter pipeline.
|
||||
if (adj.type === 'brightness-contrast') {
|
||||
const p = adj.params;
|
||||
octx.filter = `brightness(${p.brightness}) contrast(${p.contrast})`;
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
octx.filter = 'none';
|
||||
return out;
|
||||
}
|
||||
if (adj.type === 'hue-saturation') {
|
||||
const p = adj.params;
|
||||
octx.filter = `saturate(${p.saturation}) hue-rotate(${p.hue}deg)`;
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
octx.filter = 'none';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Levels + Color Balance need per-pixel math.
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
const img = octx.getImageData(0, 0, w, h);
|
||||
const d = img.data;
|
||||
|
||||
if (adj.type === 'levels') {
|
||||
const l = adj.params;
|
||||
const inLow = Math.max(0, Math.min(254, l.inBlack));
|
||||
const inHigh = Math.max(inLow + 1, Math.min(255, l.inWhite));
|
||||
const gamma = Math.max(0.1, l.gamma || 1);
|
||||
const outLow = Math.max(0, Math.min(255, l.outBlack));
|
||||
const outHigh = Math.max(outLow, Math.min(255, l.outWhite));
|
||||
const inv = 1.0 / gamma;
|
||||
const span = (outHigh - outLow);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let v = 0; v < 256; v++) {
|
||||
let t = (v - inLow) / (inHigh - inLow);
|
||||
if (t < 0) t = 0; else if (t > 1) t = 1;
|
||||
t = Math.pow(t, inv);
|
||||
lut[v] = Math.round(t * span + outLow);
|
||||
}
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = lut[d[i]]; d[i+1] = lut[d[i+1]]; d[i+2] = lut[d[i+2]];
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'color-balance') {
|
||||
const cb = adj.params;
|
||||
const scale = 0.6;
|
||||
const s = cb.shadows, m = cb.midtones, hi = cb.highlights;
|
||||
const sR = s.r*scale, sG = s.g*scale, sB = s.b*scale;
|
||||
const mR = m.r*scale, mG = m.g*scale, mB = m.b*scale;
|
||||
const hR = hi.r*scale, hG = hi.g*scale, hB = hi.b*scale;
|
||||
// Bell-curve tone weights so each pixel's shift is proportional to
|
||||
// how "shadow", "midtone", or "highlight" its luminance is.
|
||||
const wS = new Float32Array(256), wM = new Float32Array(256), wH = new Float32Array(256);
|
||||
const sig = 0.25;
|
||||
for (let v = 0; v < 256; v++) {
|
||||
const t = v / 255;
|
||||
wS[v] = Math.exp(-(t*t) / (2*sig*sig));
|
||||
wM[v] = Math.exp(-((t-0.5)*(t-0.5)) / (2*sig*sig));
|
||||
wH[v] = Math.exp(-((1-t)*(1-t)) / (2*sig*sig));
|
||||
}
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let r = d[i], g = d[i+1], b = d[i+2];
|
||||
const Y = (0.2126*r + 0.7152*g + 0.0722*b) | 0;
|
||||
const ws = wS[Y], wm = wM[Y], wh = wH[Y];
|
||||
r += sR*ws + mR*wm + hR*wh;
|
||||
g += sG*ws + mG*wm + hG*wh;
|
||||
b += sB*ws + mB*wm + hB*wh;
|
||||
d[i] = r < 0 ? 0 : r > 255 ? 255 : r;
|
||||
d[i+1] = g < 0 ? 0 : g > 255 ? 255 : g;
|
||||
d[i+2] = b < 0 ? 0 : b > 255 ? 255 : b;
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Apply a combined Levels + Color Balance pass to a layer in-place via
|
||||
* its `layer.adjustments` field. Cached on `layer._adjCache` keyed by
|
||||
* `cacheKey` so repeated composite passes don't re-run the math.
|
||||
*
|
||||
* Returns the cached output canvas.
|
||||
*
|
||||
* @param {{
|
||||
* canvas: HTMLCanvasElement,
|
||||
* adjustments: object,
|
||||
* _adjCache?: HTMLCanvasElement,
|
||||
* _adjCacheKey?: string,
|
||||
* }} layer
|
||||
* @param {string} cacheKey Stable signature of `layer.adjustments`.
|
||||
*/
|
||||
export function renderLayerPixelAdjustments(layer, cacheKey) {
|
||||
const adj = layer.adjustments;
|
||||
if (layer._adjCache && layer._adjCacheKey === cacheKey) return layer._adjCache;
|
||||
if (!layer._adjCache) {
|
||||
layer._adjCache = document.createElement('canvas');
|
||||
}
|
||||
const out = layer._adjCache;
|
||||
out.width = layer.canvas.width;
|
||||
out.height = layer.canvas.height;
|
||||
const octx = out.getContext('2d');
|
||||
octx.clearRect(0, 0, out.width, out.height);
|
||||
octx.drawImage(layer.canvas, 0, 0);
|
||||
const img = octx.getImageData(0, 0, out.width, out.height);
|
||||
const d = img.data;
|
||||
|
||||
// Single 256-entry LUT for the Levels portion (applied per R/G/B
|
||||
// channel identically — luma-style isn't right when colour balance
|
||||
// follows, per-channel is fine here).
|
||||
const l = adj.levels || { inBlack: 0, inWhite: 255, gamma: 1, outBlack: 0, outWhite: 255 };
|
||||
const inLow = Math.max(0, Math.min(254, l.inBlack));
|
||||
const inHigh = Math.max(inLow + 1, Math.min(255, l.inWhite));
|
||||
const gamma = Math.max(0.1, l.gamma || 1);
|
||||
const outLow = Math.max(0, Math.min(255, l.outBlack));
|
||||
const outHigh = Math.max(outLow, Math.min(255, l.outWhite));
|
||||
const inv = 1.0 / gamma;
|
||||
const span = (outHigh - outLow);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let v = 0; v < 256; v++) {
|
||||
let t = (v - inLow) / (inHigh - inLow);
|
||||
if (t < 0) t = 0; else if (t > 1) t = 1;
|
||||
t = Math.pow(t, inv);
|
||||
lut[v] = Math.round(t * span + outLow);
|
||||
}
|
||||
|
||||
// Color Balance bell-curve weights (see applyAdjustment).
|
||||
const cb = adj.colorBalance || { shadows: {r:0,g:0,b:0}, midtones: {r:0,g:0,b:0}, highlights: {r:0,g:0,b:0} };
|
||||
const s = cb.shadows || {r:0,g:0,b:0};
|
||||
const m = cb.midtones || {r:0,g:0,b:0};
|
||||
const h = cb.highlights || {r:0,g:0,b:0};
|
||||
const scale = 0.6;
|
||||
const sR = s.r * scale, sG = s.g * scale, sB = s.b * scale;
|
||||
const mR = m.r * scale, mG = m.g * scale, mB = m.b * scale;
|
||||
const hR = h.r * scale, hG = h.g * scale, hB = h.b * scale;
|
||||
|
||||
const wS = new Float32Array(256);
|
||||
const wM = new Float32Array(256);
|
||||
const wH = new Float32Array(256);
|
||||
for (let v = 0; v < 256; v++) {
|
||||
const t = v / 255;
|
||||
const dS = t, wsig = 0.25;
|
||||
const dM = t - 0.5;
|
||||
const dH = 1 - t;
|
||||
wS[v] = Math.exp(-(dS * dS) / (2 * wsig * wsig));
|
||||
wM[v] = Math.exp(-(dM * dM) / (2 * wsig * wsig));
|
||||
wH[v] = Math.exp(-(dH * dH) / (2 * wsig * wsig));
|
||||
}
|
||||
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let r = lut[d[i]];
|
||||
let g = lut[d[i + 1]];
|
||||
let b = lut[d[i + 2]];
|
||||
const Y = (0.2126 * r + 0.7152 * g + 0.0722 * b) | 0;
|
||||
const ws = wS[Y], wm = wM[Y], wh = wH[Y];
|
||||
r += sR * ws + mR * wm + hR * wh;
|
||||
g += sG * ws + mG * wm + hG * wh;
|
||||
b += sB * ws + mB * wm + hB * wh;
|
||||
d[i] = r < 0 ? 0 : r > 255 ? 255 : r;
|
||||
d[i + 1] = g < 0 ? 0 : g > 255 ? 255 : g;
|
||||
d[i + 2] = b < 0 ? 0 : b > 255 ? 255 : b;
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
layer._adjCacheKey = cacheKey;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Walk the layer's `adjLayers` stack (skipping the one currently being
|
||||
* edited, if any) plus an optional staged preview adjustment, producing
|
||||
* a final canvas the composite step can paint. The result is memoised
|
||||
* on `layer._adjFinal` keyed by a signature of all adjLayer params +
|
||||
* staged + editing id, so repeated composite passes are O(1) when
|
||||
* nothing has changed.
|
||||
*
|
||||
* If the stack is empty AND nothing is staged, returns the layer's own
|
||||
* canvas unchanged (no allocation).
|
||||
*
|
||||
* @param {{
|
||||
* canvas: HTMLCanvasElement,
|
||||
* adjLayers?: Array<{id: string, type: string, params: object, visible: boolean, opacity: number}>,
|
||||
* _stagedAdj?: {type: string, params: object} | null,
|
||||
* _editingAdjId?: string | null,
|
||||
* _adjFinal?: HTMLCanvasElement,
|
||||
* _adjFinalKey?: string,
|
||||
* }} layer
|
||||
* @returns {HTMLCanvasElement}
|
||||
*/
|
||||
export function renderLayerWithAdjLayers(layer) {
|
||||
const editingId = layer._editingAdjId || null;
|
||||
const stack = (layer.adjLayers || []).filter(a => a.visible && a.id !== editingId);
|
||||
const staged = layer._stagedAdj;
|
||||
if (stack.length === 0 && !staged) {
|
||||
layer._adjFinalKey = '';
|
||||
return layer.canvas;
|
||||
}
|
||||
const sig = stack.map(a => `${a.id}:${a.visible?1:0}:${a.opacity}:${a.type}:${JSON.stringify(a.params)}`).join('|') +
|
||||
(staged ? `|S:${staged.type}:${JSON.stringify(staged.params)}` : '') +
|
||||
(editingId ? `|E:${editingId}` : '');
|
||||
if (layer._adjFinal && layer._adjFinalKey === sig) return layer._adjFinal;
|
||||
let cur = layer.canvas;
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
for (const adj of stack) {
|
||||
const adjOut = applyAdjustment(cur, adj);
|
||||
if (adj.opacity >= 0.999) {
|
||||
cur = adjOut;
|
||||
} else {
|
||||
const blend = document.createElement('canvas');
|
||||
blend.width = w; blend.height = h;
|
||||
const bctx = blend.getContext('2d');
|
||||
bctx.drawImage(cur, 0, 0);
|
||||
bctx.globalAlpha = adj.opacity;
|
||||
bctx.drawImage(adjOut, 0, 0);
|
||||
bctx.globalAlpha = 1;
|
||||
cur = blend;
|
||||
}
|
||||
}
|
||||
if (staged) {
|
||||
cur = applyAdjustment(cur, staged);
|
||||
}
|
||||
layer._adjFinal = cur;
|
||||
layer._adjFinalKey = sig;
|
||||
return cur;
|
||||
}
|
||||
Reference in New Issue
Block a user