mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
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<string | null>(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<TemplateLogoPlacement>({});
|
|
const [placements, setPlacements] = useState<TemplateFieldPlacement[]>([]);
|
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
|
const editorRef = useRef<HTMLTextAreaElement>(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 `<img>`) — 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,
|
|
};
|
|
}
|