mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
feat: add single player mode
This commit is contained in:
@ -56,14 +56,14 @@ export interface ButtonProps
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
({ className, variant, size, asChild = false, loading, ...props }, ref) => {
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const showLoader = props.loading === true;
|
||||
const showLoader = loading === true;
|
||||
const isDisabled = props.disabled || showLoader;
|
||||
|
||||
return (
|
||||
|
||||
@ -109,6 +109,8 @@ export {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogOverlay,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogPortal,
|
||||
};
|
||||
|
||||
@ -73,7 +73,7 @@ const DocumentDropzoneCardCenterVariants: Variants = {
|
||||
};
|
||||
|
||||
export type DocumentDropzoneProps = {
|
||||
className: string;
|
||||
className?: string;
|
||||
onDrop?: (_file: File) => void | Promise<void>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@ -256,17 +256,28 @@ export const AddFieldsFormPartial = ({
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
useEffect(() => {
|
||||
const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
const observer = new MutationObserver((_mutations) => {
|
||||
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width } = $page.getBoundingClientRect();
|
||||
const { height, width } = $page.getBoundingClientRect();
|
||||
|
||||
fieldBounds.current = {
|
||||
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
|
||||
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
|
||||
fieldBounds.current = {
|
||||
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
|
||||
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
|
||||
};
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -396,7 +407,7 @@ export const AddFieldsFormPartial = ({
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="-mx-2 flex-1 overflow-y-scroll px-2">
|
||||
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
<button
|
||||
type="button"
|
||||
@ -505,7 +516,10 @@ export const AddFieldsFormPartial = ({
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
onGoBackClick={documentFlow.onBackStep}
|
||||
onGoBackClick={() => {
|
||||
documentFlow.onBackStep?.();
|
||||
remove();
|
||||
}}
|
||||
onGoNextClick={() => void onFormSubmit()}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
|
||||
282
packages/ui/primitives/document-flow/add-signature.tsx
Normal file
282
packages/ui/primitives/document-flow/add-signature.tsx
Normal file
@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||
import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
|
||||
import { ZAddSignatureFormSchema } from './add-signature.types';
|
||||
import {
|
||||
SinglePlayerModeCustomTextField,
|
||||
SinglePlayerModeSignatureField,
|
||||
} from './single-player-mode-fields';
|
||||
|
||||
export type AddSignatureFormProps = {
|
||||
defaultValues?: TAddSignatureFormSchema;
|
||||
documentFlow: DocumentFlowStep;
|
||||
fields: FieldWithSignature[];
|
||||
numberOfSteps: number;
|
||||
onSubmit: (_data: TAddSignatureFormSchema) => Promise<void> | void;
|
||||
requireName?: boolean;
|
||||
requireSignature?: boolean;
|
||||
};
|
||||
|
||||
export const AddSignatureFormPartial = ({
|
||||
defaultValues,
|
||||
documentFlow,
|
||||
fields,
|
||||
numberOfSteps,
|
||||
onSubmit,
|
||||
requireName = false,
|
||||
requireSignature = true,
|
||||
}: AddSignatureFormProps) => {
|
||||
// Refined schema which takes into account whether to allow an empty name or signature.
|
||||
const refinedSchema = ZAddSignatureFormSchema.superRefine((val, ctx) => {
|
||||
if (requireName && val.name.length === 0) {
|
||||
ctx.addIssue({
|
||||
path: ['name'],
|
||||
code: 'custom',
|
||||
message: 'Name is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (requireSignature && val.signature.length === 0) {
|
||||
ctx.addIssue({
|
||||
path: ['signature'],
|
||||
code: 'custom',
|
||||
message: 'Signature is required',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const form = useForm<TAddSignatureFormSchema>({
|
||||
resolver: zodResolver(refinedSchema),
|
||||
defaultValues: defaultValues ?? {
|
||||
name: '',
|
||||
email: '',
|
||||
signature: '',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A local copy of the provided fields to modify.
|
||||
*/
|
||||
const [localFields, setLocalFields] = useState(
|
||||
fields.map((field) => {
|
||||
let customText = field.customText;
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
|
||||
}
|
||||
|
||||
const inserted = match(field.type)
|
||||
.with(FieldType.DATE, () => true)
|
||||
.with(FieldType.NAME, () => form.getValues('name').length > 0)
|
||||
.with(FieldType.EMAIL, () => form.getValues('email').length > 0)
|
||||
.with(FieldType.SIGNATURE, () => form.getValues('signature').length > 0)
|
||||
.otherwise(() => true);
|
||||
|
||||
return { ...field, inserted, customText };
|
||||
}),
|
||||
);
|
||||
|
||||
const onEmailInputBlur = () => {
|
||||
setLocalFields((prev) =>
|
||||
prev.map((field) => {
|
||||
if (field.type !== FieldType.EMAIL) {
|
||||
return field;
|
||||
}
|
||||
|
||||
const value = form.getValues('email');
|
||||
|
||||
return {
|
||||
...field,
|
||||
customText: value,
|
||||
inserted: value.length > 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onNameInputBlur = () => {
|
||||
setLocalFields((prev) =>
|
||||
prev.map((field) => {
|
||||
if (field.type !== FieldType.NAME) {
|
||||
return field;
|
||||
}
|
||||
|
||||
const value = form.getValues('name');
|
||||
|
||||
return {
|
||||
...field,
|
||||
customText: value,
|
||||
inserted: value.length > 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onSignatureInputChange = (value: string) => {
|
||||
setLocalFields((prev) =>
|
||||
prev.map((field) => {
|
||||
if (field.type !== FieldType.SIGNATURE) {
|
||||
return field;
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
value: value ?? '',
|
||||
inserted: true,
|
||||
Signature: {
|
||||
id: -1,
|
||||
recipientId: -1,
|
||||
fieldId: -1,
|
||||
created: new Date(),
|
||||
signatureImageAsBase64: value,
|
||||
typedSignature: null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||
<DocumentFlowFormContainerContent>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="bg-background"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
{...field}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onEmailInputBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{requireName && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required={requireName}>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="bg-background"
|
||||
{...field}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onNameInputBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{requireSignature && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signature"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required={requireSignature}>Signature</FormLabel>
|
||||
<FormControl>
|
||||
<Card
|
||||
className={cn('mt-2', {
|
||||
'rounded-sm ring-2 ring-red-500 ring-offset-2 transition-all':
|
||||
form.formState.errors.signature,
|
||||
})}
|
||||
gradient={!form.formState.errors.signature}
|
||||
degrees={-120}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
defaultValue={field.value}
|
||||
onChange={(value) => {
|
||||
field.onChange(value ?? '');
|
||||
onSignatureInputChange(value ?? '');
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={documentFlow.title}
|
||||
step={documentFlow.stepIndex}
|
||||
maxStep={numberOfSteps}
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={form.formState.isSubmitting}
|
||||
onGoBackClick={documentFlow.onBackStep}
|
||||
onGoNextClick={async () => await form.handleSubmit(onSubmit)()}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</fieldset>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{localFields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => {
|
||||
return <SinglePlayerModeCustomTextField key={field.id} field={field} />;
|
||||
})
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
<SinglePlayerModeSignatureField key={field.id} field={field} />
|
||||
))
|
||||
.otherwise(() => {
|
||||
return null;
|
||||
}),
|
||||
)}
|
||||
</ElementVisible>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZAddSignatureFormSchema = z.object({
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
export type TAddSignatureFormSchema = z.infer<typeof ZAddSignatureFormSchema>;
|
||||
@ -13,7 +13,7 @@ export type DocumentFlowFormContainerProps = HTMLAttributes<HTMLFormElement> & {
|
||||
|
||||
export const DocumentFlowFormContainer = ({
|
||||
children,
|
||||
id = 'edit-document-form',
|
||||
id = 'document-flow-form-container',
|
||||
className,
|
||||
...props
|
||||
}: DocumentFlowFormContainerProps) => {
|
||||
@ -152,10 +152,11 @@ export const DocumentFlowFormContainerActions = ({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
type="button"
|
||||
className="bg-documenso flex-1"
|
||||
size="lg"
|
||||
disabled={disabled || loading || !canGoNext}
|
||||
loading={loading}
|
||||
onClick={onGoNextClick}
|
||||
>
|
||||
{goNextLabel}
|
||||
|
||||
@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size';
|
||||
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
||||
import {
|
||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||
DEFAULT_STANDARD_FONT_SIZE,
|
||||
MIN_HANDWRITING_FONT_SIZE,
|
||||
MIN_STANDARD_FONT_SIZE,
|
||||
} from '@documenso/lib/constants/pdf';
|
||||
import { Field, FieldType } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
export type FieldContainerPortalProps = {
|
||||
field: FieldWithSignature;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export type SinglePlayerModeFieldContainerProps = {
|
||||
field: FieldWithSignature;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function FieldContainerPortal({
|
||||
field,
|
||||
children,
|
||||
className = '',
|
||||
}: FieldContainerPortalProps) {
|
||||
const coords = useFieldPageCoords(field);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn('absolute', className)}
|
||||
style={{
|
||||
top: `${coords.y}px`,
|
||||
left: `${coords.x}px`,
|
||||
height: `${coords.height}px`,
|
||||
width: `${coords.width}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function SinglePlayerModeFieldCardContainer({
|
||||
field,
|
||||
children,
|
||||
}: SinglePlayerModeFieldContainerProps) {
|
||||
return (
|
||||
<FieldContainerPortal field={field}>
|
||||
<motion.div className="h-full w-full" animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||
<Card
|
||||
className="bg-background relative z-20 h-full w-full"
|
||||
data-inserted={field.inserted ? 'true' : 'false'}
|
||||
>
|
||||
<CardContent
|
||||
className={cn(
|
||||
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</FieldContainerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
export function SinglePlayerModeSignatureField({ field }: { field: FieldWithSignature }) {
|
||||
const fontVariable = '--font-signature';
|
||||
const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue(
|
||||
fontVariable,
|
||||
);
|
||||
|
||||
const minFontSize = MIN_HANDWRITING_FONT_SIZE;
|
||||
const maxFontSize = DEFAULT_HANDWRITING_FONT_SIZE;
|
||||
|
||||
if (!isSignatureFieldType(field.type)) {
|
||||
throw new Error('Invalid field type');
|
||||
}
|
||||
|
||||
const $paragraphEl = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
const { height, width } = useFieldPageCoords(field);
|
||||
|
||||
const scalingFactor = useElementScaleSize(
|
||||
{
|
||||
height,
|
||||
width,
|
||||
},
|
||||
$paragraphEl,
|
||||
maxFontSize,
|
||||
fontVariableValue,
|
||||
);
|
||||
|
||||
const fontSize = maxFontSize * scalingFactor;
|
||||
|
||||
const insertedBase64Signature = field.inserted && field.Signature?.signatureImageAsBase64;
|
||||
const insertedTypeSignature = field.inserted && field.Signature?.typedSignature;
|
||||
|
||||
return (
|
||||
<SinglePlayerModeFieldCardContainer field={field}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={
|
||||
(insertedBase64Signature && 'base64Signature') ||
|
||||
(insertedTypeSignature && 'typedSignature') ||
|
||||
'not-inserted'
|
||||
}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
}}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{insertedBase64Signature ? (
|
||||
<img
|
||||
src={insertedBase64Signature}
|
||||
alt="Your signature"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : insertedTypeSignature ? (
|
||||
<p
|
||||
ref={$paragraphEl}
|
||||
style={{
|
||||
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
|
||||
fontFamily: `var(${fontVariable})`,
|
||||
}}
|
||||
className="font-signature"
|
||||
>
|
||||
{insertedTypeSignature}
|
||||
</p>
|
||||
) : (
|
||||
<p className="group-hover:text-primary text-muted-foreground duration-200">Signature</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</SinglePlayerModeFieldCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function SinglePlayerModeCustomTextField({ field }: { field: Field }) {
|
||||
const fontVariable = '--font-sans';
|
||||
const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue(
|
||||
fontVariable,
|
||||
);
|
||||
|
||||
const minFontSize = MIN_STANDARD_FONT_SIZE;
|
||||
const maxFontSize = DEFAULT_STANDARD_FONT_SIZE;
|
||||
|
||||
if (isSignatureFieldType(field.type)) {
|
||||
throw new Error('Invalid field type');
|
||||
}
|
||||
|
||||
const $paragraphEl = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
const { height, width } = useFieldPageCoords(field);
|
||||
|
||||
const scalingFactor = useElementScaleSize(
|
||||
{
|
||||
height,
|
||||
width,
|
||||
},
|
||||
$paragraphEl,
|
||||
maxFontSize,
|
||||
fontVariableValue,
|
||||
);
|
||||
|
||||
const fontSize = maxFontSize * scalingFactor;
|
||||
|
||||
return (
|
||||
<SinglePlayerModeFieldCardContainer key="not-inserted" field={field}>
|
||||
{field.inserted ? (
|
||||
<p
|
||||
ref={$paragraphEl}
|
||||
style={{
|
||||
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
|
||||
fontFamily: `var(${fontVariable})`,
|
||||
}}
|
||||
>
|
||||
{field.customText}
|
||||
</p>
|
||||
) : (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">
|
||||
{match(field.type)
|
||||
.with(FieldType.DATE, () => 'Date')
|
||||
.with(FieldType.NAME, () => 'Name')
|
||||
.with(FieldType.EMAIL, () => 'Email')
|
||||
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => 'Signature')
|
||||
.otherwise(() => '')}
|
||||
</p>
|
||||
)}
|
||||
</SinglePlayerModeFieldCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const isSignatureFieldType = (fieldType: Field['type']) =>
|
||||
fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE;
|
||||
@ -52,7 +52,6 @@ export interface DocumentFlowStep {
|
||||
title: string;
|
||||
description: string;
|
||||
stepIndex: number;
|
||||
onSubmit?: () => void;
|
||||
onBackStep?: () => void;
|
||||
onNextStep?: () => void;
|
||||
}
|
||||
|
||||
@ -27,6 +27,10 @@ export const ElementVisible = ({ target, children }: ElementVisibleProps) => {
|
||||
};
|
||||
}, [target]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(!!document.querySelector(target));
|
||||
}, [target]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
192
packages/ui/primitives/form/form.tsx
Normal file
192
packages/ui/primitives/form/form.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from 'react-hook-form';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { Label } from '../label';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & { required?: boolean }
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
className,
|
||||
// error && 'text-destructive', // Uncomment to apply styling on error.
|
||||
)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: 10,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-xs text-red-500', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
@ -12,6 +12,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
className={cn(
|
||||
'border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border bg-transparent px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
{
|
||||
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],
|
||||
},
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
@ -13,9 +13,13 @@ const labelVariants = cva(
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants> & { required?: boolean }
|
||||
>(({ className, children, required, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props}>
|
||||
{children}
|
||||
{required && <span className="text-destructive ml-1 inline-block font-medium">*</span>}
|
||||
</LabelPrimitive.Root>
|
||||
));
|
||||
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
@ -14,3 +14,10 @@ export const LazyPDFViewer = dynamic(async () => import('./pdf-viewer'), {
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* LazyPDFViewer variant with no loader.
|
||||
*/
|
||||
export const LazyPDFViewerNoLoader = dynamic(async () => import('./pdf-viewer'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@ import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
|
||||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type LoadedPDFDocument = PDFDocumentProxy;
|
||||
@ -30,18 +31,27 @@ export type OnPDFViewerPageClick = (_event: {
|
||||
export type PDFViewerProps = {
|
||||
className?: string;
|
||||
document: string;
|
||||
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
|
||||
onPageClick?: OnPDFViewerPageClick;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
|
||||
|
||||
export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFViewerProps) => {
|
||||
export const PDFViewer = ({
|
||||
className,
|
||||
document,
|
||||
onDocumentLoad,
|
||||
onPageClick,
|
||||
...props
|
||||
}: PDFViewerProps) => {
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
|
||||
const onDocumentLoaded = (doc: LoadedPDFDocument) => {
|
||||
setNumPages(doc.numPages);
|
||||
onDocumentLoad?.(doc);
|
||||
};
|
||||
|
||||
const onDocumentPageClick = (
|
||||
@ -54,7 +64,7 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
|
||||
return;
|
||||
}
|
||||
|
||||
const $page = $el.closest('.react-pdf__Page');
|
||||
const $page = $el.closest(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
@ -108,12 +118,34 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
|
||||
'h-[80vh] max-h-[60rem]': numPages === 0,
|
||||
})}
|
||||
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
||||
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
|
||||
// Therefore we add some additional custom error handling.
|
||||
onSourceError={() => {
|
||||
setPdfError(true);
|
||||
}}
|
||||
externalLinkTarget="_blank"
|
||||
loading={
|
||||
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
{pdfError ? (
|
||||
<div className="text-muted-foreground text-center">
|
||||
<p>Something went wrong while loading the document.</p>
|
||||
<p className="mt-1 text-sm">Please try again or contact our support.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
error={
|
||||
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
|
||||
<div className="text-muted-foreground text-center">
|
||||
<p>Something went wrong while loading the document.</p>
|
||||
<p className="mt-1 text-sm">Please try again or contact our support.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@ -129,6 +161,7 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
loading={() => ''}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user