feat(05-02): install dnd-kit and create FieldPlacer component
- Install @dnd-kit/core@^6.3.1 and @dnd-kit/utilities@^3.2.2 - Create FieldPlacer.tsx with DndContext, draggable palette token, droppable PDF zone - Implements Y-flip coordinate conversion (screenToPdfCoords / pdfToScreenCoords) - Fetches existing fields from GET /api/documents/[id]/fields on mount - Persists fields via PUT /api/documents/[id]/fields on every add/remove - Renders placed fields as absolute-positioned blue-bordered overlays with remove button - Default field size: 144x36 PDF points (2in x 0.5in at 72 DPI)
This commit is contained in:
41
teressa-copeland-homes/package-lock.json
generated
41
teressa-copeland-homes/package-lock.json
generated
@@ -9,6 +9,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.6.3",
|
"@cantoo/pdf-lib": "^2.6.3",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@vercel/blob": "^2.3.1",
|
"@vercel/blob": "^2.3.1",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -597,6 +599,45 @@
|
|||||||
"tslib": ">=2"
|
"tslib": ">=2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@drizzle-team/brocli": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.6.3",
|
"@cantoo/pdf-lib": "^2.6.3",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@vercel/blob": "^2.3.1",
|
"@vercel/blob": "^2.3.1",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
DragOverlay,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import type { SignatureFieldData } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
interface PageInfo {
|
||||||
|
originalWidth: number;
|
||||||
|
originalHeight: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen (DOM) → PDF user space. Y-axis flip required.
|
||||||
|
// DOM: Y=0 at top, increases downward
|
||||||
|
// PDF: Y=0 at bottom, increases upward
|
||||||
|
function screenToPdfCoords(
|
||||||
|
screenX: number,
|
||||||
|
screenY: number,
|
||||||
|
containerRect: DOMRect,
|
||||||
|
pageInfo: PageInfo,
|
||||||
|
) {
|
||||||
|
// Use containerRect dimensions (current rendered size) not stale pageInfo
|
||||||
|
const renderedW = containerRect.width;
|
||||||
|
const renderedH = containerRect.height;
|
||||||
|
const pdfX = (screenX / renderedW) * pageInfo.originalWidth;
|
||||||
|
const pdfY = ((renderedH - screenY) / renderedH) * pageInfo.originalHeight;
|
||||||
|
return { x: pdfX, y: pdfY };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF user space → screen (for rendering stored fields)
|
||||||
|
function pdfToScreenCoords(
|
||||||
|
pdfX: number,
|
||||||
|
pdfY: number,
|
||||||
|
containerRect: DOMRect,
|
||||||
|
pageInfo: PageInfo,
|
||||||
|
) {
|
||||||
|
const renderedW = containerRect.width;
|
||||||
|
const renderedH = containerRect.height;
|
||||||
|
const left = (pdfX / pageInfo.originalWidth) * renderedW;
|
||||||
|
// top is measured from DOM top; pdfY is from PDF bottom — reverse flip
|
||||||
|
const top = renderedH - (pdfY / pageInfo.originalHeight) * renderedH;
|
||||||
|
return { left, top };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistFields(docId: string, fields: SignatureFieldData[]) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/documents/${docId}/fields`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(fields),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to persist fields:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draggable token in the palette
|
||||||
|
function DraggableToken({ id }: { id: string }) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id });
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
cursor: 'grab',
|
||||||
|
padding: '6px 12px',
|
||||||
|
border: '2px dashed #2563eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'rgba(37,99,235,0.08)',
|
||||||
|
color: '#2563eb',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||||
|
+ Signature Field
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Droppable zone wrapper for the PDF canvas
|
||||||
|
function DroppableZone({
|
||||||
|
id,
|
||||||
|
containerRef,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { setNodeRef } = useDroppable({ id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
setNodeRef(node);
|
||||||
|
if (containerRef && 'current' in containerRef) {
|
||||||
|
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldPlacerProps {
|
||||||
|
docId: string;
|
||||||
|
pageInfo: PageInfo | null;
|
||||||
|
currentPage: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPlacerProps) {
|
||||||
|
const [fields, setFields] = useState<SignatureFieldData[]>([]);
|
||||||
|
const [isDraggingToken, setIsDraggingToken] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Load existing fields from server on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadFields() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${docId}/fields`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setFields(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load fields:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadFields();
|
||||||
|
}, [docId]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
setIsDraggingToken(false);
|
||||||
|
|
||||||
|
const { active, over, activatorEvent, delta } = event;
|
||||||
|
|
||||||
|
// Only process if dropped onto the PDF zone
|
||||||
|
if (!over || over.id !== 'pdf-drop-zone') return;
|
||||||
|
if (!pageInfo) return;
|
||||||
|
|
||||||
|
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (!containerRect) return;
|
||||||
|
|
||||||
|
// Compute the final screen position relative to the container
|
||||||
|
// activatorEvent is the initial MouseEvent/TouchEvent at drag start
|
||||||
|
// delta is the displacement from drag start to drop
|
||||||
|
let clientX: number;
|
||||||
|
let clientY: number;
|
||||||
|
|
||||||
|
if (activatorEvent instanceof MouseEvent) {
|
||||||
|
clientX = activatorEvent.clientX;
|
||||||
|
clientY = activatorEvent.clientY;
|
||||||
|
} else if (activatorEvent instanceof TouchEvent && activatorEvent.touches.length > 0) {
|
||||||
|
clientX = activatorEvent.touches[0].clientX;
|
||||||
|
clientY = activatorEvent.touches[0].clientY;
|
||||||
|
} else {
|
||||||
|
// Fallback: use center of container
|
||||||
|
clientX = containerRect.left + containerRect.width / 2;
|
||||||
|
clientY = containerRect.top + containerRect.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalClient = activatorClient + delta (displacement during drag)
|
||||||
|
const finalClientX = clientX + delta.x;
|
||||||
|
const finalClientY = clientY + delta.y;
|
||||||
|
|
||||||
|
// Convert to coordinates relative to the container
|
||||||
|
const screenX = finalClientX - containerRect.left;
|
||||||
|
const screenY = finalClientY - containerRect.top;
|
||||||
|
|
||||||
|
// Clamp to container bounds
|
||||||
|
const clampedX = Math.max(0, Math.min(screenX, containerRect.width));
|
||||||
|
const clampedY = Math.max(0, Math.min(screenY, containerRect.height));
|
||||||
|
|
||||||
|
// Convert screen coords to PDF user-space (Y-flip applied)
|
||||||
|
const { x: pdfX, y: pdfY } = screenToPdfCoords(clampedX, clampedY, containerRect, pageInfo);
|
||||||
|
|
||||||
|
const newField: SignatureFieldData = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
page: currentPage,
|
||||||
|
x: pdfX,
|
||||||
|
y: pdfY,
|
||||||
|
width: 144, // 2 inches at 72 DPI
|
||||||
|
height: 36, // 0.5 inches at 72 DPI
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = [...fields, newField];
|
||||||
|
setFields(next);
|
||||||
|
persistFields(docId, next);
|
||||||
|
},
|
||||||
|
[fields, pageInfo, currentPage, docId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render placed fields for the current page
|
||||||
|
const renderFields = () => {
|
||||||
|
if (!pageInfo) return null;
|
||||||
|
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (!containerRect) return null;
|
||||||
|
|
||||||
|
return fields
|
||||||
|
.filter((f) => f.page === currentPage)
|
||||||
|
.map((field) => {
|
||||||
|
const { left, top } = pdfToScreenCoords(field.x, field.y, containerRect, pageInfo);
|
||||||
|
const widthPx = (field.width / pageInfo.originalWidth) * containerRect.width;
|
||||||
|
const heightPx = (field.height / pageInfo.originalHeight) * containerRect.height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left,
|
||||||
|
top: top - heightPx, // top is y of bottom-left corner; shift up by height
|
||||||
|
width: widthPx,
|
||||||
|
height: heightPx,
|
||||||
|
border: '2px solid #2563eb',
|
||||||
|
background: 'rgba(37,99,235,0.1)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#2563eb',
|
||||||
|
pointerEvents: 'all',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Signature</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = fields.filter((f) => f.id !== field.id);
|
||||||
|
setFields(next);
|
||||||
|
persistFields(docId, next);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#ef4444',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
padding: '0 2px',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
aria-label="Remove field"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Droppable PDF zone with field overlays */}
|
||||||
|
<DroppableZone id="pdf-drop-zone" containerRef={containerRef}>
|
||||||
|
{children}
|
||||||
|
{renderFields()}
|
||||||
|
</DroppableZone>
|
||||||
|
|
||||||
|
{/* Ghost overlay during drag */}
|
||||||
|
<DragOverlay>
|
||||||
|
{isDraggingToken ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 144,
|
||||||
|
height: 36,
|
||||||
|
border: '2px solid #2563eb',
|
||||||
|
background: 'rgba(37,99,235,0.15)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#2563eb',
|
||||||
|
fontWeight: 600,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Signature
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user