import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type MutableRefObject, type SetStateAction, } from 'react'; import { DndContext, DragOverlay, PointerSensor, closestCorners, useDroppable, useDraggable, useSensor, useSensors, type DragEndEvent, type DragStartEvent, } from '@dnd-kit/core'; import { NavIcon } from '../../components/NavIcon'; import { WPMediaPickerField } from '../../components/shared/WPMediaPickerField'; import { type CertBlock, type CertBlockType, type CertificatePageFinish, type MergeFieldKey, MERGE_FIELD_KEYS, type PaletteItem as PaletteItemDef, getPaletteItems, CERT_PAGE_DECO_ORDER, getCertPageDecoLabels, CERT_PAGE_DECO_SHOW_FIRST, getCertificateThemeQuickPresets, type CertificateThemeQuickPreset, CERT_PAGE_SWATCHES, CERT_PAGE_PATTERN_ORDER, CERT_LAYOUT_VERSION, type BlockFrame, createBlock, getBlockFrame, getCertificatePageBackgroundStyle, getCertificatePageDecoGradient, getCertificatePagePatternLayer, getCertificatePagePhysicalSize, mergeFieldLabel, mergeFieldToken, nextDropFrame, substituteMergePreview, type CertLayoutFile, } from './certificateLayout'; import { cloneRegaliaSeed, cloneVertexSeed } from './certificateTemplateSeeds'; import { __, sprintf } from '../../lib/i18n'; const FIELD = 'mt-1.5 w-full rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 transition-colors focus:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-200/80 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:focus:border-slate-500 dark:focus:ring-slate-600/50'; const LABEL = 'block text-sm font-medium text-slate-700 dark:text-slate-200'; const HINT = 'mt-1.5 text-xs leading-relaxed text-slate-500 dark:text-slate-400'; const PANEL_CARD = 'bg-white dark:bg-slate-900'; /** Page / drawer titles */ const PANEL_TITLE = 'text-base font-semibold tracking-tight text-slate-900 dark:text-slate-50'; const PANEL_LEDE = 'mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400'; const PANEL_HEAD_RULE = 'border-b border-slate-200/90 pb-4 dark:border-slate-800'; /** Small caps section labels (inspector + theme) */ const SECTION_LABEL = 'text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400'; /** Inspector & grouped controls */ const INSPECTOR_CARD = 'rounded-lg border border-slate-200/90 bg-slate-50/40 p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900/40'; const COLOR_INPUT = 'mt-1.5 h-10 w-full max-w-[5.5rem] cursor-pointer rounded-md border border-slate-200 bg-white p-0.5 dark:border-slate-600 dark:bg-slate-800'; /** Visible, stable scrollbars on long template / theme / inspector panels (WebKit + Firefox). */ const CERT_BUILDER_SCROLL = 'overscroll-contain [scrollbar-gutter:stable] [-webkit-overflow-scrolling:touch] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-slate-300/80 [&::-webkit-scrollbar-track]:bg-transparent dark:[&::-webkit-scrollbar-thumb]:bg-slate-600/70'; const CANVAS_ID = 'sikshya-cert-canvas-root'; const CERT_LEFT_TAB_PANEL_ID = 'sikshya-cert-left-tab-panel'; function clampNumber(raw: unknown, min: number, max: number, fallback: number): number { const n = typeof raw === 'number' ? raw : Number(raw); if (!Number.isFinite(n)) { return fallback; } return Math.min(max, Math.max(min, n)); } function certTextAlign(v: unknown, fallback: 'left' | 'center' | 'right'): NonNullable { const s = String(v ?? '').toLowerCase(); if (s === 'left' || s === 'center' || s === 'right') { return s; } if (s === 'start') { return 'left'; } if (s === 'end') { return 'right'; } return fallback; } function certBlockLayerTitle(block: CertBlock): string { const p = block.props; switch (block.type) { case 'heading': return String(p.text || '').trim().slice(0, 56) || 'Heading'; case 'text': return String(p.text || '').trim().slice(0, 56) || 'Text'; case 'merge_field': { const fk = MERGE_FIELD_KEYS.includes(String(p.field) as MergeFieldKey) ? (String(p.field) as MergeFieldKey) : 'student_name'; return mergeFieldLabel(fk); } case 'spacer': return 'Spacer'; case 'divider': return 'Divider'; case 'image': return String(p.src || '').trim() ? __('Image', 'sikshya') : __('Image (no URL)', 'sikshya'); case 'qr': return 'QR'; default: return block.type; } } function certBlockTypeLabel(type: CertBlockType): string { const hit = getPaletteItems().find((x) => x.type === type); return hit?.label ?? type; } type Props = { layout: CertLayoutFile; onLayoutChange: Dispatch>; orientation: 'landscape' | 'portrait'; onOrientationChange: (v: 'landscape' | 'portrait') => void; pageSize: 'letter' | 'a4' | 'a5'; onPageSizeChange: (v: 'letter' | 'a4' | 'a5') => void; featuredPreview: string; onFeaturedPreviewChange: (url: string) => void; onFeaturedIdChange: (id: number) => void; pageFinish: CertificatePageFinish; onPageFinishChange: Dispatch>; /** Public template preview URL including `?hash=...` (template-level preview hash). */ templatePreviewUrl?: string; }; type LeftTabId = 'templates' | 'elements' | 'layers' | 'media' | 'backgrounds'; function paletteDraggableId(item: PaletteItemDef): string { const slug = `${item.type}-${item.label}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'); return `palette-${slug}`; } /** Map palette entries to a NavIcon for compact tile UI. */ function paletteItemIcon(item: PaletteItemDef): string { if (item.type === 'image') { return 'photoImage'; } if (item.type === 'qr') { // No dedicated QR icon in icons.json; use a neutral “preview” glyph. return 'iconPreview'; } if (item.type === 'heading') { return 'badge'; } if (item.type === 'text') { return 'bookOpen'; } if (item.type === 'divider') { return 'table'; } if (item.type === 'spacer') { return 'dotsVertical'; } if (item.type === 'merge_field') { const p = item.preset; const fieldKey = p && typeof p === 'object' && p !== null && 'field' in p ? String((p as { field?: string }).field || '') : ''; const l = (fieldKey || item.label).toLowerCase(); if (l.includes('student') || l.includes('name')) { return 'userCircle'; } if (l.includes('course')) { return 'course'; } if (l.includes('instructor') || l.includes('time') || l.includes('date') || l.includes('duration')) { return 'schedule'; } if (l.includes('verification') || l.includes('certificate') || l.includes('grade') || l.includes('point')) { return 'key'; } return 'tag'; } return 'puzzle'; } function PaletteItem(props: PaletteItemDef) { const { type, label, preset } = props; const id = paletteDraggableId(props); const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id, data: { source: 'palette', blockType: type, preset }, }); const style = transform ? { transform: `translate3d(${transform.x}px,${transform.y}px,0)` } : undefined; const icon = paletteItemIcon(props); return (
); } function BlockCanvasPreview({ block, templatePreviewUrl, }: { block: CertBlock; templatePreviewUrl: string; }) { let templatePreviewHash = ''; try { const u = new URL(templatePreviewUrl); templatePreviewHash = u.searchParams.get('hash') || ''; } catch { templatePreviewHash = ''; } const p = block.props; switch (block.type) { case 'heading': { const Tag = ['h1', 'h2', 'h3'].includes(String(p.tag)) ? (String(p.tag) as 'h1' | 'h2' | 'h3') : 'h1'; const fam = String(p.fontFamily || 'serif'); const fontFamily = fam === 'mono' ? 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' : fam === 'sans' ? 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Liberation Sans", sans-serif' : 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif'; return ( {String(p.text || '')} ); } case 'text': { const fam = String(p.fontFamily || 'sans'); const fontFamily = fam === 'mono' ? 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' : fam === 'serif' ? 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif' : 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Liberation Sans", sans-serif'; return (

{String(p.text || '')}

); } case 'merge_field': { const fam = String(p.fontFamily || 'sans'); const fontFamily = fam === 'mono' ? 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' : fam === 'serif' ? 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif' : 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Liberation Sans", sans-serif'; const field: MergeFieldKey = MERGE_FIELD_KEYS.includes(String(p.field) as MergeFieldKey) ? (String(p.field) as MergeFieldKey) : 'student_name'; const token = mergeFieldToken(field); const sample = field === 'verification_code' && templatePreviewHash ? templatePreviewHash : substituteMergePreview(token); return (
{sample}
); } case 'spacer': return
; case 'divider': { const t = Number(p.thickness) || 2; const c = (p.color as string) || '#cbd5e1'; return
; } case 'image': { const src = String(p.src || '').trim(); const w = Number(p.width) || 120; const align = (p.align as string) || 'center'; const jc = align === 'left' ? 'flex-start' : align === 'right' ? __('flex-end', 'sikshya') : __('center', 'sikshya'); if (!src) { return (
Image — set URL in settings
); } return (
); } case 'qr': { const imgSrc = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(templatePreviewUrl)}`; return (
); } default: return null; } } function PositionedCanvasBlock(props: { block: CertBlock; pageRef: MutableRefObject; selected: boolean; onSelect: () => void; onUpdate: (id: string, patch: Record) => void; onRemove: (id: string) => void; templatePreviewUrl: string; }) { const { block, pageRef, selected, onSelect, onUpdate, onRemove, templatePreviewUrl } = props; const f = getBlockFrame(block.props, block.type); const drag = useRef<{ kind: 'move' | ResizeHandle; startClientX: number; startClientY: number; start: BlockFrame; } | null>(null); type ResizeHandle = 'n' | 'e' | 's' | 'w' | 'nw' | 'ne' | 'sw' | 'se'; const MIN_W = 5; const MIN_H = 2; const clampFrameToPage = (fr: BlockFrame): BlockFrame => { const w = Math.max(MIN_W, Math.min(100, fr.w)); const h = Math.max(MIN_H, Math.min(100, fr.h)); const x = Math.max(0, Math.min(100 - w, fr.x)); const y = Math.max(0, Math.min(100 - h, fr.y)); return { ...fr, x, y, w, h }; }; const frameFromResize = (handle: ResizeHandle, start: BlockFrame, dx: number, dy: number): BlockFrame => { let x = start.x; let y = start.y; let w = start.w; let h = start.h; // Horizontal if (handle === 'e' || handle === 'ne' || handle === 'se') { w = start.w + dx; } if (handle === 'w' || handle === 'nw' || handle === 'sw') { x = start.x + dx; w = start.w - dx; } // Vertical if (handle === 's' || handle === 'sw' || handle === 'se') { h = start.h + dy; } if (handle === 'n' || handle === 'nw' || handle === 'ne') { y = start.y + dy; h = start.h - dy; } // Enforce mins by shifting the anchored edge back if (w < MIN_W) { const diff = MIN_W - w; w = MIN_W; if (handle === 'w' || handle === 'nw' || handle === 'sw') { x -= diff; } } if (h < MIN_H) { const diff = MIN_H - h; h = MIN_H; if (handle === 'n' || handle === 'nw' || handle === 'ne') { y -= diff; } } // Clamp to page bounds; keep opposite edge anchored where possible. if (x < 0) { if (handle === 'w' || handle === 'nw' || handle === 'sw') { w += x; // x is negative } x = 0; } if (y < 0) { if (handle === 'n' || handle === 'nw' || handle === 'ne') { h += y; } y = 0; } if (x + w > 100) { if (handle === 'e' || handle === 'ne' || handle === 'se') { w = 100 - x; } else { x = 100 - w; } } if (y + h > 100) { if (handle === 's' || handle === 'sw' || handle === 'se') { h = 100 - y; } else { y = 100 - h; } } return clampFrameToPage({ ...start, x, y, w, h }); }; const startMove = (e: React.PointerEvent) => { e.stopPropagation(); e.preventDefault(); onSelect(); const fr = getBlockFrame(block.props, block.type); drag.current = { kind: 'move' as const, startClientX: e.clientX, startClientY: e.clientY, start: { ...fr } }; const surface = e.currentTarget; try { surface.setPointerCapture(e.pointerId); } catch { /* ignore if unsupported */ } const move = (ev: PointerEvent) => { const s = drag.current; const page = pageRef.current; if (!s || s.kind !== 'move' || !page) { return; } const rect = page.getBoundingClientRect(); if (rect.width < 1 || rect.height < 1) { return; } const dxPct = ((ev.clientX - s.startClientX) / rect.width) * 100; const dyPct = ((ev.clientY - s.startClientY) / rect.height) * 100; const nx = Math.min(100 - s.start.w, Math.max(0, s.start.x + dxPct)); const ny = Math.min(100 - s.start.h, Math.max(0, s.start.y + dyPct)); onUpdate(block.id, { x: nx, y: ny }); }; const up = (ev: PointerEvent) => { try { surface.releasePointerCapture(ev.pointerId); } catch { /* */ } drag.current = null; window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); }; const startResize = (handleKind: ResizeHandle) => (e: React.PointerEvent) => { e.stopPropagation(); e.preventDefault(); onSelect(); const fr = getBlockFrame(block.props, block.type); drag.current = { kind: handleKind, startClientX: e.clientX, startClientY: e.clientY, start: { ...fr } }; const handle = e.currentTarget; try { handle.setPointerCapture(e.pointerId); } catch { /* ignore */ } const move = (ev: PointerEvent) => { const s = drag.current; const page = pageRef.current; if (!s || s.kind === 'move' || !page) { return; } const rect = page.getBoundingClientRect(); if (rect.width < 1 || rect.height < 1) { return; } const dxPct = ((ev.clientX - s.startClientX) / rect.width) * 100; const dyPct = ((ev.clientY - s.startClientY) / rect.height) * 100; const next = frameFromResize(s.kind as ResizeHandle, s.start, dxPct, dyPct); onUpdate(block.id, { x: next.x, y: next.y, w: next.w, h: next.h }); }; const up = (ev: PointerEvent) => { try { handle.releasePointerCapture(ev.pointerId); } catch { /* */ } drag.current = null; window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); }; const handleChrome = 'opacity-0 transition-opacity duration-150 group-hover/canvas-block:opacity-100'; const blockAria = `${certBlockTypeLabel(block.type)}: ${certBlockLayerTitle(block)}`; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(); } }} role="button" tabIndex={0} aria-pressed={selected} aria-label={blockAria} >
{/* Resize handles (corners + edges). */} {( [ // Corners: larger invisible hit areas. { k: 'nw' as const, cls: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize', size: 'h-6 w-6' }, { k: 'ne' as const, cls: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize', size: 'h-6 w-6' }, { k: 'sw' as const, cls: 'left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize', size: 'h-6 w-6' }, { k: 'se' as const, cls: 'right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize', size: 'h-6 w-6' }, // Edges: thin invisible strips along the border. { k: 'n' as const, cls: 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize', size: 'h-4 w-10' }, { k: 's' as const, cls: 'left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-ns-resize', size: 'h-4 w-10' }, { k: 'w' as const, cls: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize', size: 'h-10 w-4' }, { k: 'e' as const, cls: 'right-0 top-1/2 translate-x-1/2 -translate-y-1/2 cursor-ew-resize', size: 'h-10 w-4' }, ] as const ).map((h) => (
); } function CanvasDropArea(props: { children: React.ReactNode; hasBlocks: boolean; orientation: 'landscape' | 'portrait'; pageSize: 'letter' | 'a4' | 'a5'; featuredPreview: string; pageFinish: CertificatePageFinish; onBackgroundClick: () => void; pageRef: MutableRefObject; }) { const { children, hasBlocks, orientation, pageSize, featuredPreview, pageFinish, onBackgroundClick, pageRef } = props; const { setNodeRef, isOver } = useDroppable({ id: CANVAS_ID }); const pagePhysical = getCertificatePagePhysicalSize(orientation, pageSize); const pageBg = getCertificatePageBackgroundStyle({ pageColor: pageFinish.pageColor, pagePattern: pageFinish.pagePattern, pageDeco: pageFinish.pageDeco, featuredImageUrl: featuredPreview, }); const hasFeatured = String(featuredPreview || '').trim().length > 0; const useDarkScrim = !hasFeatured && (pageFinish.pageDeco === 'night' || pageFinish.pageDeco === 'dusk'); const setMergedRef = (el: HTMLDivElement | null) => { setNodeRef(el); pageRef.current = el; }; const viewportRef = useRef(null); const [viewportSize, setViewportSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 }); useLayoutEffect(() => { const el = viewportRef.current; if (!el) return; const update = () => { const r = el.getBoundingClientRect(); setViewportSize({ w: r.width, h: r.height }); }; update(); const ro = new ResizeObserver(() => update()); ro.observe(el); return () => ro.disconnect(); }, []); const parseLenPx = (v: string): number => { const s = String(v || '').trim().toLowerCase(); const n = parseFloat(s); if (!Number.isFinite(n)) return 0; if (s.endsWith('mm')) return (n / 25.4) * 96; if (s.endsWith('in')) return n * 96; if (s.endsWith('px')) return n; return n; }; // Fit the paper sheet into the visible canvas viewport so portrait/size changes feel stable. const paperPx = useMemo(() => { const w0 = parseLenPx(pagePhysical.width); const h0 = parseLenPx(pagePhysical.height); return { w0, h0 }; }, [pagePhysical.width, pagePhysical.height]); const fitted = useMemo(() => { const pad = 32; // viewport padding inside scroll area const vw = Math.max(0, viewportSize.w - pad); const vh = Math.max(0, viewportSize.h - pad); const w0 = paperPx.w0 || 1000; const h0 = paperPx.h0 || 700; const ar = w0 / h0; // Fill the available workspace (do not cap to physical size), // so portrait doesn't look tiny and the canvas stays visually balanced. const vwSafe = vw || 900; const vhSafe = vh || 600; let w = vwSafe; let h = w / ar; if (h > vhSafe) { h = vhSafe; w = h * ar; } w = Math.max(320, w); h = Math.max(320, h); // One rounded edge + height from exact paper ratio — avoids footer/frame drift vs exported HTML. const rw = Math.round(w); const rh = Math.max(320, Math.round((rw * paperPx.h0) / paperPx.w0)); return { w: rw, h: rh }; }, [paperPx.h0, paperPx.w0, viewportSize.h, viewportSize.w]); return (
{ if (e.target === e.currentTarget) { onBackgroundClick(); } }} > {children} {!hasBlocks ? (

{__('Start your certificate', 'sikshya')}

Open {__('Elements', 'sikshya')} to drag blocks here, {__('Layers', 'sikshya')} to reorder the stack, {__('Media', 'sikshya')} for images, and{' '} {__('Theme', 'sikshya')} for page finish.

) : null}
); } function Inspector(props: { selected: CertBlock | null; onUpdateBlock: (id: string, patch: Record) => void; onRemoveBlock: (id: string) => void; }) { const { selected, onUpdateBlock, onRemoveBlock } = props; if (!selected) { return (

{__('Nothing selected', 'sikshya')}

Click a block on the certificate to edit layout, text, merge fields, or images. Use the{' '} {__('Layers', 'sikshya')} tab to reorder the stack in the side panel. Press{' '} Esc {' '} to clear selection (when not typing in a field).

); } const p = selected.props; const patch = (o: Record) => onUpdateBlock(selected.id, o); const frame = getBlockFrame(p, selected.type); const mergeFieldValue: MergeFieldKey = MERGE_FIELD_KEYS.includes(String(p.field) as MergeFieldKey) ? (String(p.field) as MergeFieldKey) : 'student_name'; const inspectorTitle = selected.type === 'merge_field' ? mergeFieldLabel(mergeFieldValue) : selected.type === 'image' ? String(p.src || '').trim() ? 'Image' : 'Image' : getPaletteItems().find((x) => x.type === selected.type)?.label ?? selected.type; return (

{__('Selected', 'sikshya')}

{inspectorTitle}

{__('Position & size', 'sikshya')}

{__( 'Values are % of the page. Drag the block to move, drag the corner handle on the canvas to resize, or edit numbers here.', 'sikshya' )}

{ const v = Number(e.target.value); if (!Number.isFinite(v)) { return; } const w = getBlockFrame(p, selected.type).w; const nx = Math.min(100 - w, Math.max(0, v)); patch({ x: nx }); }} />
{ const v = Number(e.target.value); if (!Number.isFinite(v)) { return; } const h0 = getBlockFrame(p, selected.type).h; const ny = Math.min(100 - h0, Math.max(0, v)); patch({ y: ny }); }} />
{ const v = Number(e.target.value); if (!Number.isFinite(v)) { return; } const nw = Math.min(100, Math.max(5, v)); const cx = getBlockFrame(p, selected.type).x; patch({ w: nw, x: Math.min(cx, 100 - nw) }); }} />
{ const v = Number(e.target.value); if (!Number.isFinite(v)) { return; } const nh = Math.min(100, Math.max(2, v)); const cy = getBlockFrame(p, selected.type).y; patch({ h: nh, y: Math.min(cy, 100 - nh) }); }} />
patch({ z: Math.min(200, Math.max(0, Math.floor(Number(e.target.value) || 0))) })} />

{__('Higher numbers draw in front of lower ones.', 'sikshya')}

{selected.type === 'heading' ? (

{__('Heading', 'sikshya')}