feat(05-04): add move and resize to placed signature fields
- Native pointer events on field body for move drag (no dnd-kit conflict) - 10x10 resize handle in bottom-right corner with se-resize cursor - Event delegation via DroppableZone onPointerMove/onPointerUp - DOM mutation during drag for smooth performance; commit to state on pointerUp - data-no-move attribute prevents resize handle and delete button from triggering move - draggingRef tracks in-progress drag; activeDragFieldId drives grabbing cursor + box-shadow - Minimum field size enforced: 40x20 PDF units
This commit is contained in:
@@ -90,15 +90,30 @@ function DraggableToken({ id }: { id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface DraggingState {
|
||||
type: 'move' | 'resize';
|
||||
fieldId: string;
|
||||
startPointerX: number;
|
||||
startPointerY: number;
|
||||
startFieldX: number; // PDF units
|
||||
startFieldY: number;
|
||||
startFieldW?: number;
|
||||
startFieldH?: number;
|
||||
}
|
||||
|
||||
// Droppable zone wrapper for the PDF canvas
|
||||
function DroppableZone({
|
||||
id,
|
||||
containerRef,
|
||||
children,
|
||||
onZonePointerMove,
|
||||
onZonePointerUp,
|
||||
}: {
|
||||
id: string;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
children: React.ReactNode;
|
||||
onZonePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onZonePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
}) {
|
||||
const { setNodeRef } = useDroppable({ id });
|
||||
|
||||
@@ -111,6 +126,8 @@ function DroppableZone({
|
||||
}
|
||||
}}
|
||||
style={{ position: 'relative' }}
|
||||
onPointerMove={onZonePointerMove}
|
||||
onPointerUp={onZonePointerUp}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -133,6 +150,23 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
||||
// Offset of the <canvas> element relative to the DroppableZone wrapper div
|
||||
const [canvasOffset, setCanvasOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Track in-progress move/resize without causing re-renders
|
||||
const draggingRef = useRef<DraggingState | null>(null);
|
||||
// Track which field is actively being dragged/resized for cursor styling
|
||||
const [activeDragFieldId, setActiveDragFieldId] = useState<string | null>(null);
|
||||
const [activeDragType, setActiveDragType] = useState<'move' | 'resize' | null>(null);
|
||||
|
||||
// Refs for pageInfo and fields so pointer handlers always see current values
|
||||
const pageInfoRef = useRef<PageInfo | null>(null);
|
||||
const fieldsRef = useRef<SignatureFieldData[]>([]);
|
||||
const containerSizeRef = useRef<{ w: number; h: number } | null>(null);
|
||||
const canvasOffsetRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => { pageInfoRef.current = pageInfo; }, [pageInfo]);
|
||||
useEffect(() => { fieldsRef.current = fields; }, [fields]);
|
||||
useEffect(() => { containerSizeRef.current = containerSize; }, [containerSize]);
|
||||
useEffect(() => { canvasOffsetRef.current = canvasOffset; }, [canvasOffset]);
|
||||
|
||||
// Configure sensors: require a minimum drag distance so clicks on delete buttons
|
||||
// are not intercepted as drag starts
|
||||
const sensors = useSensors(
|
||||
@@ -234,6 +268,145 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
||||
[fields, pageInfo, currentPage, docId],
|
||||
);
|
||||
|
||||
// --- Move / Resize pointer handlers (event delegation on DroppableZone) ---
|
||||
|
||||
const handleMoveStart = useCallback((e: React.PointerEvent<HTMLDivElement>, fieldId: string) => {
|
||||
e.stopPropagation();
|
||||
const field = fieldsRef.current.find((f) => f.id === fieldId);
|
||||
if (!field) return;
|
||||
|
||||
draggingRef.current = {
|
||||
type: 'move',
|
||||
fieldId,
|
||||
startPointerX: e.clientX,
|
||||
startPointerY: e.clientY,
|
||||
startFieldX: field.x,
|
||||
startFieldY: field.y,
|
||||
};
|
||||
setActiveDragFieldId(fieldId);
|
||||
setActiveDragType('move');
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>, fieldId: string) => {
|
||||
e.stopPropagation();
|
||||
const field = fieldsRef.current.find((f) => f.id === fieldId);
|
||||
if (!field) return;
|
||||
|
||||
draggingRef.current = {
|
||||
type: 'resize',
|
||||
fieldId,
|
||||
startPointerX: e.clientX,
|
||||
startPointerY: e.clientY,
|
||||
startFieldX: field.x,
|
||||
startFieldY: field.y,
|
||||
startFieldW: field.width,
|
||||
startFieldH: field.height,
|
||||
};
|
||||
setActiveDragFieldId(fieldId);
|
||||
setActiveDragType('resize');
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const handleZonePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = draggingRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
const pi = pageInfoRef.current;
|
||||
const cs = containerSizeRef.current;
|
||||
if (!pi || !cs) return;
|
||||
|
||||
const renderedW = cs.w;
|
||||
const renderedH = cs.h;
|
||||
|
||||
// Screen delta from drag start
|
||||
const screenDx = e.clientX - drag.startPointerX;
|
||||
const screenDy = e.clientY - drag.startPointerY;
|
||||
|
||||
// Convert screen delta to PDF units
|
||||
const pdfDx = (screenDx / renderedW) * pi.originalWidth;
|
||||
// Screen Y down = PDF Y decrease (Y-axis inversion)
|
||||
const pdfDy = -(screenDy / renderedH) * pi.originalHeight;
|
||||
|
||||
if (drag.type === 'move') {
|
||||
const newX = drag.startFieldX + pdfDx;
|
||||
const newY = drag.startFieldY + pdfDy;
|
||||
|
||||
// Update DOM directly for smooth performance (commit to React state on pointerup)
|
||||
const co = canvasOffsetRef.current;
|
||||
const { left, top } = pdfToScreenCoords(newX, newY, renderedW, renderedH, pi);
|
||||
const fieldEl = containerRef.current?.querySelector<HTMLElement>(`[data-field-id="${drag.fieldId}"]`);
|
||||
if (fieldEl) {
|
||||
const heightPx = parseFloat(fieldEl.style.height);
|
||||
fieldEl.style.left = `${left + co.x}px`;
|
||||
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 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 { 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`;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZonePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = draggingRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
const pi = pageInfoRef.current;
|
||||
const cs = containerSizeRef.current;
|
||||
draggingRef.current = null;
|
||||
setActiveDragFieldId(null);
|
||||
setActiveDragType(null);
|
||||
|
||||
if (!pi || !cs) return;
|
||||
|
||||
const renderedW = cs.w;
|
||||
const renderedH = cs.h;
|
||||
|
||||
const screenDx = e.clientX - drag.startPointerX;
|
||||
const screenDy = e.clientY - drag.startPointerY;
|
||||
const pdfDx = (screenDx / renderedW) * pi.originalWidth;
|
||||
const pdfDy = -(screenDy / renderedH) * pi.originalHeight;
|
||||
|
||||
const currentFields = fieldsRef.current;
|
||||
|
||||
if (drag.type === 'move') {
|
||||
const next = currentFields.map((f) => {
|
||||
if (f.id !== drag.fieldId) return f;
|
||||
return {
|
||||
...f,
|
||||
x: drag.startFieldX + pdfDx,
|
||||
y: drag.startFieldY + pdfDy,
|
||||
};
|
||||
});
|
||||
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 next = currentFields.map((f) => {
|
||||
if (f.id !== drag.fieldId) return f;
|
||||
return { ...f, width: newW, height: newH };
|
||||
});
|
||||
setFields(next);
|
||||
persistFields(docId, next);
|
||||
}
|
||||
}, [docId]);
|
||||
|
||||
// Render placed fields for the current page
|
||||
// Uses pageInfo.width/height (not getBoundingClientRect) for consistent coordinate math
|
||||
const renderFields = () => {
|
||||
@@ -249,9 +422,12 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
||||
const widthPx = (field.width / pageInfo.originalWidth) * renderedW;
|
||||
const heightPx = (field.height / pageInfo.originalHeight) * renderedH;
|
||||
|
||||
const isMoving = activeDragFieldId === field.id && activeDragType === 'move';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
data-field-id={field.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: left + canvasOffset.x,
|
||||
@@ -271,10 +447,20 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
||||
zIndex: 10,
|
||||
pointerEvents: 'all',
|
||||
boxSizing: 'border-box',
|
||||
cursor: isMoving ? 'grabbing' : 'grab',
|
||||
boxShadow: isMoving ? '0 4px 12px rgba(37,99,235,0.35)' : undefined,
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// 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>Signature</span>
|
||||
<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();
|
||||
@@ -283,7 +469,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
||||
persistFields(docId, next);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// Prevent dnd-kit sensors from capturing this pointer event
|
||||
// Prevent dnd-kit sensors and move handler from capturing this pointer event
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
@@ -304,6 +490,26 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
||||
>
|
||||
×
|
||||
</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);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -333,7 +539,12 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
||||
</div>
|
||||
|
||||
{/* Droppable PDF zone with field overlays */}
|
||||
<DroppableZone id="pdf-drop-zone" containerRef={containerRef}>
|
||||
<DroppableZone
|
||||
id="pdf-drop-zone"
|
||||
containerRef={containerRef}
|
||||
onZonePointerMove={handleZonePointerMove}
|
||||
onZonePointerUp={handleZonePointerUp}
|
||||
>
|
||||
{children}
|
||||
{renderFields()}
|
||||
</DroppableZone>
|
||||
|
||||
Reference in New Issue
Block a user