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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user