/** * 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 }; }