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 { interface DraggingState {
type: 'move' | 'resize'; type: 'move' | 'resize';
corner?: ResizeCorner;
fieldId: string; fieldId: string;
startPointerX: number; startPointerX: number;
startPointerY: number; startPointerY: number;
@@ -139,9 +142,10 @@ interface FieldPlacerProps {
pageInfo: PageInfo | null; pageInfo: PageInfo | null;
currentPage: number; currentPage: number;
children: React.ReactNode; 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 [fields, setFields] = useState<SignatureFieldData[]>([]);
const [isDraggingToken, setIsDraggingToken] = useState(false); const [isDraggingToken, setIsDraggingToken] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -216,6 +220,8 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
(event: DragEndEvent) => { (event: DragEndEvent) => {
setIsDraggingToken(false); setIsDraggingToken(false);
if (readOnly) return;
const { over, active } = event; const { over, active } = event;
if (!over || over.id !== 'pdf-drop-zone') return; if (!over || over.id !== 'pdf-drop-zone') return;
if (!pageInfo) return; if (!pageInfo) return;
@@ -265,12 +271,13 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
setFields(next); setFields(next);
persistFields(docId, next); persistFields(docId, next);
}, },
[fields, pageInfo, currentPage, docId], [fields, pageInfo, currentPage, docId, readOnly],
); );
// --- Move / Resize pointer handlers (event delegation on DroppableZone) --- // --- Move / Resize pointer handlers (event delegation on DroppableZone) ---
const handleMoveStart = useCallback((e: React.PointerEvent<HTMLDivElement>, fieldId: string) => { const handleMoveStart = useCallback((e: React.PointerEvent<HTMLDivElement>, fieldId: string) => {
if (readOnly) return;
e.stopPropagation(); e.stopPropagation();
const field = fieldsRef.current.find((f) => f.id === fieldId); const field = fieldsRef.current.find((f) => f.id === fieldId);
if (!field) return; if (!field) return;
@@ -286,15 +293,17 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
setActiveDragFieldId(fieldId); setActiveDragFieldId(fieldId);
setActiveDragType('move'); setActiveDragType('move');
e.currentTarget.setPointerCapture(e.pointerId); 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(); e.stopPropagation();
const field = fieldsRef.current.find((f) => f.id === fieldId); const field = fieldsRef.current.find((f) => f.id === fieldId);
if (!field) return; if (!field) return;
draggingRef.current = { draggingRef.current = {
type: 'resize', type: 'resize',
corner,
fieldId, fieldId,
startPointerX: e.clientX, startPointerX: e.clientX,
startPointerY: e.clientY, startPointerY: e.clientY,
@@ -306,7 +315,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
setActiveDragFieldId(fieldId); setActiveDragFieldId(fieldId);
setActiveDragType('resize'); setActiveDragType('resize');
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
}, []); }, [readOnly]);
const handleZonePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => { const handleZonePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const drag = draggingRef.current; const drag = draggingRef.current;
@@ -342,22 +351,71 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
fieldEl.style.top = `${top - heightPx + co.y}px`; fieldEl.style.top = `${top - heightPx + co.y}px`;
} }
} else if (drag.type === 'resize') { } else if (drag.type === 'resize') {
const newW = Math.max(40, (drag.startFieldW ?? 144) + pdfDx); const corner = drag.corner ?? 'se';
// Dragging down (positive screenDy) increases height in PDF units const startW = drag.startFieldW ?? 144;
const newH = Math.max(20, (drag.startFieldH ?? 36) - pdfDy); 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 fieldEl = containerRef.current?.querySelector<HTMLElement>(`[data-field-id="${drag.fieldId}"]`);
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 co = canvasOffsetRef.current;
const { left, top } = pdfToScreenCoords(drag.startFieldX, drag.startFieldY, renderedW, renderedH, pi); if (fieldEl) {
fieldEl.style.width = `${widthPx}px`; fieldEl.style.left = `${newLeft + co.x}px`;
fieldEl.style.height = `${heightPx}px`; fieldEl.style.top = `${newTop + co.y}px`;
fieldEl.style.left = `${left + co.x}px`; fieldEl.style.width = `${newWidthPx}px`;
fieldEl.style.top = `${top - heightPx + co.y}px`; fieldEl.style.height = `${newHeightPx}px`;
} }
} }
}, []); }, []);
@@ -396,11 +454,68 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
setFields(next); setFields(next);
persistFields(docId, next); persistFields(docId, next);
} else if (drag.type === 'resize') { } else if (drag.type === 'resize') {
const newW = Math.max(40, (drag.startFieldW ?? 144) + pdfDx); const corner = drag.corner ?? 'se';
const newH = Math.max(20, (drag.startFieldH ?? 36) - pdfDy); 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) => { const next = currentFields.map((f) => {
if (f.id !== drag.fieldId) return 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); setFields(next);
persistFields(docId, next); persistFields(docId, next);
@@ -424,6 +539,42 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
const isMoving = activeDragFieldId === field.id && activeDragType === 'move'; 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 ( return (
<div <div
key={field.id} key={field.id}
@@ -435,7 +586,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
width: widthPx, width: widthPx,
height: heightPx, height: heightPx,
border: '2px solid #2563eb', 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', borderRadius: '2px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -445,20 +596,23 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
color: '#2563eb', color: '#2563eb',
// Raise above droppable overlay so the delete button is clickable // Raise above droppable overlay so the delete button is clickable
zIndex: 10, zIndex: 10,
pointerEvents: 'all', pointerEvents: readOnly ? 'none' : 'all',
boxSizing: 'border-box', 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, boxShadow: isMoving ? '0 4px 12px rgba(37,99,235,0.35)' : undefined,
userSelect: 'none', userSelect: 'none',
touchAction: 'none', touchAction: 'none',
opacity: readOnly ? 0.6 : 1,
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (readOnly) return;
// Only trigger move from the field body (not delete button or resize handle) // Only trigger move from the field body (not delete button or resize handle)
if ((e.target as HTMLElement).closest('[data-no-move]')) return; if ((e.target as HTMLElement).closest('[data-no-move]')) return;
handleMoveStart(e, field.id); handleMoveStart(e, field.id);
}} }}
> >
<span style={{ pointerEvents: 'none' }}>Signature</span> <span style={{ pointerEvents: 'none' }}>Signature</span>
{!readOnly && (
<button <button
data-no-move data-no-move
onClick={(e) => { onClick={(e) => {
@@ -490,26 +644,15 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
> >
× ×
</button> </button>
{/* Resize handle — bottom-right corner */} )}
<div {!readOnly && (
data-no-move <>
style={{ {resizeHandle('nw')}
position: 'absolute', {resizeHandle('ne')}
right: 0, {resizeHandle('sw')}
bottom: 0, {resizeHandle('se')}
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);
}}
/>
</div> </div>
); );
}); });
@@ -521,7 +664,8 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
onDragStart={() => setIsDraggingToken(true)} onDragStart={() => setIsDraggingToken(true)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{/* Palette */} {/* Palette — hidden in read-only mode */}
{!readOnly && (
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -537,6 +681,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
<span style={{ fontSize: '12px', color: '#64748b', fontWeight: 500 }}>Field Palette:</span> <span style={{ fontSize: '12px', color: '#64748b', fontWeight: 500 }}>Field Palette:</span>
<DraggableToken id="signature-token" /> <DraggableToken id="signature-token" />
</div> </div>
)}
{/* Droppable PDF zone with field overlays */} {/* Droppable PDF zone with field overlays */}
<DroppableZone <DroppableZone