mirror of
https://github.com/hanzoai/sign.git
synced 2026-10-21 01:21:51 +00:00
@hookform/resolvers 5 types a zod resolver as input in, output out: the
form holds z.input of its schema, and only handleSubmit sees z.output. The
flows spelled their forms as the output and dropped the input on the floor,
where a default or a transform makes the two differ: signers' actionAuth and
distributionMethod have defaults, and field meta takes `{}` and makes it
undefined.
Each flow's schema now exports its input beside its output, and whatever
holds or passes on the form's values uses it: the clipboard and last-active
field, the field item, its content and tooltip (which re-parse the meta
anyway), the email checkboxes, and every autosave. Autosave reads
getValues(), which is what the user typed, not what the schema made of it,
so it is typed as that all the way to the mutation, whose request type is
now z.input of the request schema — what the server parses.
Three reads of form state now apply the schema's own default where the
value may be unset: `actionAuth ?? []` in both signer steps and
`distributionMethod ?? EMAIL` in the subject step. Every writer in those
forms sets the value, so nothing reachable changes. Two `value &&
onChange(value)` statements become ifs, which typescript-eslint 8 asks for.
Co-authored-by: Hanzo Dev <[email protected]>
418 lines
13 KiB
TypeScript
418 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import { FieldType } from '@prisma/client';
|
|
import { CopyPlus, Settings2, SquareStack, Trash } from 'lucide-react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Rnd } from 'react-rnd';
|
|
import { useSearchParams } from 'react-router';
|
|
|
|
import { useElementBounds } from '@hanzo/esign-lib/client-only/hooks/use-element-bounds';
|
|
import { useIsPageInDom } from '@hanzo/esign-lib/client-only/hooks/use-is-page-in-dom';
|
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@hanzo/esign-lib/constants/pdf-viewer';
|
|
import type { TFieldMetaInput } from '@hanzo/esign-lib/types/field-meta';
|
|
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@hanzo/esign-lib/types/field-meta';
|
|
|
|
import { useRecipientColors } from '../../lib/recipient-colors';
|
|
import { cn } from '../../lib/utils';
|
|
import { FieldContent } from './field-content';
|
|
import type { TDocumentFlowFormInput } from './types';
|
|
|
|
type Field = TDocumentFlowFormInput['fields'][0];
|
|
|
|
export type FieldItemProps = {
|
|
field: Field;
|
|
fieldClassName?: string;
|
|
passive?: boolean;
|
|
disabled?: boolean;
|
|
minHeight?: number;
|
|
minWidth?: number;
|
|
defaultHeight?: number;
|
|
defaultWidth?: number;
|
|
onResize?: (_node: HTMLElement) => void;
|
|
onMove?: (_node: HTMLElement) => void;
|
|
onRemove?: () => void;
|
|
onDuplicate?: () => void;
|
|
onDuplicateAllPages?: () => void;
|
|
onAdvancedSettings?: () => void;
|
|
onFocus?: () => void;
|
|
onBlur?: () => void;
|
|
onMouseEnter?: () => void;
|
|
onMouseLeave?: () => void;
|
|
recipientIndex?: number;
|
|
hasErrors?: boolean;
|
|
active?: boolean;
|
|
onFieldActivate?: () => void;
|
|
onFieldDeactivate?: () => void;
|
|
};
|
|
|
|
/**
|
|
* The item when editing fields??
|
|
*/
|
|
export const FieldItem = (props: FieldItemProps) => {
|
|
const isPageInDom = useIsPageInDom(props.field.pageNumber);
|
|
|
|
if (!isPageInDom) {
|
|
return null;
|
|
}
|
|
|
|
return <FieldItemInner {...props} />;
|
|
};
|
|
|
|
const FieldItemInner = ({
|
|
fieldClassName,
|
|
field,
|
|
passive,
|
|
disabled,
|
|
minHeight,
|
|
minWidth,
|
|
defaultHeight,
|
|
defaultWidth,
|
|
onResize,
|
|
onMove,
|
|
onRemove,
|
|
onDuplicate,
|
|
onDuplicateAllPages,
|
|
onAdvancedSettings,
|
|
onFocus,
|
|
onBlur,
|
|
recipientIndex = 0,
|
|
hasErrors,
|
|
active,
|
|
onFieldActivate,
|
|
onFieldDeactivate,
|
|
}: FieldItemProps) => {
|
|
const { _ } = useLingui();
|
|
const [searchParams] = useSearchParams();
|
|
|
|
const [coords, setCoords] = useState({
|
|
pageX: 0,
|
|
pageY: 0,
|
|
pageHeight: defaultHeight || 0,
|
|
pageWidth: defaultWidth || 0,
|
|
});
|
|
const [settingsActive, setSettingsActive] = useState(false);
|
|
const $el = useRef<HTMLDivElement>(null);
|
|
|
|
const $pageBounds = useElementBounds(
|
|
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
|
);
|
|
|
|
const signerStyles = useRecipientColors(recipientIndex);
|
|
|
|
const isDevMode = searchParams.get('devmode') === 'true';
|
|
|
|
const advancedField = [
|
|
'NUMBER',
|
|
'RADIO',
|
|
'CHECKBOX',
|
|
'DROPDOWN',
|
|
'TEXT',
|
|
'INITIALS',
|
|
'EMAIL',
|
|
'DATE',
|
|
'NAME',
|
|
].includes(field.type);
|
|
|
|
const calculateCoords = useCallback(() => {
|
|
const $page = document.querySelector<HTMLElement>(
|
|
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
|
);
|
|
|
|
if (!$page) {
|
|
return;
|
|
}
|
|
|
|
const { height, width } = $page.getBoundingClientRect();
|
|
|
|
const top = $page.getBoundingClientRect().top + window.scrollY;
|
|
const left = $page.getBoundingClientRect().left + window.scrollX;
|
|
|
|
// X and Y are percentages of the page's height and width
|
|
const pageX = (field.pageX / 100) * width + left;
|
|
const pageY = (field.pageY / 100) * height + top;
|
|
|
|
const pageHeight = (field.pageHeight / 100) * height;
|
|
const pageWidth = (field.pageWidth / 100) * width;
|
|
|
|
setCoords({
|
|
pageX: pageX,
|
|
pageY: pageY,
|
|
pageHeight: pageHeight,
|
|
pageWidth: pageWidth,
|
|
});
|
|
}, [field.pageHeight, field.pageNumber, field.pageWidth, field.pageX, field.pageY]);
|
|
|
|
useEffect(() => {
|
|
calculateCoords();
|
|
}, [calculateCoords]);
|
|
|
|
useEffect(() => {
|
|
const onResize = () => {
|
|
calculateCoords();
|
|
};
|
|
|
|
window.addEventListener('resize', onResize);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', onResize);
|
|
};
|
|
}, [calculateCoords]);
|
|
|
|
useEffect(() => {
|
|
const onClickOutsideOfField = (event: MouseEvent) => {
|
|
const isOutsideOfField = $el.current && !event.composedPath().includes($el.current);
|
|
|
|
setSettingsActive((active) => {
|
|
if (active && isOutsideOfField) {
|
|
return false;
|
|
}
|
|
|
|
return active;
|
|
});
|
|
|
|
if (isOutsideOfField) {
|
|
setSettingsActive(false);
|
|
onFieldDeactivate?.();
|
|
onBlur?.();
|
|
}
|
|
};
|
|
|
|
document.body.addEventListener('click', onClickOutsideOfField);
|
|
|
|
return () => {
|
|
document.body.removeEventListener('click', onClickOutsideOfField);
|
|
};
|
|
}, [onBlur]);
|
|
|
|
const hasFieldMetaValues = (
|
|
fieldType: string,
|
|
fieldMeta: TFieldMetaInput,
|
|
parser: typeof ZCheckboxFieldMeta | typeof ZRadioFieldMeta,
|
|
) => {
|
|
if (field.type !== fieldType || !fieldMeta) {
|
|
return false;
|
|
}
|
|
|
|
const parsedMeta = parser?.parse(fieldMeta);
|
|
return parsedMeta && parsedMeta.values && parsedMeta.values.length > 0;
|
|
};
|
|
|
|
const checkBoxHasValues = useMemo(
|
|
() => hasFieldMetaValues('CHECKBOX', field.fieldMeta, ZCheckboxFieldMeta),
|
|
[field.fieldMeta],
|
|
);
|
|
|
|
const radioHasValues = useMemo(
|
|
() => hasFieldMetaValues('RADIO', field.fieldMeta, ZRadioFieldMeta),
|
|
[field.fieldMeta],
|
|
);
|
|
|
|
const hasCheckedValues = (fieldMeta: TFieldMetaInput, type: FieldType) => {
|
|
if (!fieldMeta || (type !== FieldType.RADIO && type !== FieldType.CHECKBOX)) {
|
|
return false;
|
|
}
|
|
|
|
if (type === FieldType.RADIO) {
|
|
const parsed = ZRadioFieldMeta.parse(fieldMeta);
|
|
return parsed.values?.some((value) => value.checked) ?? false;
|
|
}
|
|
|
|
if (type === FieldType.CHECKBOX) {
|
|
const parsed = ZCheckboxFieldMeta.parse(fieldMeta);
|
|
return parsed.values?.some((value) => value.checked) ?? false;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const fieldHasCheckedValues = useMemo(
|
|
() => hasCheckedValues(field.fieldMeta, field.type),
|
|
[field.fieldMeta, field.type],
|
|
);
|
|
|
|
const fixedSize = checkBoxHasValues || radioHasValues;
|
|
|
|
return createPortal(
|
|
<Rnd
|
|
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
|
|
className={cn('dark-mode-disabled group', {
|
|
'pointer-events-none': passive,
|
|
'pointer-events-none cursor-not-allowed opacity-75': disabled,
|
|
'z-50': active && !disabled,
|
|
'z-20': !active && !disabled,
|
|
'z-10': disabled,
|
|
})}
|
|
minHeight={fixedSize ? '' : minHeight || 'auto'}
|
|
minWidth={fixedSize ? '' : minWidth || 'auto'}
|
|
default={{
|
|
x: coords.pageX,
|
|
y: coords.pageY,
|
|
height: fixedSize ? 'auto' : coords.pageHeight,
|
|
width: fixedSize ? 'auto' : coords.pageWidth,
|
|
}}
|
|
maxWidth={fixedSize && $pageBounds?.width ? $pageBounds.width - coords.pageX : undefined}
|
|
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
|
|
onDragStart={() => onFieldActivate?.()}
|
|
onResizeStart={() => onFieldActivate?.()}
|
|
onMouseEnter={() => onFocus?.()}
|
|
onMouseLeave={() => onBlur?.()}
|
|
enableResizing={!fixedSize}
|
|
resizeHandleStyles={{
|
|
bottom: { bottom: -8, cursor: 'ns-resize' },
|
|
top: { top: -8, cursor: 'ns-resize' },
|
|
left: { cursor: 'ew-resize' },
|
|
right: { cursor: 'ew-resize' },
|
|
}}
|
|
onResizeStop={(_e, _d, ref) => {
|
|
onFieldDeactivate?.();
|
|
onResize?.(ref);
|
|
}}
|
|
onDragStop={(_e, d) => {
|
|
onFieldDeactivate?.();
|
|
onMove?.(d.node);
|
|
}}
|
|
>
|
|
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
|
field.fieldMeta?.label && (
|
|
<div
|
|
className={cn(
|
|
'absolute -top-16 right-0 left-0 rounded-md p-2 text-center text-xs text-gray-700',
|
|
{
|
|
'border-primary bg-foreground/5 border': !fieldHasCheckedValues,
|
|
'border-primary bg-sign-200 border': fieldHasCheckedValues,
|
|
},
|
|
)}
|
|
>
|
|
{field.fieldMeta.label}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
'group/field-item relative flex h-full w-full items-center justify-center rounded-[2px] bg-white/90 px-2 ring-2 transition-colors',
|
|
!hasErrors && signerStyles.base,
|
|
!hasErrors && signerStyles.fieldItem,
|
|
fieldClassName,
|
|
{
|
|
'rounded-[2px] border bg-red-400/20 shadow-[0_0_0_5px_theme(colors.red.500/10%),0_0_0_2px_theme(colors.red.500/40%),0_0_0_0.5px_theme(colors.red.500)] ring-red-400':
|
|
hasErrors,
|
|
},
|
|
!fixedSize && '[container-type:size]',
|
|
)}
|
|
data-error={hasErrors ? 'true' : undefined}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSettingsActive((prev) => !prev);
|
|
onFieldActivate?.();
|
|
onFocus?.();
|
|
}}
|
|
ref={$el}
|
|
data-field-id={field.nativeId}
|
|
data-field-type={field.type}
|
|
data-recipient-id={field.recipientId}
|
|
>
|
|
<FieldContent field={field} />
|
|
|
|
{/* On hover, display recipient initials on side of field. */}
|
|
<div className="absolute top-0 -right-5 z-20 hidden h-full w-5 items-center justify-center group-hover:flex">
|
|
<div
|
|
className={cn(
|
|
'flex h-5 w-5 flex-col items-center justify-center rounded-r-md text-[0.5rem] font-bold text-white opacity-0 transition duration-200 group-hover/field-item:opacity-100',
|
|
signerStyles.fieldItemInitials,
|
|
{
|
|
'!opacity-50': disabled || passive,
|
|
},
|
|
)}
|
|
>
|
|
{(field.signerEmail?.charAt(0)?.toUpperCase() ?? '') +
|
|
(field.signerEmail?.charAt(1)?.toUpperCase() ?? '')}
|
|
</div>
|
|
</div>
|
|
|
|
{isDevMode && (
|
|
<div className="border-border bg-background/95 absolute -top-20 left-1/2 z-50 -translate-x-1/2 rounded-md border px-2 py-1 shadow-sm backdrop-blur-sm">
|
|
<div className="flex flex-col gap-0.5 text-[9px]">
|
|
<span>
|
|
<span className="text-muted-foreground">
|
|
<Trans>Pos X:</Trans>
|
|
</span>
|
|
|
|
<span className="text-foreground font-mono">{field.pageX.toFixed(2)}</span>
|
|
</span>
|
|
<span>
|
|
<span className="text-muted-foreground">
|
|
<Trans>Pos Y:</Trans>
|
|
</span>
|
|
|
|
<span className="text-foreground font-mono">{field.pageY.toFixed(2)}</span>
|
|
</span>
|
|
<span>
|
|
<span className="text-muted-foreground">
|
|
<Trans>Width:</Trans>
|
|
</span>
|
|
|
|
<span className="text-foreground font-mono">{field.pageWidth.toFixed(2)}</span>
|
|
</span>
|
|
<span>
|
|
<span className="text-muted-foreground">
|
|
<Trans>Height:</Trans>
|
|
</span>
|
|
|
|
<span className="text-foreground font-mono">{field.pageHeight.toFixed(2)}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!disabled && settingsActive && (
|
|
<div className="absolute z-[60] mt-1 flex w-full items-center justify-center">
|
|
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
|
|
{advancedField && (
|
|
<button
|
|
title={_(msg`Advanced settings`)}
|
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
|
onClick={onAdvancedSettings}
|
|
onTouchEnd={onAdvancedSettings}
|
|
>
|
|
<Settings2 className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
title={_(msg`Duplicate`)}
|
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
|
onClick={onDuplicate}
|
|
onTouchEnd={onDuplicate}
|
|
>
|
|
<CopyPlus className="h-3 w-3" />
|
|
</button>
|
|
|
|
<button
|
|
title={_(msg`Duplicate on all pages`)}
|
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
|
onClick={onDuplicateAllPages}
|
|
onTouchEnd={onDuplicateAllPages}
|
|
>
|
|
<SquareStack className="h-3 w-3" />
|
|
</button>
|
|
|
|
<button
|
|
title={_(msg`Remove`)}
|
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
|
onClick={onRemove}
|
|
onTouchEnd={onRemove}
|
|
>
|
|
<Trash className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Rnd>,
|
|
document.body,
|
|
);
|
|
};
|