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