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:
Chandler Copeland
2026-03-19 23:59:51 -06:00
parent f1cb526213
commit 6069ae5e06
3 changed files with 364 additions and 0 deletions

View File

@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.6.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@vercel/blob": "^2.3.1",
"adm-zip": "^0.5.16",
"bcryptjs": "^3.0.3",
@@ -597,6 +599,45 @@
"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": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",

View File

@@ -16,6 +16,8 @@
},
"dependencies": {
"@cantoo/pdf-lib": "^2.6.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@vercel/blob": "^2.3.1",
"adm-zip": "^0.5.16",
"bcryptjs": "^3.0.3",

View File

@@ -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>
);
}