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
|
// Droppable zone wrapper for the PDF canvas
|
||||||
function DroppableZone({
|
function DroppableZone({
|
||||||
id,
|
id,
|
||||||
containerRef,
|
containerRef,
|
||||||
children,
|
children,
|
||||||
|
onZonePointerMove,
|
||||||
|
onZonePointerUp,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
onZonePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||||
|
onZonePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef } = useDroppable({ id });
|
const { setNodeRef } = useDroppable({ id });
|
||||||
|
|
||||||
@@ -111,6 +126,8 @@ function DroppableZone({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
|
onPointerMove={onZonePointerMove}
|
||||||
|
onPointerUp={onZonePointerUp}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -133,6 +150,23 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
|||||||
// Offset of the <canvas> element relative to the DroppableZone wrapper div
|
// Offset of the <canvas> element relative to the DroppableZone wrapper div
|
||||||
const [canvasOffset, setCanvasOffset] = useState({ x: 0, y: 0 });
|
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
|
// Configure sensors: require a minimum drag distance so clicks on delete buttons
|
||||||
// are not intercepted as drag starts
|
// are not intercepted as drag starts
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -234,6 +268,145 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
|||||||
[fields, pageInfo, currentPage, docId],
|
[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
|
// Render placed fields for the current page
|
||||||
// Uses pageInfo.width/height (not getBoundingClientRect) for consistent coordinate math
|
// Uses pageInfo.width/height (not getBoundingClientRect) for consistent coordinate math
|
||||||
const renderFields = () => {
|
const renderFields = () => {
|
||||||
@@ -249,9 +422,12 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
|||||||
const widthPx = (field.width / pageInfo.originalWidth) * renderedW;
|
const widthPx = (field.width / pageInfo.originalWidth) * renderedW;
|
||||||
const heightPx = (field.height / pageInfo.originalHeight) * renderedH;
|
const heightPx = (field.height / pageInfo.originalHeight) * renderedH;
|
||||||
|
|
||||||
|
const isMoving = activeDragFieldId === field.id && activeDragType === 'move';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
key={field.id}
|
||||||
|
data-field-id={field.id}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: left + canvasOffset.x,
|
left: left + canvasOffset.x,
|
||||||
@@ -271,10 +447,20 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
pointerEvents: 'all',
|
pointerEvents: 'all',
|
||||||
boxSizing: 'border-box',
|
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
|
<button
|
||||||
|
data-no-move
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// Stop dnd-kit from treating this click as a drag activation
|
// Stop dnd-kit from treating this click as a drag activation
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -283,7 +469,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
|||||||
persistFields(docId, next);
|
persistFields(docId, next);
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
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();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
@@ -304,6 +490,26 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
|||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -333,7 +539,12 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Droppable PDF zone with field overlays */}
|
{/* 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}
|
{children}
|
||||||
{renderFields()}
|
{renderFields()}
|
||||||
</DroppableZone>
|
</DroppableZone>
|
||||||
|
|||||||
Reference in New Issue
Block a user