/**
* 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': '',
exposure: '',
develop: '',
'white-balance': '',
'hue-saturation': '',
vibrance: '',
'black-white': '',
'shadows-highlights': '',
levels: '',
curves: '',
'color-balance': '',
'selective-color': '',
'gradient-map': '',
invert: '',
posterize: '',
threshold: '',
'photo-filter': '',
'channel-mixer': '',
'color-lookup': '',
});
function adjIcon(type) {
const body = ADJUSTMENT_ICONS[type];
if (!body) return '';
return ``;
}
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', 'Layer—');
const propKind = el('div', 'ge-info-row', 'Type—');
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)}${label}`);
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', `${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}`);
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 };
}