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:
pewdiepie-archdaemon
2026-10-15 11:57:35 +00:00
co-authored by Claude Opus 5.5
parent c74f475679
commit 5fac693c1c
290 changed files with 34358 additions and 766 deletions
+446
View File
@@ -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 };
}
+298
View File
@@ -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 };
}