mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-20 13:52:24 +02: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
@@ -0,0 +1,446 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Extra right-dock studio panels (PhotoCraft parity): Properties,
|
||||
* Character/Paragraph, Paths, Patterns, Adjustments and History. Every
|
||||
* panel is decoupled — live edits are delegated back through the
|
||||
* callbacks passed in, so this module owns no editor state.
|
||||
*/
|
||||
import { WARP_STYLES, EDITOR_TEXT_FONTS, availableEditorFonts } from '../text-layer.js';
|
||||
|
||||
const FONTS = availableEditorFonts().length ? availableEditorFonts() : EDITOR_TEXT_FONTS.slice();
|
||||
|
||||
export const ADJUSTMENT_TYPES = Object.freeze([
|
||||
['brightness-contrast', 'Brightness/Contrast'],
|
||||
['exposure', 'Exposure'],
|
||||
['develop', 'Develop'],
|
||||
['white-balance', 'White Balance'],
|
||||
['hue-saturation', 'Hue/Saturation'],
|
||||
['vibrance', 'Vibrance'],
|
||||
['black-white', 'Black & White'],
|
||||
['shadows-highlights', 'Shadows/Highlights'],
|
||||
['levels', 'Levels'],
|
||||
['curves', 'Curves'],
|
||||
['color-balance', 'Color Balance'],
|
||||
['selective-color', 'Selective Color'],
|
||||
['gradient-map', 'Gradient Map'],
|
||||
['invert', 'Invert'],
|
||||
['posterize', 'Posterize'],
|
||||
['threshold', 'Threshold'],
|
||||
['photo-filter', 'Photo Filter'],
|
||||
['channel-mixer', 'Channel Mixer'],
|
||||
['color-lookup', 'Color Lookup'],
|
||||
]);
|
||||
|
||||
// Inline SVG glyphs (16x16, currentColor) for the Adjustments panel buttons.
|
||||
// Kept as path strings so each button is a single self-contained SVG.
|
||||
const ADJUSTMENT_ICONS = Object.freeze({
|
||||
'brightness-contrast': '<circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 1 0 12z" fill="currentColor" stroke="none"/>',
|
||||
exposure: '<circle cx="8" cy="8" r="3.2"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.2 3.2l1.4 1.4M11.4 11.4l1.4 1.4M12.8 3.2l-1.4 1.4M4.6 11.4l-1.4 1.4"/>',
|
||||
develop: '<path d="M2 4h12M2 8h12M2 12h12"/><circle cx="5" cy="4" r="1.6" fill="currentColor" stroke="none"/><circle cx="10" cy="8" r="1.6" fill="currentColor" stroke="none"/><circle cx="6" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
|
||||
'white-balance': '<circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 0 0 12z" fill="currentColor" stroke="none"/>',
|
||||
'hue-saturation': '<circle cx="8" cy="8" r="6"/><path d="M8 2v12M2 8h12" opacity=".7"/>',
|
||||
vibrance: '<circle cx="8" cy="8" r="5.5"/><path d="M8 4.4l1 2.4 2.4 1-2.4 1-1 2.4-1-2.4-2.4-1 2.4-1z" fill="currentColor" stroke="none"/>',
|
||||
'black-white': '<rect x="2" y="2" width="12" height="12" rx="2"/><path d="M8 2h4a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H8z" fill="currentColor" stroke="none"/>',
|
||||
'shadows-highlights': '<circle cx="6" cy="8" r="4"/><circle cx="10" cy="8" r="4"/>',
|
||||
levels: '<path d="M3 13V7M6.3 13V4M9.7 13V9M13 13V6"/>',
|
||||
curves: '<path d="M2 13C6 13 6 3 14 3"/>',
|
||||
'color-balance': '<circle cx="6" cy="6" r="3.6"/><circle cx="10" cy="6" r="3.6"/><circle cx="8" cy="10" r="3.6"/>',
|
||||
'selective-color': '<circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.6" fill="currentColor" stroke="none"/>',
|
||||
'gradient-map': '<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M2 7h12M2 9h12M2 11h12" opacity=".55"/>',
|
||||
invert: '<circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 0 0 12z" fill="currentColor" stroke="none"/>',
|
||||
posterize: '<rect x="2" y="2" width="6" height="6"/><rect x="8" y="8" width="6" height="6"/><rect x="8" y="2" width="6" height="6" fill="currentColor" stroke="none"/>',
|
||||
threshold: '<rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M8 2h6v12H8z" fill="currentColor" stroke="none"/>',
|
||||
'photo-filter': '<circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.6"/>',
|
||||
'channel-mixer': '<path d="M3 4h10M3 8h10M3 12h10"/><circle cx="6" cy="4" r="1.6" fill="currentColor" stroke="none"/><circle cx="10" cy="8" r="1.6" fill="currentColor" stroke="none"/><circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
|
||||
'color-lookup': '<rect x="2" y="2" width="5.5" height="5.5"/><rect x="8.5" y="2" width="5.5" height="5.5"/><rect x="2" y="8.5" width="5.5" height="5.5"/><rect x="8.5" y="8.5" width="5.5" height="5.5"/>',
|
||||
});
|
||||
|
||||
function adjIcon(type) {
|
||||
const body = ADJUSTMENT_ICONS[type];
|
||||
if (!body) return '';
|
||||
return `<span class="ge-adj-icon" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">${body}</svg></span>`;
|
||||
}
|
||||
|
||||
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 row(label, control) {
|
||||
const wrap = el('label', 'ge-text-field');
|
||||
wrap.appendChild(el('span', null, label));
|
||||
wrap.appendChild(control);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function activeLayerOf(state) {
|
||||
return (state.layers || []).find(layer => layer.id === state.activeLayerId) || null;
|
||||
}
|
||||
|
||||
function patternDataUrl(kind) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 32;
|
||||
canvas.height = 32;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#20242c';
|
||||
ctx.fillRect(0, 0, 32, 32);
|
||||
ctx.fillStyle = '#c8cdd6';
|
||||
if (kind === 'stripes') {
|
||||
for (let x = 0; x < 32; x += 8) ctx.fillRect(x, 0, 4, 32);
|
||||
} else if (kind === 'checker') {
|
||||
for (let y = 0; y < 32; y += 8) {
|
||||
for (let x = 0; x < 32; x += 8) if (((x + y) / 8) % 2 === 0) ctx.fillRect(x, y, 8, 8);
|
||||
}
|
||||
} else if (kind === 'dots') {
|
||||
for (let y = 4; y < 32; y += 8) for (let x = 4; x < 32; x += 8) { ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill(); }
|
||||
} else if (kind === 'grid') {
|
||||
ctx.strokeStyle = '#c8cdd6';
|
||||
for (let i = 0; i <= 32; i += 8) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, 32); ctx.moveTo(0, i); ctx.lineTo(32, i); ctx.stroke(); }
|
||||
} else if (kind === 'diagonal') {
|
||||
for (let i = -32; i < 32; i += 8) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i + 32, 32); ctx.lineWidth = 3; ctx.strokeStyle = '#c8cdd6'; ctx.stroke(); }
|
||||
} else if (kind === 'cross') {
|
||||
ctx.fillRect(12, 0, 4, 32);
|
||||
ctx.fillRect(0, 12, 32, 4);
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
const PATTERN_KINDS = ['stripes', 'checker', 'dots', 'grid', 'diagonal', 'cross'];
|
||||
|
||||
export function createStudioPanels({
|
||||
state,
|
||||
onSelectLayer = () => {},
|
||||
onAddAdjustment = () => {},
|
||||
onPattern = () => {},
|
||||
updateText = () => {},
|
||||
getTextLayer = () => null,
|
||||
setLayerOpacity = () => {},
|
||||
requestRender = () => {},
|
||||
} = {}) {
|
||||
if (!state) throw new Error('createStudioPanels requires editor state');
|
||||
|
||||
// ---- Properties -------------------------------------------------------
|
||||
const propBody = el('div', 'ge-panel-body');
|
||||
const propName = el('div', 'ge-info-row', '<span class="ge-info-label">Layer</span><span class="ge-info-value" data-prop="name">—</span>');
|
||||
const propKind = el('div', 'ge-info-row', '<span class="ge-info-label">Type</span><span class="ge-info-value" data-prop="kind">—</span>');
|
||||
const propOpacity = document.createElement('input');
|
||||
propOpacity.type = 'range';
|
||||
propOpacity.min = '0';
|
||||
propOpacity.max = '100';
|
||||
propOpacity.value = '100';
|
||||
propOpacity.setAttribute('data-prop', 'opacity');
|
||||
propOpacity.addEventListener('input', () => setLayerOpacity(Number(propOpacity.value) / 100));
|
||||
const propBlend = document.createElement('select');
|
||||
propBlend.setAttribute('data-prop', 'blend');
|
||||
const liveBlend = document.getElementById('ge-layer-blend');
|
||||
if (liveBlend) {
|
||||
propBlend.innerHTML = liveBlend.innerHTML;
|
||||
propBlend.addEventListener('change', () => {
|
||||
const target = document.getElementById('ge-layer-blend');
|
||||
if (target) { target.value = propBlend.value; target.dispatchEvent(new Event('change', { bubbles: true })); }
|
||||
});
|
||||
}
|
||||
propBody.append(propName, propKind, row('Opacity', propOpacity), row('Blend', propBlend));
|
||||
const properties = panel('properties', '', propBody);
|
||||
|
||||
// ---- Character / Paragraph -------------------------------------------
|
||||
const charBody = el('div', 'ge-panel-body');
|
||||
const charFont = document.createElement('select');
|
||||
FONTS.forEach(font => charFont.appendChild(new Option(font, font)));
|
||||
const charSize = document.createElement('input'); charSize.type = 'number'; charSize.min = '1'; charSize.max = '2000'; charSize.value = '48';
|
||||
const charLine = document.createElement('input'); charLine.type = 'number'; charLine.step = '0.05'; charLine.min = '0.5'; charLine.max = '5'; charLine.value = '1.2';
|
||||
const charTracking = document.createElement('input'); charTracking.type = 'number'; charTracking.min = '-100'; charTracking.max = '500'; charTracking.value = '0';
|
||||
const charColor = document.createElement('input'); charColor.type = 'color'; charColor.className = 'ge-color-picker'; charColor.value = '#ffffff';
|
||||
const charVertical = document.createElement('input'); charVertical.type = 'checkbox';
|
||||
const charSmallCaps = document.createElement('input'); charSmallCaps.type = 'checkbox';
|
||||
const charLigatures = document.createElement('input'); charLigatures.type = 'checkbox'; charLigatures.checked = true;
|
||||
const charWarp = document.createElement('select');
|
||||
WARP_STYLES.forEach(style => charWarp.appendChild(new Option(style, style)));
|
||||
const alignButtons = el('div', 'ge-text-align-row');
|
||||
const alignGroup = ['left', 'center', 'right'].map(value => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ge-text-toggle';
|
||||
button.dataset.charAlign = value;
|
||||
button.textContent = value === 'left' ? 'L' : value === 'center' ? 'C' : 'R';
|
||||
alignButtons.appendChild(button);
|
||||
return button;
|
||||
});
|
||||
const emitText = patch => { updateText(patch); };
|
||||
charFont.addEventListener('change', () => emitText({ fontFamily: charFont.value }));
|
||||
[['fontSize', charSize], ['lineHeight', charLine], ['letterSpacing', charTracking]].forEach(([key, input]) => {
|
||||
input.addEventListener('input', () => emitText({ [key]: Number(input.value) }));
|
||||
});
|
||||
charColor.addEventListener('input', () => emitText({ color: charColor.value }));
|
||||
charVertical.addEventListener('change', () => emitText({ vertical: charVertical.checked }));
|
||||
charSmallCaps.addEventListener('change', () => emitText({ smallCaps: charSmallCaps.checked }));
|
||||
charLigatures.addEventListener('change', () => emitText({ ligatures: charLigatures.checked }));
|
||||
charWarp.addEventListener('change', () => emitText({ warp: charWarp.value }));
|
||||
alignGroup.forEach(button => button.addEventListener('click', () => {
|
||||
emitText({ align: button.dataset.charAlign });
|
||||
refreshCharacter();
|
||||
}));
|
||||
charBody.append(
|
||||
row('Font', charFont), row('Size', charSize), row('Line height', charLine),
|
||||
row('Tracking', charTracking), row('Color', charColor), alignButtons,
|
||||
row('Vertical', charVertical), row('Small caps', charSmallCaps),
|
||||
row('Ligatures', charLigatures), row('Warp', charWarp),
|
||||
);
|
||||
const character = panel('character', 'CHARACTER / PARAGRAPH', charBody);
|
||||
|
||||
// ---- Paths ------------------------------------------------------------
|
||||
const pathBody = el('div', 'ge-panel-body ge-path-list');
|
||||
const paths = panel('paths', 'PATHS', pathBody);
|
||||
|
||||
// ---- Patterns ---------------------------------------------------------
|
||||
const patternGrid = el('div', 'ge-swatch-grid');
|
||||
PATTERN_KINDS.forEach(kind => {
|
||||
const url = patternDataUrl(kind);
|
||||
const swatch = el('button', 'ge-swatch');
|
||||
swatch.type = 'button';
|
||||
swatch.title = kind;
|
||||
swatch.style.backgroundImage = `url(${url})`;
|
||||
swatch.style.background = `url(${url})`;
|
||||
swatch.addEventListener('click', () => {
|
||||
renderPatterns(kind);
|
||||
onPattern(url, kind);
|
||||
});
|
||||
patternGrid.appendChild(swatch);
|
||||
});
|
||||
const patterns = panel('patterns', '', patternGrid);
|
||||
|
||||
// ---- Adjustments ------------------------------------------------------
|
||||
const adjGrid = el('div', 'ge-adjustment-grid');
|
||||
ADJUSTMENT_TYPES.forEach(([type, label]) => {
|
||||
const button = el('button', 'ge-btn ge-btn-sm ge-adj-btn', `${adjIcon(type)}<span class="ge-adj-label">${label}</span>`);
|
||||
button.type = 'button';
|
||||
button.dataset.adjustmentType = type;
|
||||
button.addEventListener('click', () => onAddAdjustment(type, button));
|
||||
adjGrid.appendChild(button);
|
||||
});
|
||||
const adjustments = panel('adjustments', '', adjGrid);
|
||||
|
||||
// ---- History ----------------------------------------------------------
|
||||
// Photoshop-style: the panel *is* the timeline — a scrollable list of
|
||||
// states you can click to jump to. No "open in window" button: the timeline
|
||||
// is already right here. The floating window is still reachable from the
|
||||
// topbar clock button (#ge-history-btn) if you want it undocked.
|
||||
const historyBody = el('div', 'ge-panel-body ge-history-body');
|
||||
const historyList = el('div', 'ge-history-list');
|
||||
historyList.id = 'ge-dock-history-list';
|
||||
historyBody.appendChild(historyList);
|
||||
const history = panel('history', 'HISTORY', historyBody);
|
||||
|
||||
function refreshProperties() {
|
||||
const layer = activeLayerOf(state);
|
||||
propName.querySelector('[data-prop="name"]').textContent = layer ? (layer.name || 'Layer') : '—';
|
||||
propKind.querySelector('[data-prop="kind"]').textContent = layer ? (layer.kind || 'raster') : '—';
|
||||
const opacity = layer && Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||
propOpacity.value = String(Math.round(opacity * 100));
|
||||
const target = document.getElementById('ge-layer-blend');
|
||||
if (target) propBlend.value = target.value;
|
||||
}
|
||||
|
||||
function refreshCharacter() {
|
||||
const layer = getTextLayer();
|
||||
if (!layer || !layer.text) return;
|
||||
const text = layer.text;
|
||||
charFont.value = text.fontFamily || 'Arial';
|
||||
charSize.value = String(text.fontSize ?? 48);
|
||||
charLine.value = String(text.lineHeight ?? 1.2);
|
||||
charTracking.value = String(text.letterSpacing ?? 0);
|
||||
charColor.value = text.color || '#ffffff';
|
||||
charVertical.checked = !!text.vertical;
|
||||
charSmallCaps.checked = !!text.smallCaps;
|
||||
charLigatures.checked = text.ligatures !== false;
|
||||
charWarp.value = text.warp || 'none';
|
||||
alignGroup.forEach(button => button.classList.toggle('active', button.dataset.charAlign === (text.align || 'left')));
|
||||
}
|
||||
|
||||
function renderPaths() {
|
||||
pathBody.innerHTML = '';
|
||||
const shapeLayers = (state.layers || []).filter(layer => layer.kind === 'shape' && layer.shape);
|
||||
if (!shapeLayers.length) {
|
||||
pathBody.appendChild(el('p', 'ge-panel-empty', 'No vector shapes yet.'));
|
||||
return;
|
||||
}
|
||||
shapeLayers.slice().reverse().forEach(layer => {
|
||||
const button = el('button', 'ge-channel-row', `<span class="ge-channel-name">${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}</span>`);
|
||||
button.type = 'button';
|
||||
button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); });
|
||||
pathBody.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPatterns(activeKind = null) {
|
||||
patternGrid.querySelectorAll('.ge-swatch').forEach((swatch, index) => {
|
||||
swatch.classList.toggle('active', PATTERN_KINDS[index] === activeKind);
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
refreshProperties();
|
||||
refreshCharacter();
|
||||
renderPaths();
|
||||
}
|
||||
|
||||
refresh();
|
||||
return { properties, character, paths, patterns, adjustments, history, refresh };
|
||||
}
|
||||
Reference in New Issue
Block a user