feat(12.1-01): add optional text-edit props and click-to-select interaction
- PdfViewerWrapper: add selectedFieldId?, textFillData?, onFieldSelect?, onFieldValueChange? and forward to PdfViewer - PdfViewer: add same 4 optional props, forward to FieldPlacer; call onFieldSelect?.(null) on page navigation - FieldPlacer: extend FieldPlacerProps with 4 new optional props - DroppableZone: add optional onClick prop for background deselect - renderFields: text fields show inline input when selected (isSelected), value/label otherwise - Per-field onClick: text fields call onFieldSelect(id), non-text fields call onFieldSelect(null) - Cursor updated to 'text' for text field type; boxShadow ring on selected state
This commit is contained in:
@@ -123,12 +123,14 @@ function DroppableZone({
|
|||||||
children,
|
children,
|
||||||
onZonePointerMove,
|
onZonePointerMove,
|
||||||
onZonePointerUp,
|
onZonePointerUp,
|
||||||
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
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;
|
onZonePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||||
onZonePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void;
|
onZonePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef } = useDroppable({ id });
|
const { setNodeRef } = useDroppable({ id });
|
||||||
|
|
||||||
@@ -143,6 +145,7 @@ function DroppableZone({
|
|||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
onPointerMove={onZonePointerMove}
|
onPointerMove={onZonePointerMove}
|
||||||
onPointerUp={onZonePointerUp}
|
onPointerUp={onZonePointerUp}
|
||||||
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,9 +159,13 @@ interface FieldPlacerProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onFieldsChanged?: () => void;
|
onFieldsChanged?: () => void;
|
||||||
|
selectedFieldId?: string | null;
|
||||||
|
textFillData?: Record<string, string>;
|
||||||
|
onFieldSelect?: (fieldId: string | null) => void;
|
||||||
|
onFieldValueChange?: (fieldId: string, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = false, onFieldsChanged }: FieldPlacerProps) {
|
export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = false, onFieldsChanged, selectedFieldId, textFillData, onFieldSelect, onFieldValueChange }: FieldPlacerProps) {
|
||||||
const [fields, setFields] = useState<SignatureFieldData[]>([]);
|
const [fields, setFields] = useState<SignatureFieldData[]>([]);
|
||||||
const [isDraggingToken, setIsDraggingToken] = useState<string | null>(null);
|
const [isDraggingToken, setIsDraggingToken] = useState<string | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -566,6 +573,8 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
|
|||||||
const heightPx = (field.height / pageInfo.originalHeight) * renderedH;
|
const heightPx = (field.height / pageInfo.originalHeight) * renderedH;
|
||||||
|
|
||||||
const isMoving = activeDragFieldId === field.id && activeDragType === 'move';
|
const isMoving = activeDragFieldId === field.id && activeDragType === 'move';
|
||||||
|
const isSelected = selectedFieldId === field.id;
|
||||||
|
const currentValue = textFillData?.[field.id] ?? '';
|
||||||
|
|
||||||
// Per-type color and label
|
// Per-type color and label
|
||||||
const fieldType = getFieldType(field);
|
const fieldType = getFieldType(field);
|
||||||
@@ -632,8 +641,10 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
pointerEvents: readOnly ? 'none' : 'all',
|
pointerEvents: readOnly ? 'none' : 'all',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
cursor: readOnly ? 'default' : (isMoving ? 'grabbing' : 'grab'),
|
cursor: readOnly ? 'default' : (isMoving ? 'grabbing' : (fieldType === 'text' ? 'text' : 'grab')),
|
||||||
boxShadow: isMoving ? `0 4px 12px ${fieldColor}59` : undefined,
|
boxShadow: isSelected && fieldType === 'text'
|
||||||
|
? `0 0 0 2px ${fieldColor}66${isMoving ? `, 0 4px 12px ${fieldColor}59` : ''}`
|
||||||
|
: (isMoving ? `0 4px 12px ${fieldColor}59` : undefined),
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
opacity: readOnly ? 0.6 : 1,
|
opacity: readOnly ? 0.6 : 1,
|
||||||
@@ -644,9 +655,47 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
|
|||||||
if ((e.target as HTMLElement).closest('[data-no-move]')) return;
|
if ((e.target as HTMLElement).closest('[data-no-move]')) return;
|
||||||
handleMoveStart(e, field.id);
|
handleMoveStart(e, field.id);
|
||||||
}}
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (readOnly) return;
|
||||||
|
if (getFieldType(field) === 'text') {
|
||||||
|
e.stopPropagation(); // prevent DroppableZone's deselect handler from firing
|
||||||
|
onFieldSelect?.(field.id);
|
||||||
|
} else {
|
||||||
|
// Non-text field click: deselect any selected text field
|
||||||
|
onFieldSelect?.(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{fieldType !== 'checkbox' && (
|
{fieldType === 'text' ? (
|
||||||
|
isSelected ? (
|
||||||
|
<input
|
||||||
|
data-no-move
|
||||||
|
autoFocus
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => onFieldValueChange?.(field.id, e.target.value)}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: fieldColor,
|
||||||
|
width: '100%',
|
||||||
|
cursor: 'text',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
placeholder="Type value..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', pointerEvents: 'none' }}>
|
||||||
|
{currentValue || fieldLabel}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
fieldType !== 'checkbox' && (
|
||||||
<span style={{ pointerEvents: 'none' }}>{fieldLabel}</span>
|
<span style={{ pointerEvents: 'none' }}>{fieldLabel}</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
@@ -728,6 +777,12 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
|
|||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
onZonePointerMove={handleZonePointerMove}
|
onZonePointerMove={handleZonePointerMove}
|
||||||
onZonePointerUp={handleZonePointerUp}
|
onZonePointerUp={handleZonePointerUp}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Deselect if clicking the zone background (not a field box)
|
||||||
|
if (!(e.target as HTMLElement).closest('[data-field-id]')) {
|
||||||
|
onFieldSelect?.(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{renderFields()}
|
{renderFields()}
|
||||||
|
|||||||
@@ -19,7 +19,23 @@ interface PageInfo {
|
|||||||
scale: number;
|
scale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PdfViewer({ docId, docStatus, onFieldsChanged }: { docId: string; docStatus?: string; onFieldsChanged?: () => void }) {
|
export function PdfViewer({
|
||||||
|
docId,
|
||||||
|
docStatus,
|
||||||
|
onFieldsChanged,
|
||||||
|
selectedFieldId,
|
||||||
|
textFillData,
|
||||||
|
onFieldSelect,
|
||||||
|
onFieldValueChange,
|
||||||
|
}: {
|
||||||
|
docId: string;
|
||||||
|
docStatus?: string;
|
||||||
|
onFieldsChanged?: () => void;
|
||||||
|
selectedFieldId?: string | null;
|
||||||
|
textFillData?: Record<string, string>;
|
||||||
|
onFieldSelect?: (fieldId: string | null) => void;
|
||||||
|
onFieldValueChange?: (fieldId: string, value: string) => void;
|
||||||
|
}) {
|
||||||
const [numPages, setNumPages] = useState(0);
|
const [numPages, setNumPages] = useState(0);
|
||||||
const [pageNumber, setPageNumber] = useState(1);
|
const [pageNumber, setPageNumber] = useState(1);
|
||||||
const [scale, setScale] = useState(1.0);
|
const [scale, setScale] = useState(1.0);
|
||||||
@@ -31,7 +47,7 @@ export function PdfViewer({ docId, docStatus, onFieldsChanged }: { docId: string
|
|||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPageNumber(p => Math.max(1, p - 1))}
|
onClick={() => { setPageNumber(p => Math.max(1, p - 1)); onFieldSelect?.(null); }}
|
||||||
disabled={pageNumber <= 1}
|
disabled={pageNumber <= 1}
|
||||||
className="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100"
|
className="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
@@ -39,7 +55,7 @@ export function PdfViewer({ docId, docStatus, onFieldsChanged }: { docId: string
|
|||||||
</button>
|
</button>
|
||||||
<span>{pageNumber} / {numPages || '?'}</span>
|
<span>{pageNumber} / {numPages || '?'}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPageNumber(p => Math.min(numPages, p + 1))}
|
onClick={() => { setPageNumber(p => Math.min(numPages, p + 1)); onFieldSelect?.(null); }}
|
||||||
disabled={pageNumber >= numPages}
|
disabled={pageNumber >= numPages}
|
||||||
className="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100"
|
className="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
@@ -69,7 +85,17 @@ export function PdfViewer({ docId, docStatus, onFieldsChanged }: { docId: string
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PDF canvas wrapped in FieldPlacer for drag-and-drop field placement */}
|
{/* PDF canvas wrapped in FieldPlacer for drag-and-drop field placement */}
|
||||||
<FieldPlacer docId={docId} pageInfo={pageInfo} currentPage={pageNumber} readOnly={readOnly} onFieldsChanged={onFieldsChanged}>
|
<FieldPlacer
|
||||||
|
docId={docId}
|
||||||
|
pageInfo={pageInfo}
|
||||||
|
currentPage={pageNumber}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onFieldsChanged={onFieldsChanged}
|
||||||
|
selectedFieldId={selectedFieldId}
|
||||||
|
textFillData={textFillData}
|
||||||
|
onFieldSelect={onFieldSelect}
|
||||||
|
onFieldValueChange={onFieldValueChange}
|
||||||
|
>
|
||||||
<Document
|
<Document
|
||||||
file={`/api/documents/${docId}/file`}
|
file={`/api/documents/${docId}/file`}
|
||||||
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
|
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
|
||||||
|
|||||||
@@ -3,6 +3,32 @@ import dynamic from 'next/dynamic';
|
|||||||
|
|
||||||
const PdfViewer = dynamic(() => import('./PdfViewer').then(m => m.PdfViewer), { ssr: false });
|
const PdfViewer = dynamic(() => import('./PdfViewer').then(m => m.PdfViewer), { ssr: false });
|
||||||
|
|
||||||
export function PdfViewerWrapper({ docId, docStatus, onFieldsChanged }: { docId: string; docStatus?: string; onFieldsChanged?: () => void }) {
|
export function PdfViewerWrapper({
|
||||||
return <PdfViewer docId={docId} docStatus={docStatus} onFieldsChanged={onFieldsChanged} />;
|
docId,
|
||||||
|
docStatus,
|
||||||
|
onFieldsChanged,
|
||||||
|
selectedFieldId,
|
||||||
|
textFillData,
|
||||||
|
onFieldSelect,
|
||||||
|
onFieldValueChange,
|
||||||
|
}: {
|
||||||
|
docId: string;
|
||||||
|
docStatus?: string;
|
||||||
|
onFieldsChanged?: () => void;
|
||||||
|
selectedFieldId?: string | null;
|
||||||
|
textFillData?: Record<string, string>;
|
||||||
|
onFieldSelect?: (fieldId: string | null) => void;
|
||||||
|
onFieldValueChange?: (fieldId: string, value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PdfViewer
|
||||||
|
docId={docId}
|
||||||
|
docStatus={docStatus}
|
||||||
|
onFieldsChanged={onFieldsChanged}
|
||||||
|
selectedFieldId={selectedFieldId}
|
||||||
|
textFillData={textFillData}
|
||||||
|
onFieldSelect={onFieldSelect}
|
||||||
|
onFieldValueChange={onFieldValueChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user