Files
odysseus/static/js/editor/panels/panels.js
T
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c 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>
2026-10-15 11:57:35 +00:00

447 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Color + analysis panels for the image editor right dock (PhotoCraft parity).
// Pure DOM panels that read the shared editor `state` and call back into the
// editor for the few actions that need to mutate the document (color pick,
// layer visibility, pan, zoom). No global side effects beyond the state/hooks
// passed in by buildRightPanel/galleryEditor.
import { drawHistogram } from '../fx/histogram.js';
import { applyCanvasPan } from '../canvas-navigation.js';
import { attachColorPicker, getRecentColors, suggestColorsFor, recordRecentColor } from '../../colorPicker.js?v=20260910eyedropper1';
const SWATCHES = [
'#000000', '#404040', '#808080', '#c0c0c0', '#ffffff', '#ff0000', '#ff8000',
'#ffff00', '#80ff00', '#00ff00', '#00ff80', '#00ffff', '#0080ff', '#0000ff',
'#8000ff', '#ff00ff', '#ff0080', '#7f0000', '#7f4000', '#7f7f00', '#407f00',
'#007f00', '#007f40', '#007f7f', '#00407f', '#00007f', '#40007f', '#7f007f',
'#7f0040', '#ffcccc', '#ffd9b3', '#ffffcc', '#ccffcc', '#cce5ff', '#e5ccff',
'#ffcce5', '#8b4513', '#d2b48c', '#f5deb3', '#2f4f4f',
];
const GRADIENTS = [
['Black → White', ['#000000', '#ffffff']],
['White → Black', ['#ffffff', '#000000']],
['Red → Yellow', ['#ff0000', '#ffff00']],
['Yellow → Blue', ['#ffff00', '#0000ff']],
['Cyan → Blue', ['#00ffff', '#0000ff']],
['Magenta → Orange', ['#ff00ff', '#ff8000']],
['Green → Cyan', ['#00ff00', '#00ffff']],
['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']],
];
function el(tag, className, html) {
const node = document.createElement(tag);
if (className) node.className = className;
if (html != null) node.innerHTML = html;
return node;
}
function panel(id, title, body) {
const wrap = el('div', 'ge-panel');
wrap.id = `ge-panel-${id}`;
if (title) wrap.appendChild(el('div', 'ge-panel-title', title));
wrap.appendChild(body);
return wrap;
}
function activeLayer(state) {
if (!state || !Array.isArray(state.layers)) return null;
return state.layers.find((layer) => layer.id === state.activeLayerId) || state.layers[state.layers.length - 1] || null;
}
// Minimal HSV <-> hex conversion for the inline Swatches picker. Kept local so
// the panel does not depend on colorPicker.js internals (which only export the
// harmony/recent helpers) and to avoid pulling the shared popover singleton
// into the dock.
function hexToHsv(hex) {
const m = String(hex || '').trim().match(/^#?([0-9a-f]{6})$/i);
const int = m ? parseInt(m[1], 16) : 0;
const r = ((int >> 16) & 255) / 255;
const g = ((int >> 8) & 255) / 255;
const b = (int & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
const s = max ? (d / max) * 100 : 0;
return { h, s, v: max * 100 };
}
function hsvToHex(h, s, v) {
h = ((h % 360) + 360) % 360;
s = Math.max(0, Math.min(100, s)) / 100;
v = Math.max(0, Math.min(100, v)) / 100;
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
const to = (n) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
return `#${to(r)}${to(g)}${to(b)}`;
}
export function createColorPanels({
state,
canvasArea = null,
setColor = (hex) => { if (state) state.color = hex; },
requestRender = () => {},
zoomIn = null,
zoomOut = null,
zoomFit = null,
} = {}) {
if (!state) throw new Error('createColorPanels requires editor state');
// ---- Swatches ---------------------------------------------------------
// Reuse the app's shared color picker (colorPicker.js → `.cp-popover`: SL +
// hue, hex, suggestions, recent swatches, eyedropper) instead of a static
// preset grid.
const swatchBody = el('div', 'ge-swatch-body');
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.className = 'ge-color-input';
colorInput.value = (state.color || '#000000');
colorInput.title = 'Pick a color';
// colorInput is kept as an internal colour holder only (it is not appended);
// the panel shows the inline picker, a hex field, Suggestions and Recent.
// Suggestions + recent colours sit right next to the picker (reusing the
// app's colorPicker harmony/recent logic), so common picks are one click.
const swatchCol = el('div', 'ge-swatch-col');
const suggestRow = el('div', 'ge-swatch-row ge-swatch-suggest');
const recentRow = el('div', 'ge-swatch-row ge-swatch-recent');
const sugLabel = el('div', 'ge-swatch-row-label'); sugLabel.textContent = 'Suggestions';
const recLabel = el('div', 'ge-swatch-row-label'); recLabel.textContent = 'Recent';
swatchCol.append(sugLabel, suggestRow, recLabel, recentRow);
swatchBody.appendChild(swatchCol);
const _syncForeground = (hex) => {
setColor(hex);
// Mirror the pick into the rail's foreground swatch so the whole editor
// (brush/bucket colour, swap, footer) follows a click on a suggestion.
const fg = document.getElementById('ge-fg-color');
if (fg) { fg.value = hex; try { fg.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {} }
};
const renderInlineSwatches = () => {
const cur = state.color || colorInput.value || '#000000';
const sug = suggestColorsFor(cur);
suggestRow.innerHTML = sug.map(s =>
`<button type="button" class="ge-mini-swatch" title="${s.label}: ${s.hex}" data-hex="${s.hex}" style="background:${s.hex}"></button>`
).join('');
// Same number of Recent swatches as Suggestions, on a single row.
const recs = getRecentColors().slice(0, sug.length);
recentRow.innerHTML = recs.length
? recs.map(h => `<button type="button" class="ge-mini-swatch" title="${h}" data-hex="${h}" style="background:${h}"></button>`).join('')
: '<span class="ge-mini-empty">none yet</span>';
};
// Inline picker: an always-visible SV square + hue strip so the panel reads
// as a colour picker at a glance (the shared popover only appears after a
// click, so a static dock shot never shows it). Reuses the app's .cp-sl /
// .cp-hue styling so it matches the popover exactly.
const picker = el('div', 'ge-inline-picker');
const sl = el('div', 'cp-sl');
sl.innerHTML = '<div class="cp-sl-white"></div><div class="cp-sl-black"></div><div class="cp-sl-handle"></div>';
const hue = el('div', 'cp-hue');
hue.innerHTML = '<div class="cp-hue-handle"></div>';
picker.append(sl, hue);
swatchBody.appendChild(picker);
// Hex code field (e.g. #e06c75) — type a value to set the colour.
const hexRow = el('div', 'ge-hex-row');
const hexHash = el('span', 'ge-hex-hash'); hexHash.textContent = '#';
const hexInput = el('input', 'ge-hex-input');
hexInput.type = 'text';
hexInput.spellcheck = false;
hexInput.autocomplete = 'off';
hexInput.setAttribute('aria-label', 'Hex colour code');
hexInput.value = String(state.color || '#000000').replace(/^#/, '');
hexRow.append(hexHash, hexInput);
swatchBody.appendChild(hexRow);
const _setHex = (hex) => {
if (hexInput && document.activeElement !== hexInput) hexInput.value = String(hex || '').replace(/^#/, '');
};
hexInput.addEventListener('input', () => {
let v = String(hexInput.value || '').trim();
if (v && v[0] !== '#') v = `#${v}`;
if (v.length === 4) v = `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`;
if (!/^#[0-9a-f]{6}$/i.test(v)) return;
_syncForeground(v);
colorInput.value = v;
setPickerFromColor(v);
renderInlineSwatches();
});
const slHandle = sl.querySelector('.cp-sl-handle');
const hueHandle = hue.querySelector('.cp-hue-handle');
let ipH = 0, ipS = 0, ipV = 0, ipDrag = null;
const paintPicker = () => {
sl.style.backgroundColor = hsvToHex(ipH, 100, 100);
slHandle.style.left = `${ipS}%`;
slHandle.style.top = `${100 - ipV}%`;
hueHandle.style.left = `${(ipH / 360) * 100}%`;
};
const setPickerFromColor = (hex) => {
const { h, s, v } = hexToHsv(hex);
ipH = h; ipS = s; ipV = v;
paintPicker();
};
const commitPicker = () => {
const hex = hsvToHex(ipH, ipS, ipV);
_syncForeground(hex);
colorInput.value = hex;
_setHex(hex);
renderInlineSwatches();
};
const pickFromEvent = (event) => {
if (ipDrag === 'sl') {
const r = sl.getBoundingClientRect();
ipS = Math.max(0, Math.min(100, ((event.clientX - r.left) / r.width) * 100));
ipV = Math.max(0, Math.min(100, (1 - (event.clientY - r.top) / r.height) * 100));
paintPicker();
commitPicker();
} else if (ipDrag === 'hue') {
const r = hue.getBoundingClientRect();
ipH = Math.max(0, Math.min(360, ((event.clientX - r.left) / r.width) * 360));
paintPicker();
commitPicker();
}
};
const startDrag = (kind, captureEl) => (event) => {
event.preventDefault();
ipDrag = kind;
try { captureEl.setPointerCapture(event.pointerId); } catch (_) {}
pickFromEvent(event);
};
sl.addEventListener('pointerdown', startDrag('sl', sl));
hue.addEventListener('pointerdown', startDrag('hue', hue));
picker.addEventListener('pointermove', (event) => { if (ipDrag) pickFromEvent(event); });
picker.addEventListener('pointerup', () => { ipDrag = null; });
picker.addEventListener('pointercancel', () => { ipDrag = null; });
setPickerFromColor(state.color || colorInput.value || '#000000');
swatchBody.addEventListener('click', (e) => {
const btn = e.target.closest('.ge-mini-swatch');
if (!btn) return;
const hex = btn.dataset.hex;
recordRecentColor(hex);
_syncForeground(hex);
colorInput.value = hex;
_setHex(hex);
setPickerFromColor(hex);
renderInlineSwatches();
});
colorInput.addEventListener('input', () => {
_syncForeground(colorInput.value);
_setHex(colorInput.value);
setPickerFromColor(colorInput.value);
renderInlineSwatches();
});
const swatches = panel('swatches', '', swatchBody);
renderInlineSwatches();
const refreshSwatches = () => {
const color = (state.color || '#000000').toLowerCase();
if (String(colorInput.value).toLowerCase() !== color) colorInput.value = color;
_setHex(color);
setPickerFromColor(color);
renderInlineSwatches();
};
// ---- Gradients --------------------------------------------------------
const gradientGrid = el('div', 'ge-gradient-grid');
GRADIENTS.forEach(([name, stops]) => {
const css = `linear-gradient(90deg, ${stops.join(', ')})`;
const btn = el('button', 'ge-gradient-swatch');
btn.type = 'button';
btn.title = name;
btn.style.background = css;
btn.addEventListener('click', () => {
const start = document.getElementById('ge-gradient-start');
const end = document.getElementById('ge-gradient-end');
const type = document.getElementById('ge-gradient-type');
if (start && end) {
start.value = stops[0];
end.value = stops[stops.length - 1];
start.dispatchEvent(new Event('input', { bubbles: true }));
end.dispatchEvent(new Event('input', { bubbles: true }));
if (type) { type.value = 'linear'; type.dispatchEvent(new Event('change', { bubbles: true })); }
requestRender();
}
});
gradientGrid.appendChild(btn);
});
const gradients = panel('gradients', '', gradientGrid);
// ---- Channels (layer visibility) -------------------------------------
const channelList = el('div', 'ge-channel-list');
const channels = panel('channels', 'CHANNELS', channelList);
const renderChannels = () => {
channelList.innerHTML = '';
const layers = Array.isArray(state.layers) ? state.layers : [];
if (!layers.length) {
channelList.appendChild(el('p', 'ge-panel-empty', 'No layers yet.'));
return;
}
layers.slice().reverse().forEach((layer) => {
const row = el('div', 'ge-channel-row');
row.classList.toggle('active', layer.id === state.activeLayerId);
const eye = el('button', 'ge-channel-eye');
eye.type = 'button';
eye.setAttribute('aria-pressed', layer.visible === false ? 'false' : 'true');
eye.title = 'Toggle visibility';
eye.textContent = layer.visible === false ? '◌' : '◉';
eye.addEventListener('click', () => {
layer.visible = layer.visible === false;
requestRender();
renderChannels();
});
const name = el('span', 'ge-channel-name', layer.name || 'Layer');
row.appendChild(eye);
row.appendChild(name);
row.addEventListener('click', (event) => {
if (event.target === eye) return;
state.activeLayerId = layer.id;
requestRender();
renderChannels();
});
channelList.appendChild(row);
});
};
// ---- Histogram --------------------------------------------------------
const histCanvas = el('canvas', 'ge-histogram-canvas');
histCanvas.width = 260;
histCanvas.height = 90;
const histBody = el('div', 'ge-histogram-body');
histBody.appendChild(histCanvas);
const histogram = panel('histogram', '', histBody);
const redrawHistogram = () => {
try { drawHistogram(histCanvas, activeLayer(state)); } catch { /* not ready */ }
};
// ---- Info -------------------------------------------------------------
const infoBody = el('div', 'ge-info-body');
const infoColor = el('span', 'ge-info-color');
const infoHex = el('span', 'ge-info-hex', '—');
const infoSize = el('span', 'ge-info-size', '—');
const infoZoom = el('span', 'ge-info-zoom', '—');
const infoLayer = el('span', 'ge-info-layer', '—');
[
['Color', [infoColor, infoHex]],
['Size', infoSize],
['Zoom', infoZoom],
['Layer', infoLayer],
].forEach(([label, nodes]) => {
const row = el('div', 'ge-info-row');
row.appendChild(el('span', 'ge-info-label', label));
const cell = el('span', 'ge-info-value');
(Array.isArray(nodes) ? nodes : [nodes]).forEach((n) => cell.appendChild(n));
row.appendChild(cell);
infoBody.appendChild(row);
});
const info = panel('info', 'INFO', infoBody);
const refreshInfo = () => {
infoSize.textContent = `${state.imgWidth || 0} × ${state.imgHeight || 0} px`;
infoZoom.textContent = `${Math.round((state.zoom || 1) * 100)}%`;
const layer = activeLayer(state);
infoLayer.textContent = layer ? (layer.name || 'Layer') : '—';
};
const sampleAt = (clientX, clientY) => {
const canvas = state.mainCanvas;
const ctx = state.mainCtx;
if (!canvas || !ctx) return;
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const x = Math.round((clientX - rect.left) / rect.width * (canvas.width || 1));
const y = Math.round((clientY - rect.top) / rect.height * (canvas.height || 1));
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return;
try {
const data = ctx.getImageData(x, y, 1, 1).data;
const hex = `#${[data[0], data[1], data[2]].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
infoColor.style.background = hex;
infoHex.textContent = hex.toUpperCase();
} catch { /* tainted/empty */ }
};
if (canvasArea) {
canvasArea.addEventListener('pointermove', (event) => sampleAt(event.clientX, event.clientY));
}
// ---- Navigator --------------------------------------------------------
const navCanvas = el('canvas', 'ge-navigator-canvas');
navCanvas.width = 220;
navCanvas.height = 150;
const navBody = el('div', 'ge-navigator-body');
navBody.appendChild(navCanvas);
const navigator = panel('navigator', 'NAVIGATOR', navBody);
const navScale = () => {
const docW = state.imgWidth || state.mainCanvas?.width || 1;
const docH = state.imgHeight || state.mainCanvas?.height || 1;
return Math.min(navCanvas.width / docW, navCanvas.height / docH);
};
const drawNavigator = () => {
const ctx = navCanvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, navCanvas.width, navCanvas.height);
ctx.fillStyle = '#0b0d10';
ctx.fillRect(0, 0, navCanvas.width, navCanvas.height);
const source = state.mainCanvas;
if (source) {
try {
const s = navScale();
const w = (state.imgWidth || source.width) * s;
const h = (state.imgHeight || source.height) * s;
ctx.drawImage(source, 0, 0, source.width, source.height, 0, 0, w, h);
const zoom = state.zoom || 1;
const area = canvasArea?.getBoundingClientRect();
if (area && area.width && area.height) {
const viewX = (-(state.panX || 0)) / zoom * s;
const viewY = (-(state.panY || 0)) / zoom * s;
const viewW = area.width / zoom * s;
const viewH = area.height / zoom * s;
ctx.strokeStyle = '#ff5a5f';
ctx.lineWidth = 1.5;
ctx.strokeRect(viewX, viewY, viewW, viewH);
}
} catch { /* not ready */ }
}
};
const panFromNavigator = (event) => {
const rect = navCanvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const mx = (event.clientX - rect.left) / rect.width * navCanvas.width;
const my = (event.clientY - rect.top) / rect.height * navCanvas.height;
const s = navScale();
const docX = mx / s;
const docY = my / s;
const zoom = state.zoom || 1;
const area = canvasArea;
if (!area) return;
const panX = -docX * zoom + area.clientWidth / 2;
const panY = -docY * zoom + area.clientHeight / 2;
applyCanvasPan(state, area, panX, panY);
drawNavigator();
};
let navDragging = false;
navCanvas.addEventListener('pointerdown', (event) => { navDragging = true; navCanvas.setPointerCapture?.(event.pointerId); panFromNavigator(event); });
navCanvas.addEventListener('pointermove', (event) => { if (navDragging) panFromNavigator(event); });
navCanvas.addEventListener('pointerup', (event) => { navDragging = false; navCanvas.releasePointerCapture?.(event.pointerId); });
navCanvas.addEventListener('pointerleave', () => { navDragging = false; });
const refresh = () => {
refreshSwatches();
renderChannels();
redrawHistogram();
refreshInfo();
drawNavigator();
};
refresh();
return { swatches, gradients, channels, histogram, info, navigator, refresh };
}