/** Local, reversible poster overlays. Never rewrites the source image or calls a model. */ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' import { createPortal } from 'react-dom' import { MAX_POSTER_LAYERS, MAX_POSTER_SIDE, isPosterDocument, type PosterDocument, type PosterLayer, type PosterTextLayer } from '../poster.ts' import type { CanvasAssetRef } from '../protocol.ts' import type { ImageGenApi } from './api.ts' import { tt } from './helpers.ts' import { loadPosterImage, paintPoster, renderPoster } from './poster-render.ts' import css from './poster-editor.module.css' interface Props { api: ImageGenApi asset: CanvasAssetRef recipe?: PosterDocument onSave: (poster: PosterDocument, dataUrl: string) => Promise onClose: () => void } const id = (): string => globalThis.crypto?.randomUUID?.() ?? `layer-${Date.now()}-${Math.random().toString(36).slice(2)}` const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)) const clone = (poster: PosterDocument): PosterDocument => JSON.parse(JSON.stringify(poster)) as PosterDocument export function PosterEditor({ api, asset, recipe, onSave, onClose }: Props): React.JSX.Element { const [poster, setPoster] = useState(() => { if (isPosterDocument(recipe)) return clone(recipe) const scale = Math.min(1, MAX_POSTER_SIDE / Math.max(asset.width, asset.height, 1)) return { version: 1, width: Math.max(1, Math.round(asset.width * scale)), height: Math.max(1, Math.round(asset.height * scale)), background: asset, layers: [] } }) const [selectedId, setSelectedId] = useState(null) const [error, setError] = useState('') const [busy, setBusy] = useState(false) const [ready, setReady] = useState(false) const [dirty, setDirty] = useState(false) const [confirmClose, setConfirmClose] = useState(false) const [, refreshHistory] = useState(0) const history = useRef([]) const future = useRef([]) const current = useRef(poster) current.current = poster const initial = useRef(JSON.stringify(poster)) const canvasRef = useRef(null) const stageRef = useRef(null) const dialogRef = useRef(null) const inputRef = useRef(null) const images = useRef(new Map()) const drag = useRef<{ pointerId: number; id: string; clientX: number; clientY: number; x: number; y: number; snapshot: PosterDocument } | null>(null) const mounted = useRef(true) const selected = poster.layers.find(layer => layer.id === selectedId) const changed = (next: PosterDocument): void => { setPoster(next) setDirty(JSON.stringify(next) !== initial.current) setConfirmClose(false) } const commit = (next: PosterDocument): void => { history.current = [...history.current.slice(-39), clone(current.current)] future.current = [] changed(next) } const patch = (layer: PosterLayer): void => commit({ ...poster, layers: poster.layers.map(item => item.id === layer.id ? layer : item) }) const undo = (): void => { const previous = history.current.pop() if (previous === undefined) return future.current.push(clone(current.current)); changed(previous); refreshHistory(value => value + 1) } const redo = (): void => { const next = future.current.pop() if (next === undefined) return history.current.push(clone(current.current)); changed(next); refreshHistory(value => value + 1) } const close = (): void => { if (busy) return if (dirty) { setConfirmClose(true); return } onClose() } useEffect(() => { mounted.current = true const previous = globalThis.document.activeElement as HTMLElement | null dialogRef.current?.focus() return () => { mounted.current = false; if (previous?.isConnected) previous.focus() } }, []) const assetKey = [poster.background, ...poster.layers.flatMap(layer => layer.type === 'image' ? [layer.asset] : [])].map(item => item.assetId).join('|') useEffect(() => { let disposed = false setReady(false); setError('') const assets = [poster.background, ...poster.layers.flatMap(layer => layer.type === 'image' ? [layer.asset] : [])] void Promise.all([...new Map(assets.map(item => [item.assetId, item])).values()].map(async item => { if (!images.current.has(item.assetId)) images.current.set(item.assetId, await loadPosterImage(item)) })).then(async () => { await globalThis.document.fonts?.ready if (!disposed) setReady(true) }).catch(caught => { if (!disposed) setError(String(caught instanceof Error ? caught.message : caught)) }) return () => { disposed = true } }, [assetKey]) useEffect(() => { if (!ready || canvasRef.current === null) return try { paintPoster(canvasRef.current, poster, images.current) } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) } }, [poster, ready]) const addText = (): void => { if (poster.layers.length >= MAX_POSTER_LAYERS) return const fontSize = clamp(Math.round(poster.width * 0.055), 8, 512) const layer: PosterTextLayer = { id: id(), type: 'text', text: tt('poster.sampleText'), x: Math.round(poster.width * 0.08), y: Math.round(poster.height * 0.08), width: Math.max(1, Math.round(poster.width * 0.84)), fontSize, fontFamily: 'sans-serif', color: '#ffffff', bold: true, align: 'left' } commit({ ...poster, layers: [...poster.layers, layer] }); setSelectedId(layer.id) } const uploadLogo = async (file: File): Promise => { if (poster.layers.length >= MAX_POSTER_LAYERS || busy) return setBusy(true); setError('') try { if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) throw new Error(tt('poster.logoLimit')) const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error(tt('poster.logoFailed'))); reader.readAsDataURL(file) }) const size = await new Promise<{ width: number; height: number }>((resolve, reject) => { const image = new Image(); image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); image.onerror = () => reject(new Error(tt('poster.logoFailed'))); image.src = dataUrl }) if (size.width <= 0 || size.height <= 0 || size.width * size.height > 40_000_000) throw new Error(tt('poster.logoLimit')) const uploaded = await api.canvasUpload(dataUrl, size.width, size.height, { origin: 'upload', originId: file.name }) if (!mounted.current) return const latest = current.current const ratio = Math.min(latest.width * 0.25 / size.width, latest.height * 0.25 / size.height) const layer: PosterLayer = { id: id(), type: 'image', asset: uploaded, x: Math.round(latest.width * 0.05), y: Math.round(latest.height * 0.05), width: Math.max(1, Math.round(size.width * ratio)), height: Math.max(1, Math.round(size.height * ratio)) } commit({ ...latest, layers: [...latest.layers, layer] }); setSelectedId(layer.id) } catch (caught) { if (mounted.current) setError(caught instanceof Error ? caught.message : String(caught)) } finally { if (mounted.current) setBusy(false) } } const output = async (save: boolean): Promise => { if (busy || !ready) return setBusy(true); setError('') try { const snapshot = clone(current.current) const canvas = await renderPoster(snapshot) if (save) { await onSave(snapshot, canvas.toDataURL('image/png')) if (mounted.current) onClose() } else { const blob = await new Promise((resolve, reject) => canvas.toBlob(value => value === null ? reject(new Error(tt('poster.exportFailed'))) : resolve(value), 'image/png')) const url = URL.createObjectURL(blob) const anchor = globalThis.document.createElement('a') anchor.href = url; anchor.download = `poster-${Date.now()}.png` globalThis.document.body.appendChild(anchor); anchor.click(); anchor.remove() window.setTimeout(() => URL.revokeObjectURL(url), 1000) } } catch (caught) { if (mounted.current) setError(caught instanceof Error ? caught.message : String(caught)) } finally { if (mounted.current) setBusy(false) } } const startDrag = (event: ReactPointerEvent, layer: PosterLayer): void => { if (event.button !== 0 || busy) return event.preventDefault(); event.stopPropagation(); setSelectedId(layer.id) event.currentTarget.setPointerCapture?.(event.pointerId) drag.current = { pointerId: event.pointerId, id: layer.id, clientX: event.clientX, clientY: event.clientY, x: layer.x, y: layer.y, snapshot: clone(poster) } } const finishDrag = (): void => { if (drag.current === null) return if (JSON.stringify(current.current) !== JSON.stringify(drag.current.snapshot)) { history.current = [...history.current.slice(-39), drag.current.snapshot]; future.current = []; refreshHistory(value => value + 1) } drag.current = null } const move = (event: ReactPointerEvent): void => { const state = drag.current if (state === null || state.pointerId !== event.pointerId || stageRef.current === null) return const rect = stageRef.current.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return const x = clamp(Math.round(state.x + (event.clientX - state.clientX) * poster.width / rect.width), 0, poster.width - 1) const y = clamp(Math.round(state.y + (event.clientY - state.clientY) * poster.height / rect.height), 0, poster.height - 1) changed({ ...current.current, layers: current.current.layers.map(layer => layer.id === state.id ? { ...layer, x, y } : layer) }) } const numberField = (label: string, value: number, min: number, max: number, change: (value: number) => void): React.JSX.Element => return createPortal(
event.stopPropagation()} onWheel={event => event.stopPropagation()} onPaste={event => event.stopPropagation()} onKeyUp={event => event.stopPropagation()} onKeyDown={event => { event.stopPropagation() if (event.key === 'Escape') { event.preventDefault(); close() } if (event.key === 'Tab') { const items = [...(dialogRef.current?.querySelectorAll('button:not(:disabled), input:not(:disabled):not([type=hidden]), textarea:not(:disabled), select:not(:disabled), [tabindex="0"]') ?? [])] const first = items[0], last = items[items.length - 1], active = globalThis.document.activeElement if (event.shiftKey && (active === first || active === dialogRef.current)) { event.preventDefault(); last?.focus() } else if (!event.shiftKey && (active === last || active === dialogRef.current)) { event.preventDefault(); first?.focus() } } if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z' && !(event.target as HTMLElement).matches('input, textarea')) { event.preventDefault(); if (!busy) { if (event.shiftKey) redo(); else undo() } } }}>
{tt('poster.title')}{tt('poster.hint')}
{poster.width} × {poster.height} · {poster.layers.length}/{MAX_POSTER_LAYERS} { const file = event.target.files?.[0]; event.target.value = ''; if (file) void uploadLogo(file) }} />
{poster.layers.map(layer =>
{!ready ?

{tt('poster.loading')}

: null}