Files
emaui/apps/backoffice/src/app/features/certificate-designer/hooks/useTemplateDraft.ts

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,
};
}