feat(05-04): replace single resize handle with 4-corner resize handles

- Add ResizeCorner type ('se' | 'sw' | 'ne' | 'nw')
- handleResizeStart now accepts a corner argument, stored in DraggingState
- Screen-space math: compute start rect (top/left/right/bottom px), apply delta
  per corner, enforce 40px/20px minimums, then convert back to PDF units on pointerup
- renderFields renders four 10x10 blue square handles at each corner with the
  correct CSS resize cursor (nw-resize, ne-resize, sw-resize, se-resize)
- Opposite corner is always the anchor; only the two edges adjacent to the dragged
  corner move

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chandler Copeland
2026-03-20 10:36:04 -06:00
parent 51a77ef7d2
commit 08719a6109

View File

@@ -90,8 +90,11 @@ function DraggableToken({ id }: { id: string }) {
);
}
type ResizeCorner = 'se' | 'sw' | 'ne' | 'nw';
interface DraggingState {
type: 'move' | 'resize';
corner?: ResizeCorner;
fieldId: string;
startPointerX: number;
startPointerY: number;
@@ -139,9 +142,10 @@ interface FieldPlacerProps {
pageInfo: PageInfo | null;
currentPage: number;
children: React.ReactNode;
readOnly?: boolean;
}
export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPlacerProps) {
export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = false }: FieldPlacerProps) {
const [fields, setFields] = useState<SignatureFieldData[]>([]);
const [isDraggingToken, setIsDraggingToken] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -216,6 +220,8 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
(event: DragEndEvent) => {
setIsDraggingToken(false);
if (readOnly) return;
const { over, active } = event;
if (!over || over.id !== 'pdf-drop-zone') return;
if (!pageInfo) return;
@@ -265,12 +271,13 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
setFields(next);
persistFields(docId, next);
},
[fields, pageInfo, currentPage, docId],
[fields, pageInfo, currentPage, docId, readOnly],
);
// --- Move / Resize pointer handlers (event delegation on DroppableZone) ---
const handleMoveStart = useCallback((e: React.PointerEvent<HTMLDivElement>, fieldId: string) => {
if (readOnly) return;
e.stopPropagation();
const field = fieldsRef.current.find((f) => f.id === fieldId);
if (!field) return;
@@ -286,15 +293,17 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
setActiveDragFieldId(fieldId);
setActiveDragType('move');
e.currentTarget.setPointerCapture(e.pointerId);
}, []);
}, [readOnly]);
const handleResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>, fieldId: string) => {
const handleResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>, fieldId: string, corner: ResizeCorner) => {
if (readOnly) return;
e.stopPropagation();
const field = fieldsRef.current.find((f) => f.id === fieldId);
if (!field) return;
draggingRef.current = {
type: 'resize',
corner,
fieldId,
startPointerX: e.clientX,
startPointerY: e.clientY,
@@ -306,7 +315,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
setActiveDragFieldId(fieldId);
setActiveDragType('resize');
e.currentTarget.setPointerCapture(e.pointerId);
}, []);
}, [readOnly]);
const handleZonePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const drag = draggingRef.current;
@@ -342,22 +351,71 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
fieldEl.style.top = `${top - heightPx + co.y}px`;
}
} else if (drag.type === 'resize') {
const newW = Math.max(40, (drag.startFieldW ?? 144) + pdfDx);
// Dragging down (positive screenDy) increases height in PDF units
const newH = Math.max(20, (drag.startFieldH ?? 36) - pdfDy);
const corner = drag.corner ?? 'se';
const startW = drag.startFieldW ?? 144;
const startH = drag.startFieldH ?? 36;
// Compute start rect in screen pixels
const startLeft = (drag.startFieldX / pi.originalWidth) * renderedW;
// pdfToScreenCoords returns top of bottom edge; field screen top = that - heightPx
const startBottomScreenY = renderedH - (drag.startFieldY / pi.originalHeight) * renderedH;
const startHeightPx = (startH / pi.originalHeight) * renderedH;
const startTopScreenY = startBottomScreenY - startHeightPx;
const startRightScreenX = startLeft + (startW / pi.originalWidth) * renderedW;
// For each corner, the OPPOSITE corner is the anchor.
// Compute new screen rect from pointer delta.
let newLeft = startLeft;
let newTop = startTopScreenY;
let newRight = startRightScreenX;
let newBottom = startBottomScreenY;
if (corner === 'se') {
// anchor: top-left; move: right and bottom
newRight = startRightScreenX + screenDx;
newBottom = startBottomScreenY + screenDy;
} else if (corner === 'sw') {
// anchor: top-right; move: left and bottom
newLeft = startLeft + screenDx;
newBottom = startBottomScreenY + screenDy;
} else if (corner === 'ne') {
// anchor: bottom-left; move: right and top
newRight = startRightScreenX + screenDx;
newTop = startTopScreenY + screenDy;
} else if (corner === 'nw') {
// anchor: bottom-right; move: left and top
newLeft = startLeft + screenDx;
newTop = startTopScreenY + screenDy;
}
// Enforce minimum screen dimensions before converting
const MIN_W_PX = 40;
const MIN_H_PX = 20;
if (newRight - newLeft < MIN_W_PX) {
if (corner === 'sw' || corner === 'nw') {
newLeft = newRight - MIN_W_PX;
} else {
newRight = newLeft + MIN_W_PX;
}
}
if (newBottom - newTop < MIN_H_PX) {
if (corner === 'ne' || corner === 'nw') {
newTop = newBottom - MIN_H_PX;
} else {
newBottom = newTop + MIN_H_PX;
}
}
const newWidthPx = newRight - newLeft;
const newHeightPx = newBottom - newTop;
const fieldEl = containerRef.current?.querySelector<HTMLElement>(`[data-field-id="${drag.fieldId}"]`);
const co = canvasOffsetRef.current;
if (fieldEl) {
const widthPx = (newW / pi.originalWidth) * renderedW;
const heightPx = (newH / pi.originalHeight) * renderedH;
// Recalculate top based on fixed bottom-left origin (field.y = PDF bottom edge stays fixed)
const co = canvasOffsetRef.current;
const { left, top } = pdfToScreenCoords(drag.startFieldX, drag.startFieldY, renderedW, renderedH, pi);
fieldEl.style.width = `${widthPx}px`;
fieldEl.style.height = `${heightPx}px`;
fieldEl.style.left = `${left + co.x}px`;
fieldEl.style.top = `${top - heightPx + co.y}px`;
fieldEl.style.left = `${newLeft + co.x}px`;
fieldEl.style.top = `${newTop + co.y}px`;
fieldEl.style.width = `${newWidthPx}px`;
fieldEl.style.height = `${newHeightPx}px`;
}
}
}, []);
@@ -396,11 +454,68 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
setFields(next);
persistFields(docId, next);
} else if (drag.type === 'resize') {
const newW = Math.max(40, (drag.startFieldW ?? 144) + pdfDx);
const newH = Math.max(20, (drag.startFieldH ?? 36) - pdfDy);
const corner = drag.corner ?? 'se';
const startW = drag.startFieldW ?? 144;
const startH = drag.startFieldH ?? 36;
// Compute start rect in screen pixels
const startLeft = (drag.startFieldX / pi.originalWidth) * renderedW;
const startBottomScreenY = renderedH - (drag.startFieldY / pi.originalHeight) * renderedH;
const startHeightPx = (startH / pi.originalHeight) * renderedH;
const startTopScreenY = startBottomScreenY - startHeightPx;
const startRightScreenX = startLeft + (startW / pi.originalWidth) * renderedW;
let newLeft = startLeft;
let newTop = startTopScreenY;
let newRight = startRightScreenX;
let newBottom = startBottomScreenY;
if (corner === 'se') {
newRight = startRightScreenX + screenDx;
newBottom = startBottomScreenY + screenDy;
} else if (corner === 'sw') {
newLeft = startLeft + screenDx;
newBottom = startBottomScreenY + screenDy;
} else if (corner === 'ne') {
newRight = startRightScreenX + screenDx;
newTop = startTopScreenY + screenDy;
} else if (corner === 'nw') {
newLeft = startLeft + screenDx;
newTop = startTopScreenY + screenDy;
}
const MIN_W_PX = 40;
const MIN_H_PX = 20;
if (newRight - newLeft < MIN_W_PX) {
if (corner === 'sw' || corner === 'nw') {
newLeft = newRight - MIN_W_PX;
} else {
newRight = newLeft + MIN_W_PX;
}
}
if (newBottom - newTop < MIN_H_PX) {
if (corner === 'ne' || corner === 'nw') {
newTop = newBottom - MIN_H_PX;
} else {
newBottom = newTop + MIN_H_PX;
}
}
const newWidthPx = newRight - newLeft;
const newHeightPx = newBottom - newTop;
// Convert new screen rect back to PDF units
// newLeft (screen) → pdfX
const newPdfX = (newLeft / renderedW) * pi.originalWidth;
// newBottom (screen) → pdfY (bottom edge in PDF space)
// screen Y from top → PDF Y from bottom: pdfY = ((renderedH - screenY) / renderedH) * originalHeight
const newPdfY = ((renderedH - newBottom) / renderedH) * pi.originalHeight;
const newPdfW = (newWidthPx / renderedW) * pi.originalWidth;
const newPdfH = (newHeightPx / renderedH) * pi.originalHeight;
const next = currentFields.map((f) => {
if (f.id !== drag.fieldId) return f;
return { ...f, width: newW, height: newH };
return { ...f, x: newPdfX, y: newPdfY, width: newPdfW, height: newPdfH };
});
setFields(next);
persistFields(docId, next);
@@ -424,6 +539,42 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
const isMoving = activeDragFieldId === field.id && activeDragType === 'move';
// Resize handle style factory
const resizeHandle = (corner: ResizeCorner) => {
const cursors: Record<ResizeCorner, string> = {
nw: 'nw-resize',
ne: 'ne-resize',
sw: 'sw-resize',
se: 'se-resize',
};
const pos: Record<ResizeCorner, React.CSSProperties> = {
nw: { top: 0, left: 0 },
ne: { top: 0, right: 0 },
sw: { bottom: 0, left: 0 },
se: { bottom: 0, right: 0 },
};
return (
<div
key={corner}
data-no-move
style={{
position: 'absolute',
...pos[corner],
width: 10,
height: 10,
cursor: cursors[corner],
background: '#2563eb',
zIndex: 12,
pointerEvents: readOnly ? 'none' : 'all',
}}
onPointerDown={(e) => {
e.stopPropagation();
handleResizeStart(e, field.id, corner);
}}
/>
);
};
return (
<div
key={field.id}
@@ -435,7 +586,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
width: widthPx,
height: heightPx,
border: '2px solid #2563eb',
background: 'rgba(37,99,235,0.1)',
background: readOnly ? 'rgba(37,99,235,0.05)' : 'rgba(37,99,235,0.1)',
borderRadius: '2px',
display: 'flex',
alignItems: 'center',
@@ -445,71 +596,63 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
color: '#2563eb',
// Raise above droppable overlay so the delete button is clickable
zIndex: 10,
pointerEvents: 'all',
pointerEvents: readOnly ? 'none' : 'all',
boxSizing: 'border-box',
cursor: isMoving ? 'grabbing' : 'grab',
cursor: readOnly ? 'default' : (isMoving ? 'grabbing' : 'grab'),
boxShadow: isMoving ? '0 4px 12px rgba(37,99,235,0.35)' : undefined,
userSelect: 'none',
touchAction: 'none',
opacity: readOnly ? 0.6 : 1,
}}
onPointerDown={(e) => {
if (readOnly) return;
// Only trigger move from the field body (not delete button or resize handle)
if ((e.target as HTMLElement).closest('[data-no-move]')) return;
handleMoveStart(e, field.id);
}}
>
<span style={{ pointerEvents: 'none' }}>Signature</span>
<button
data-no-move
onClick={(e) => {
// Stop dnd-kit from treating this click as a drag activation
e.stopPropagation();
const next = fields.filter((f) => f.id !== field.id);
setFields(next);
persistFields(docId, next);
}}
onPointerDown={(e) => {
// Prevent dnd-kit sensors and move handler from capturing this pointer event
e.stopPropagation();
}}
style={{
cursor: 'pointer',
background: 'none',
border: 'none',
color: '#ef4444',
fontWeight: 'bold',
padding: '0 2px',
fontSize: '12px',
lineHeight: 1,
// Ensure the button sits above any overlay that might capture events
position: 'relative',
zIndex: 11,
pointerEvents: 'all',
}}
aria-label="Remove field"
>
×
</button>
{/* Resize handle — bottom-right corner */}
<div
data-no-move
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: 10,
height: 10,
cursor: 'se-resize',
background: '#2563eb',
borderRadius: '2px 0 2px 0',
zIndex: 12,
pointerEvents: 'all',
}}
onPointerDown={(e) => {
e.stopPropagation();
handleResizeStart(e, field.id);
}}
/>
{!readOnly && (
<button
data-no-move
onClick={(e) => {
// Stop dnd-kit from treating this click as a drag activation
e.stopPropagation();
const next = fields.filter((f) => f.id !== field.id);
setFields(next);
persistFields(docId, next);
}}
onPointerDown={(e) => {
// Prevent dnd-kit sensors and move handler from capturing this pointer event
e.stopPropagation();
}}
style={{
cursor: 'pointer',
background: 'none',
border: 'none',
color: '#ef4444',
fontWeight: 'bold',
padding: '0 2px',
fontSize: '12px',
lineHeight: 1,
// Ensure the button sits above any overlay that might capture events
position: 'relative',
zIndex: 11,
pointerEvents: 'all',
}}
aria-label="Remove field"
>
×
</button>
)}
{!readOnly && (
<>
{resizeHandle('nw')}
{resizeHandle('ne')}
{resizeHandle('sw')}
{resizeHandle('se')}
</>
)}
</div>
);
});
@@ -521,22 +664,24 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
onDragStart={() => setIsDraggingToken(true)}
onDragEnd={handleDragEnd}
>
{/* Palette */}
<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: '#64748b', fontWeight: 500 }}>Field Palette:</span>
<DraggableToken id="signature-token" />
</div>
{/* Palette — hidden in read-only mode */}
{!readOnly && (
<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: '#64748b', fontWeight: 500 }}>Field Palette:</span>
<DraggableToken id="signature-token" />
</div>
)}
{/* Droppable PDF zone with field overlays */}
<DroppableZone