import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { LicenseTemplate, TemplateFieldPlacement, TemplateLogoPlacement, } from '@ema-platform/api'; /** Stable ids for new blocks; `crypto.randomUUID` is not in every test env. */ function blockId(): string { return `blk_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; } /** * Editor state for the selected version. * * Selection defaults to the live design because that is the one staff usually * open, and the fields reset whenever the selection changes so an edit can * never leak from one version into another. */ export function useTemplateDraft(templates: LicenseTemplate[]) { const [selectedId, setSelectedId] = useState(null); const [source, setSource] = useState(''); const [name, setName] = useState(''); const [landscape, setLandscape] = useState(true); const [backgroundUrl, setBackgroundUrl] = useState(''); const [logoUrl, setLogoUrl] = useState(''); const [logoPlacement, setLogoPlacement] = useState({}); const [placements, setPlacements] = useState([]); const [selectedBlockId, setSelectedBlockId] = useState(null); const editorRef = useRef(null); const selected = useMemo( () => templates.find((tpl) => tpl.id === selectedId) ?? null, [templates, selectedId], ); useEffect(() => { if (!templates.length) { setSelectedId(null); return; } if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return; const published = templates.find((tpl) => tpl.status === 'PUBLISHED'); setSelectedId((published ?? templates[0]).id); }, [templates, selectedId]); useEffect(() => { if (!selected) return; setSource(selected.hbsSource); setName(selected.name); setLandscape(selected.pageOptions?.landscape ?? true); setBackgroundUrl(selected.backgroundUrl ?? ''); setLogoUrl(selected.logoUrl ?? ''); setLogoPlacement(selected.logoPlacement ?? {}); setPlacements(selected.fieldPlacements ?? []); setSelectedBlockId(null); }, [selected]); const isPublished = selected?.status === 'PUBLISHED'; // Compared as JSON because both are plain data the server round-trips; a // reference check would mark the draft dirty on every render. const placementsChanged = JSON.stringify(placements) !== JSON.stringify(selected?.fieldPlacements ?? []); const logoPlacementChanged = JSON.stringify(logoPlacement) !== JSON.stringify(selected?.logoPlacement ?? {}); const dirty = Boolean(selected) && (source !== selected?.hbsSource || name !== selected?.name || landscape !== (selected?.pageOptions?.landscape ?? true) || backgroundUrl !== (selected?.backgroundUrl ?? '') || logoUrl !== (selected?.logoUrl ?? '') || logoPlacementChanged || placementsChanged); /** True once the canvas owns the layout, which locks the raw editor. */ const usesCanvas = placements.length > 0; const selectedBlock = placements.find((block) => block.id === selectedBlockId) ?? null; /** * Drops a new block near the top-left, where it is immediately visible. * * An image block gets a square-ish default footprint instead of the text * defaults (fontSize/color/align mean nothing on an ``) — a seal or * signature dropped at 30% width and no explicit height would otherwise * stretch to whatever the image's own aspect ratio makes of that width, * which reads as broken until the author manually resizes it. */ const addBlock = useCallback( (variable: string | null, text?: string, kind: 'text' | 'image' = 'text') => { const block: TemplateFieldPlacement = kind === 'image' ? { id: blockId(), variable, type: 'image', xPct: 10, yPct: 10, widthPct: 15, } : { id: blockId(), variable, text, type: 'text', xPct: 10, yPct: 10, widthPct: 30, fontSize: 14, fontWeight: 'normal', fontStyle: 'normal', align: 'left', color: '#111111', }; setPlacements((prev) => [...prev, block]); setSelectedBlockId(block.id); }, [], ); const updateBlock = useCallback((next: TemplateFieldPlacement) => { setPlacements((prev) => prev.map((block) => (block.id === next.id ? next : block)), ); }, []); const deleteBlock = useCallback((id: string) => { setPlacements((prev) => prev.filter((block) => block.id !== id)); setSelectedBlockId((current) => (current === id ? null : current)); }, []); /** Inserts a placeholder where the caret is, rather than at the end. */ function insertVariable(key: string) { const el = editorRef.current; const token = `{{${key}}}`; if (!el) { setSource((prev) => prev + token); return; } const start = el.selectionStart ?? source.length; const end = el.selectionEnd ?? start; setSource(source.slice(0, start) + token + source.slice(end)); requestAnimationFrame(() => { el.focus(); el.setSelectionRange(start + token.length, start + token.length); }); } return { selected, selectedId, setSelectedId, source, setSource, name, setName, landscape, setLandscape, backgroundUrl, setBackgroundUrl, logoUrl, setLogoUrl, logoPlacement, setLogoPlacement, placements, setPlacements, selectedBlockId, setSelectedBlockId, selectedBlock, usesCanvas, addBlock, updateBlock, deleteBlock, editorRef, isPublished, dirty, insertVariable, }; }