feat(16-03): active signer selector, per-signer field coloring, unassigned field red highlight

This commit is contained in:
Chandler Copeland
2026-04-03 16:25:39 -06:00
parent 4e9d373e1d
commit d768fc6aae

View File

@@ -171,6 +171,7 @@ interface FieldPlacerProps {
export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = false, onFieldsChanged, selectedFieldId, textFillData, onFieldSelect, onFieldValueChange, aiPlacementKey = 0, signers = [], unassignedFieldIds = new Set() }: FieldPlacerProps) { export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = false, onFieldsChanged, selectedFieldId, textFillData, onFieldSelect, onFieldValueChange, aiPlacementKey = 0, signers = [], unassignedFieldIds = new Set() }: FieldPlacerProps) {
const [fields, setFields] = useState<SignatureFieldData[]>([]); const [fields, setFields] = useState<SignatureFieldData[]>([]);
const [isDraggingToken, setIsDraggingToken] = useState<string | null>(null); const [isDraggingToken, setIsDraggingToken] = useState<string | null>(null);
const [activeSignerEmail, setActiveSignerEmail] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
// Track rendered canvas dimensions in state so renderFields re-runs when they change // Track rendered canvas dimensions in state so renderFields re-runs when they change
const [containerSize, setContainerSize] = useState<{ w: number; h: number } | null>(null); const [containerSize, setContainerSize] = useState<{ w: number; h: number } | null>(null);
@@ -194,6 +195,18 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
useEffect(() => { containerSizeRef.current = containerSize; }, [containerSize]); useEffect(() => { containerSizeRef.current = containerSize; }, [containerSize]);
useEffect(() => { canvasOffsetRef.current = canvasOffset; }, [canvasOffset]); useEffect(() => { canvasOffsetRef.current = canvasOffset; }, [canvasOffset]);
// Default active signer to first signer on load; keep current if still valid (D-08)
useEffect(() => {
if (signers && signers.length > 0) {
setActiveSignerEmail(prev => {
if (prev && signers.some(s => s.email === prev)) return prev;
return signers[0].email;
});
} else {
setActiveSignerEmail(null);
}
}, [signers]);
// Configure sensors: require a minimum drag distance so clicks on delete buttons // Configure sensors: require a minimum drag distance so clicks on delete buttons
// are not intercepted as drag starts // are not intercepted as drag starts
const sensors = useSensors( const sensors = useSensors(
@@ -301,6 +314,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
width: fieldW, width: fieldW,
height: fieldH, height: fieldH,
type: droppedType, type: droppedType,
...(activeSignerEmail ? { signerEmail: activeSignerEmail } : {}),
}; };
const next = [...fields, newField]; const next = [...fields, newField];
@@ -308,7 +322,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
persistFields(docId, next); persistFields(docId, next);
onFieldsChanged?.(); onFieldsChanged?.();
}, },
[fields, pageInfo, currentPage, docId, readOnly, onFieldsChanged], [fields, pageInfo, currentPage, docId, readOnly, onFieldsChanged, activeSignerEmail],
); );
// --- Move / Resize pointer handlers (event delegation on DroppableZone) --- // --- Move / Resize pointer handlers (event delegation on DroppableZone) ---
@@ -583,7 +597,18 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
// Per-type color and label // Per-type color and label
const fieldType = getFieldType(field); const fieldType = getFieldType(field);
const tokenMeta = PALETTE_TOKENS.find((t) => t.id === fieldType); const tokenMeta = PALETTE_TOKENS.find((t) => t.id === fieldType);
const fieldColor = tokenMeta?.color ?? '#2563eb';
// Color override: signer color when signerEmail is set (D-06), else type color (D-07)
let fieldColor = tokenMeta?.color ?? '#2563eb';
if (field.signerEmail && signers) {
const matchedSigner = signers.find(s => s.email === field.signerEmail);
if (matchedSigner) {
fieldColor = matchedSigner.color;
}
}
// Validation overlay: unassigned field highlight (D-09/D-10)
const isUnassigned = unassignedFieldIds?.has(field.id) ?? false;
const fieldLabel = tokenMeta?.label ?? 'Signature'; const fieldLabel = tokenMeta?.label ?? 'Signature';
// Resize handle style factory // Resize handle style factory
@@ -632,8 +657,8 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
top: top - heightPx + canvasOffset.y, // top is y of bottom-left corner; shift up by height top: top - heightPx + canvasOffset.y, // top is y of bottom-left corner; shift up by height
width: widthPx, width: widthPx,
height: heightPx, height: heightPx,
border: `2px solid ${fieldColor}`, border: isUnassigned ? '2px solid #ef4444' : `2px solid ${fieldColor}`,
background: readOnly ? `${fieldColor}0d` : `${fieldColor}1a`, background: isUnassigned ? '#ef444414' : (readOnly ? `${fieldColor}0d` : `${fieldColor}1a`),
borderRadius: '2px', borderRadius: '2px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -754,6 +779,53 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
onDragStart={(event) => setIsDraggingToken(event.active.id as string)} onDragStart={(event) => setIsDraggingToken(event.active.id as string)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{/* Active signer selector — only when signers configured and not read-only (D-05) */}
{!readOnly && signers && signers.length > 0 && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px',
padding: '8px 12px',
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: '6px',
}}>
<span style={{ fontSize: '12px', color: '#6b7280', fontWeight: 500, whiteSpace: 'nowrap' }}>
Active signer:
</span>
<select
value={activeSignerEmail ?? ''}
onChange={(e) => setActiveSignerEmail(e.target.value)}
style={{
flex: 1,
height: '32px',
border: '1px solid #D1D5DB',
borderRadius: '0.375rem',
fontSize: '0.875rem',
padding: '0 8px',
background: 'white',
}}
>
{signers.map(s => (
<option key={s.email} value={s.email}>
{s.email}
</option>
))}
</select>
{/* Color indicator dot next to the dropdown */}
{(() => {
const activeSigner = signers.find(s => s.email === activeSignerEmail);
return activeSigner ? (
<span style={{
width: '8px', height: '8px', borderRadius: '50%',
backgroundColor: activeSigner.color, flexShrink: 0,
}} />
) : null;
})()}
</div>
)}
{/* Palette — hidden in read-only mode */} {/* Palette — hidden in read-only mode */}
{!readOnly && ( {!readOnly && (
<div <div
@@ -797,20 +869,25 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
{isDraggingToken ? (() => { {isDraggingToken ? (() => {
const tokenMeta = PALETTE_TOKENS.find((t) => t.id === isDraggingToken); const tokenMeta = PALETTE_TOKENS.find((t) => t.id === isDraggingToken);
const label = tokenMeta?.label ?? 'Field'; const label = tokenMeta?.label ?? 'Field';
const color = tokenMeta?.color ?? '#2563eb'; // Ghost uses active signer color if one is selected, else type color (D-06)
let ghostColor = tokenMeta?.color ?? '#2563eb';
if (activeSignerEmail && signers) {
const activeSigner = signers.find(s => s.email === activeSignerEmail);
if (activeSigner) ghostColor = activeSigner.color;
}
const isCheckbox = isDraggingToken === 'checkbox'; const isCheckbox = isDraggingToken === 'checkbox';
return ( return (
<div style={{ <div style={{
width: isCheckbox ? 24 : 144, width: isCheckbox ? 24 : 144,
height: isCheckbox ? 24 : 36, height: isCheckbox ? 24 : 36,
border: `2px solid ${color}`, border: `2px solid ${ghostColor}`,
background: `${color}26`, background: `${ghostColor}26`,
borderRadius: '2px', borderRadius: '2px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '11px', fontSize: '11px',
color, color: ghostColor,
fontWeight: 600, fontWeight: 600,
pointerEvents: 'none', pointerEvents: 'none',
}}> }}>