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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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