mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Seafarer and vessel registration were filtered out of the operations step by `requiresOperatorMode !== false`, so someone registering as a seafarer or vessel owner landed on an onboarding screen that did not describe them. Both now appear, grouped apart from the company modes: declaring "I am a seafarer" is a different kind of statement from "my company forwards freight". The designer's preview was gated on the Handlebars source alone, which a canvas layout does not have until the server compiles it on save -- so the button was dead for exactly the designs the canvas exists for. Editing looked broken rather than deliberately read-only: every seeded template is PUBLISHED, and a published design is immutable because certificates were issued from it. Says so, and offers the new-version action that is the way forward. Reviewing officers saw company, capital and staff tabs on seafarer certificate applications, because PRESENTATION is keyed by the generic licence keys and the fifty-odd rank-specific CoC/CoP keys fell through to the company default. Matched by prefix instead, so a certificate configured tomorrow gets the right presentation without a code change. Seaman book and BTC are wired to the newly seeded licence types and no longer marked "soon". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
9.1 KiB
TypeScript
289 lines
9.1 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Box, Paper, Text } from '@mantine/core';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
|
|
|
|
interface Props {
|
|
backgroundUrl: string;
|
|
logoUrl: string;
|
|
logoPlacement: TemplateLogoPlacement;
|
|
landscape: boolean;
|
|
placements: TemplateFieldPlacement[];
|
|
selectedId: string | null;
|
|
onSelect: (id: string | null) => void;
|
|
onChange: (placements: TemplateFieldPlacement[]) => void;
|
|
disabled: boolean;
|
|
}
|
|
|
|
/** A4 aspect ratio, the only page size the renderer is configured for. */
|
|
const A4_RATIO = 297 / 210;
|
|
|
|
const LOGO_CORNER_STYLE: Record<string, (offset: number) => React.CSSProperties> = {
|
|
TOP_LEFT: (o) => ({ top: `${o}%`, left: `${o}%` }),
|
|
TOP_CENTER: (o) => ({ top: `${o}%`, left: '50%', transform: 'translateX(-50%)' }),
|
|
TOP_RIGHT: (o) => ({ top: `${o}%`, right: `${o}%` }),
|
|
BOTTOM_LEFT: (o) => ({ bottom: `${o}%`, left: `${o}%` }),
|
|
BOTTOM_RIGHT: (o) => ({ bottom: `${o}%`, right: `${o}%` }),
|
|
};
|
|
|
|
type DragState = {
|
|
id: string;
|
|
mode: 'move' | 'resize';
|
|
pointerId: number;
|
|
/** Grab offset within the block, in percent, so it does not jump on grab. */
|
|
grabDxPct: number;
|
|
grabDyPct: number;
|
|
startWidthPct: number;
|
|
startXPct: number;
|
|
};
|
|
|
|
/**
|
|
* The visual certificate editor.
|
|
*
|
|
* Blocks are positioned in percentages of the page box, which is what lets the
|
|
* same layout render correctly in both orientations and at any zoom: the
|
|
* canvas scales with its container, and the compiled Handlebars uses the same
|
|
* percentages against the real A4 page.
|
|
*
|
|
* Pointer events rather than HTML5 drag-and-drop — the latter cannot report
|
|
* continuous positions during a drag, and offers no path to resizing.
|
|
*/
|
|
export function TemplateCanvas({
|
|
backgroundUrl,
|
|
logoUrl,
|
|
logoPlacement,
|
|
landscape,
|
|
placements,
|
|
selectedId,
|
|
onSelect,
|
|
onChange,
|
|
disabled,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const pageRef = useRef<HTMLDivElement>(null);
|
|
const [drag, setDrag] = useState<DragState | null>(null);
|
|
|
|
// Held in a ref as well: the pointer handlers are bound to the window for the
|
|
// life of a drag, and would otherwise close over a stale placements array.
|
|
const placementsRef = useRef(placements);
|
|
placementsRef.current = placements;
|
|
|
|
const pointToPct = useCallback((clientX: number, clientY: number) => {
|
|
const rect = pageRef.current?.getBoundingClientRect();
|
|
if (!rect || rect.width === 0 || rect.height === 0) return { xPct: 0, yPct: 0 };
|
|
return {
|
|
xPct: ((clientX - rect.left) / rect.width) * 100,
|
|
yPct: ((clientY - rect.top) / rect.height) * 100,
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!drag) return;
|
|
|
|
function handleMove(event: PointerEvent) {
|
|
if (!drag) return;
|
|
const { xPct, yPct } = pointToPct(event.clientX, event.clientY);
|
|
|
|
onChange(
|
|
placementsRef.current.map((block) => {
|
|
if (block.id !== drag.id) return block;
|
|
|
|
if (drag.mode === 'resize') {
|
|
// Width follows the pointer's distance from the block's left edge,
|
|
// clamped so a block can neither invert nor leave the page.
|
|
const width = Math.min(
|
|
100 - drag.startXPct,
|
|
Math.max(5, xPct - drag.startXPct),
|
|
);
|
|
return { ...block, widthPct: Number(width.toFixed(2)) };
|
|
}
|
|
|
|
const nextX = Math.min(
|
|
100 - block.widthPct,
|
|
Math.max(0, xPct - drag.grabDxPct),
|
|
);
|
|
const nextY = Math.min(99, Math.max(0, yPct - drag.grabDyPct));
|
|
return {
|
|
...block,
|
|
xPct: Number(nextX.toFixed(2)),
|
|
yPct: Number(nextY.toFixed(2)),
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
function handleUp() {
|
|
setDrag(null);
|
|
}
|
|
|
|
window.addEventListener('pointermove', handleMove);
|
|
window.addEventListener('pointerup', handleUp);
|
|
window.addEventListener('pointercancel', handleUp);
|
|
return () => {
|
|
window.removeEventListener('pointermove', handleMove);
|
|
window.removeEventListener('pointerup', handleUp);
|
|
window.removeEventListener('pointercancel', handleUp);
|
|
};
|
|
}, [drag, onChange, pointToPct]);
|
|
|
|
function startDrag(
|
|
event: React.PointerEvent,
|
|
block: TemplateFieldPlacement,
|
|
mode: 'move' | 'resize',
|
|
) {
|
|
if (disabled) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onSelect(block.id);
|
|
const { xPct, yPct } = pointToPct(event.clientX, event.clientY);
|
|
setDrag({
|
|
id: block.id,
|
|
mode,
|
|
pointerId: event.pointerId,
|
|
grabDxPct: xPct - block.xPct,
|
|
grabDyPct: yPct - block.yPct,
|
|
startWidthPct: block.widthPct,
|
|
startXPct: block.xPct,
|
|
});
|
|
}
|
|
|
|
/** Arrow keys nudge the selected block, for placement finer than a drag. */
|
|
function handleKeyDown(event: React.KeyboardEvent, block: TemplateFieldPlacement) {
|
|
if (disabled) return;
|
|
const step = event.shiftKey ? 5 : 0.5;
|
|
const deltas: Record<string, [number, number]> = {
|
|
ArrowLeft: [-step, 0],
|
|
ArrowRight: [step, 0],
|
|
ArrowUp: [0, -step],
|
|
ArrowDown: [0, step],
|
|
};
|
|
const delta = deltas[event.key];
|
|
if (!delta) return;
|
|
event.preventDefault();
|
|
onChange(
|
|
placements.map((candidate) =>
|
|
candidate.id === block.id
|
|
? {
|
|
...candidate,
|
|
xPct: Number(
|
|
Math.min(100 - candidate.widthPct, Math.max(0, candidate.xPct + delta[0])).toFixed(2),
|
|
),
|
|
yPct: Number(Math.min(99, Math.max(0, candidate.yPct + delta[1])).toFixed(2)),
|
|
}
|
|
: candidate,
|
|
),
|
|
);
|
|
}
|
|
|
|
const corner = logoPlacement.corner ?? 'TOP_LEFT';
|
|
const logoStyle = (LOGO_CORNER_STYLE[corner] ?? LOGO_CORNER_STYLE.TOP_LEFT)(
|
|
logoPlacement.offsetPct ?? 5,
|
|
);
|
|
|
|
return (
|
|
<Paper withBorder p="sm" radius="md">
|
|
<Text size="xs" c="dimmed" mb="xs">
|
|
{t(
|
|
'designer.canvasHint',
|
|
'Drag a block to move it, drag its right edge to resize, or use the arrow keys for fine placement.',
|
|
)}
|
|
</Text>
|
|
|
|
<Box
|
|
ref={pageRef}
|
|
onPointerDown={() => onSelect(null)}
|
|
style={{
|
|
position: 'relative',
|
|
width: '100%',
|
|
aspectRatio: landscape ? String(A4_RATIO) : String(1 / A4_RATIO),
|
|
background: '#ffffff',
|
|
border: '1px solid var(--mantine-color-gray-4)',
|
|
overflow: 'hidden',
|
|
// Blocks are positioned against this box, so it must not be static.
|
|
touchAction: 'none',
|
|
}}
|
|
>
|
|
{backgroundUrl && (
|
|
<img
|
|
src={backgroundUrl}
|
|
alt=""
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{logoUrl && (
|
|
<img
|
|
src={logoUrl}
|
|
alt=""
|
|
style={{
|
|
position: 'absolute',
|
|
width: `${logoPlacement.widthPct ?? 18}%`,
|
|
pointerEvents: 'none',
|
|
...logoStyle,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{placements.map((block) => {
|
|
const isSelected = block.id === selectedId;
|
|
return (
|
|
<div
|
|
key={block.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onPointerDown={(event) => startDrag(event, block, 'move')}
|
|
onKeyDown={(event) => handleKeyDown(event, block)}
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${block.xPct}%`,
|
|
top: `${block.yPct}%`,
|
|
width: `${block.widthPct}%`,
|
|
fontSize: block.fontSize ?? 14,
|
|
fontWeight: block.fontWeight ?? 'normal',
|
|
textAlign: block.align ?? 'left',
|
|
color: block.color ?? '#111111',
|
|
cursor: disabled ? 'default' : 'move',
|
|
outline: isSelected
|
|
? '2px solid var(--mantine-color-blue-6)'
|
|
: '1px dashed var(--mantine-color-gray-5)',
|
|
background: isSelected ? 'rgba(34,139,230,0.06)' : 'transparent',
|
|
boxSizing: 'border-box',
|
|
padding: 2,
|
|
lineHeight: 1.3,
|
|
wordWrap: 'break-word',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{block.variable ? `{{${block.variable}}}` : block.text || ' '}
|
|
|
|
{isSelected && !disabled && (
|
|
<span
|
|
onPointerDown={(event) => startDrag(event, block, 'resize')}
|
|
style={{
|
|
position: 'absolute',
|
|
right: -4,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
width: 8,
|
|
height: 18,
|
|
borderRadius: 2,
|
|
background: 'var(--mantine-color-blue-6)',
|
|
cursor: 'ew-resize',
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
}
|