feat(16-03): active signer selector, per-signer field coloring, unassigned field red highlight
This commit is contained in:
@@ -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',
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
Reference in New Issue
Block a user