mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-06 05:02:24 +01:00
feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).
Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -48,6 +48,7 @@ import {
|
||||
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() {
|
||||
@@ -152,6 +153,12 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
{ 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 =>
|
||||
`<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>`
|
||||
@@ -332,7 +339,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
<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>
|
||||
<button class="ge-adj-close" type="button" title="Close" aria-label="Close">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<label class="ge-adj-preset-field">Preset
|
||||
@@ -382,7 +389,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
}
|
||||
|
||||
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]');
|
||||
@@ -530,8 +536,8 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
return p.channels[channel];
|
||||
};
|
||||
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}">
|
||||
const sliderRow = (key, label, min, max, value, suffix, defaultValue) => `
|
||||
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
|
||||
<label>${label}</label>
|
||||
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
|
||||
<span class="ge-adj-value">${value}${suffix || ''}</span>
|
||||
@@ -551,6 +557,25 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')}
|
||||
`;
|
||||
} else if (type === 'develop') {
|
||||
body.innerHTML = `
|
||||
<div class="ge-adj-develop">
|
||||
<div class="ge-adj-group-label">Basic</div>
|
||||
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature || 0), '', 0)}
|
||||
${sliderRow('tint', 'Tint', -100, 100, Math.round(p.tint || 0), '', 0)}
|
||||
${sliderRow('exposure', 'Exposure', -500, 500, Math.round((p.exposure || 0) * 100), ' EV', 0)}
|
||||
${sliderRow('contrast', 'Contrast', -100, 100, Math.round(p.contrast || 0), '', 0)}
|
||||
${sliderRow('highlights', 'Highlights', -100, 100, Math.round(p.highlights || 0), '', 0)}
|
||||
${sliderRow('shadows', 'Shadows', -100, 100, Math.round(p.shadows || 0), '', 0)}
|
||||
${sliderRow('whites', 'Whites', -100, 100, Math.round(p.whites || 0), '', 0)}
|
||||
${sliderRow('blacks', 'Blacks', -100, 100, Math.round(p.blacks || 0), '', 0)}
|
||||
<div class="ge-adj-group-label">Presence</div>
|
||||
${sliderRow('clarity', 'Clarity', -100, 100, Math.round(p.clarity || 0), '', 0)}
|
||||
${sliderRow('vibrance', 'Vibrance', -100, 100, Math.round(p.vibrance || 0), '', 0)}
|
||||
${sliderRow('saturation', 'Saturation', -100, 100, Math.round(p.saturation || 0), '', 0)}
|
||||
<p class="ge-adj-help">Camera-Raw-style Basic panel: white balance, exposure and tone, then clarity, vibrance and saturation.</p>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'white-balance') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')}
|
||||
@@ -746,6 +771,134 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
p.reverse = event.target.checked;
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
} else if (type === 'invert') {
|
||||
body.innerHTML = `
|
||||
<p class="ge-adj-help">Inverts every color channel in the source layer.</p>
|
||||
`;
|
||||
} else if (type === 'posterize') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('levels', 'Levels', 2, 255, Math.round(p.levels ?? 4), '')}
|
||||
<p class="ge-adj-help">Reduces each channel to a limited number of tonal steps.</p>
|
||||
`;
|
||||
} else if (type === 'threshold') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('level', 'Threshold', 0, 255, Math.round(p.level ?? 128), '')}
|
||||
<p class="ge-adj-help">Pixels brighter than the threshold become white, the rest black.</p>
|
||||
`;
|
||||
} else if (type === 'photo-filter') {
|
||||
body.innerHTML = `
|
||||
<div class="ge-adj-row">
|
||||
<label>Color</label>
|
||||
<input type="color" data-photo-key="color" value="${p.color || '#ec8a00'}">
|
||||
</div>
|
||||
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
|
||||
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
|
||||
`;
|
||||
body.querySelector('[data-photo-key="color"]')?.addEventListener('input', event => {
|
||||
p.color = event.target.value;
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
body.querySelector('[data-photo-preserve]')?.addEventListener('change', event => {
|
||||
p.preserve_luminosity = event.target.checked;
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
} else if (type === 'channel-mixer') {
|
||||
const channels = ['red', 'green', 'blue'];
|
||||
const ch = channels.includes(popEl._cmMixChannel) ? popEl._cmMixChannel : 'red';
|
||||
popEl._cmMixChannel = ch;
|
||||
if (!p[ch]) p[ch] = defaultAdjParams(type)[ch];
|
||||
if (!p.constant) p.constant = defaultAdjParams(type).constant;
|
||||
const values = p[ch];
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Output channel
|
||||
<select class="ge-adj-cm-channel-select">
|
||||
<option value="red"${ch === 'red' ? ' selected' : ''}>Red</option>
|
||||
<option value="green"${ch === 'green' ? ' selected' : ''}>Green</option>
|
||||
<option value="blue"${ch === 'blue' ? ' selected' : ''}>Blue</option>
|
||||
</select>
|
||||
</label>
|
||||
${sliderRow(`${ch}-r`, 'Red', -200, 200, Math.round(values.r ?? 0), '%')}
|
||||
${sliderRow(`${ch}-g`, 'Green', -200, 200, Math.round(values.g ?? 0), '%')}
|
||||
${sliderRow(`${ch}-b`, 'Blue', -200, 200, Math.round(values.b ?? 0), '%')}
|
||||
${sliderRow(`${ch}-c`, 'Constant', -100, 100, Math.round(p.constant[ch] ?? 0), '')}
|
||||
<label class="ge-adj-toggle-row"><span>Monochrome</span><input type="checkbox" data-cm-monochrome${p.monochrome ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
|
||||
<p class="ge-adj-help">Mixes source channels into the selected output channel.</p>
|
||||
`;
|
||||
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 = `
|
||||
<label class="ge-adj-channel-field">Lookup preset
|
||||
<select class="ge-adj-lut-select">
|
||||
${presets.map(name => `<option value="${name}"${(p.preset || 'none') === name ? ' selected' : ''}>${presetLabels[name]}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<div class="ge-adj-lut-actions">
|
||||
<label class="ge-btn ge-btn-sm ge-adj-lut-load">Load .cube…
|
||||
<input type="file" accept=".cube,text/plain" hidden />
|
||||
</label>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-adj-lut-export"${hasCube ? '' : ' disabled'}>Export .cube</button>
|
||||
</div>
|
||||
<div class="ge-adj-lut-name">${lutName}</div>
|
||||
${sliderRow('strength', 'Strength', 0, 100, strength, '%', 100)}
|
||||
<p class="ge-adj-help">Import an Adobe .cube 3D LUT (or pick a built-in look). Strength blends the LUT with the original pixels.</p>
|
||||
`;
|
||||
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 => {
|
||||
@@ -769,7 +922,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
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)) {
|
||||
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';
|
||||
@@ -779,6 +932,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
} 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];
|
||||
}
|
||||
@@ -791,6 +948,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
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') {
|
||||
@@ -804,10 +965,13 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
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 === 'vibrance') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'black-white') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'shadows-highlights') {
|
||||
@@ -828,6 +992,18 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
||||
} 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);
|
||||
|
||||
Reference in New Issue
Block a user