This commit is contained in:
David Nguyen
2025-10-16 13:46:45 +11:00
parent a26a740fe5
commit 0a0d2d1a82
24 changed files with 691 additions and 893 deletions
@@ -1,40 +1,15 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
const ZSignFieldDropdownFormSchema = z.object({
dropdown: z.string().min(1, { message: msg`Option is required`.id }),
});
type TSignFieldDropdownFormSchema = z.infer<typeof ZSignFieldDropdownFormSchema>;
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@documenso/ui/primitives/command';
export type SignFieldDropdownDialogProps = {
fieldMeta: TDropdownFieldMeta;
@@ -46,72 +21,20 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
const values = fieldMeta.values?.map((value) => value.value) ?? [];
const form = useForm<TSignFieldDropdownFormSchema>({
resolver: zodResolver(ZSignFieldDropdownFormSchema),
defaultValues: {
dropdown: fieldMeta.defaultValue,
},
});
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sign Dropdown Field</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Select a value to sign into the field</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="dropdown"
render={({ field }) => (
<FormItem>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue placeholder={t`Select an option`} />
</SelectTrigger>
<SelectContent>
{values.map((value, i) => (
<SelectItem key={i} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
<CommandDialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<CommandInput placeholder={t`Select an option`} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading={t`Options`}>
{values.map((value, i) => (
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
{value}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
},
);
@@ -45,26 +45,32 @@ const ZDropdownFieldFormSchema = z
.min(1, {
message: msg`Dropdown must have at least one option`.id,
})
.refine(
(data) => {
// Todo: Envelopes - This doesn't work.
console.log({
data,
});
if (data) {
const values = data.map((item) => item.value);
return new Set(values).size === values.length;
.superRefine((values, ctx) => {
const seen = new Map<string, number[]>(); // value → indices
values.forEach((item, index) => {
const key = item.value;
if (!seen.has(key)) {
seen.set(key, []);
}
return true;
},
{
message: 'Duplicate values are not allowed',
},
),
seen.get(key)!.push(index);
});
for (const [key, indices] of seen) {
if (indices.length > 1 && key.trim() !== '') {
for (const i of indices) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Duplicate values are not allowed`.id,
path: [i, 'value'],
});
}
}
}
}),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
// Todo: Envelopes - This doesn't work
.refine(
(data) => {
// Default value must be one of the available options
@@ -111,7 +117,20 @@ export const EditorFieldDropdownForm = ({
const addValue = () => {
const currentValues = form.getValues('values') || [];
const newValues = [...currentValues, { value: 'New option' }];
let newValue = 'New option';
// Iterate to create a unique value
for (let i = 0; i < currentValues.length; i++) {
newValue = `New option ${i + 1}`;
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
newValue = `New option ${i + 1}`;
} else {
break;
}
}
const newValues = [...currentValues, { value: newValue }];
form.setValue('values', newValues);
};
@@ -127,6 +146,10 @@ export const EditorFieldDropdownForm = ({
newValues.splice(index, 1);
form.setValue('values', newValues);
if (form.getValues('defaultValue') === newValues[index].value) {
form.setValue('defaultValue', undefined);
}
};
useEffect(() => {
@@ -163,20 +186,26 @@ export const EditorFieldDropdownForm = ({
</FormLabel>
<FormControl>
<Select
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
{...field}
value={field.value}
onValueChange={(val) => field.onChange(val)}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === undefined ? null : value)}
>
{/* Todo: Envelopes - THis is cooked */}
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Default Value`} />
</SelectTrigger>
<SelectContent position="popper">
{(formValues.values || []).map((item, index) => (
<SelectItem key={index} value={item.value || ''}>
{item.value}
</SelectItem>
))}
{(formValues.values || [])
.filter((item) => item.value)
.map((item, index) => (
<SelectItem key={index} value={item.value || ''}>
{item.value}
</SelectItem>
))}
<SelectItem value={'-1'}>
<Trans>None</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
@@ -3,15 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer';
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
@@ -26,27 +23,10 @@ import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
export default function EnvelopeEditorFieldsPageRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const interactiveTransformer = useRef<Transformer | null>(null);
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
@@ -54,10 +34,17 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const localPageFields = useMemo(
() =>
@@ -68,44 +55,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
[editorFields.localFields, pageContext.pageNumber],
);
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
console.log('Field resized or moved');
@@ -120,6 +69,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const fieldGroup = event.target as Konva.Group;
const fieldFormId = fieldGroup.id();
// Note: This values are scaled.
const {
width: fieldPixelWidth,
height: fieldPixelHeight,
@@ -130,7 +80,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
skipShadow: true,
});
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
const pageHeight = scaledViewport.height;
const pageWidth = scaledViewport.width;
// Calculate x and y as a percentage of the page width and height
const positionPercentX = (fieldX / pageWidth) * 100;
@@ -165,7 +116,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
};
const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current || !interactiveTransformer.current) {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
@@ -174,7 +125,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const isFieldEditable =
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup, isFirstRender } = renderField({
const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
@@ -183,8 +135,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
inserted: false,
fieldMeta: field.fieldMeta,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId),
editable: isFieldEditable,
mode: 'edit',
@@ -210,24 +162,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Initialize snap guides layer
// snapGuideLayer.current = initializeSnapGuides(stage.current);
// Add transformer for resizing and rotating.
interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
// Render the fields.
for (const field of localPageFields) {
@@ -235,12 +177,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
// Handle stage click to deselect.
stage.current?.on('click', (e) => {
currentStage.on('click', (e) => {
removePendingField();
if (e.target === stage.current) {
setSelectedFields([]);
pageLayer.current?.batchDraw();
currentPageLayer.batchDraw();
}
});
@@ -267,12 +209,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
setSelectedFields([e.target]);
};
stage.current?.on('dragstart', onDragStartOrEnd);
stage.current?.on('dragend', onDragStartOrEnd);
stage.current?.on('transformstart', () => setIsFieldChanging(true));
stage.current?.on('transformend', () => setIsFieldChanging(false));
currentStage.on('dragstart', onDragStartOrEnd);
currentStage.on('dragend', onDragStartOrEnd);
currentStage.on('transformstart', () => setIsFieldChanging(true));
currentStage.on('transformend', () => setIsFieldChanging(false));
pageLayer.current.batchDraw();
currentPageLayer.batchDraw();
};
/**
@@ -284,7 +226,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
* - Selecting multiple fields
* - Selecting empty area to create fields
*/
const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
const createInteractiveTransformer = (
currentStage: Konva.Stage,
currentPageLayer: Konva.Layer,
) => {
const transformer = new Konva.Transformer({
rotateEnabled: false,
keepRatio: false,
@@ -301,36 +246,39 @@ export default function EnvelopeEditorFieldsPageRenderer() {
},
});
layer.add(transformer);
currentPageLayer.add(transformer);
// Add selection rectangle.
const selectionRectangle = new Konva.Rect({
fill: 'rgba(24, 160, 251, 0.3)',
visible: false,
});
layer.add(selectionRectangle);
currentPageLayer.add(selectionRectangle);
let x1: number;
let y1: number;
let x2: number;
let y2: number;
stage.on('mousedown touchstart', (e) => {
currentStage.on('mousedown touchstart', (e) => {
// do nothing if we mousedown on any shape
if (e.target !== stage) {
if (e.target !== currentStage) {
return;
}
const pointerPosition = stage.getPointerPosition();
const pointerPosition = currentStage.getPointerPosition();
if (!pointerPosition) {
return;
}
x1 = pointerPosition.x;
y1 = pointerPosition.y;
x2 = pointerPosition.x;
y2 = pointerPosition.y;
console.log(`pointerPosition.x: ${pointerPosition.x}`);
console.log(`pointerPosition.y: ${pointerPosition.y}`);
x1 = pointerPosition.x / scale;
y1 = pointerPosition.y / scale;
x2 = pointerPosition.x / scale;
y2 = pointerPosition.y / scale;
selectionRectangle.setAttrs({
x: x1,
@@ -341,7 +289,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
});
});
stage.on('mousemove touchmove', () => {
currentStage.on('mousemove touchmove', () => {
// do nothing if we didn't start selection
if (!selectionRectangle.visible()) {
return;
@@ -349,14 +297,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.moveToTop();
const pointerPosition = stage.getPointerPosition();
const pointerPosition = currentStage.getPointerPosition();
if (!pointerPosition) {
return;
}
x2 = pointerPosition.x;
y2 = pointerPosition.y;
x2 = pointerPosition.x / scale;
y2 = pointerPosition.y / scale;
selectionRectangle.setAttrs({
x: Math.min(x1, x2),
@@ -366,7 +314,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
});
});
stage.on('mouseup touchend', () => {
currentStage.on('mouseup touchend', () => {
// do nothing if we didn't start selection
if (!selectionRectangle.visible()) {
return;
@@ -377,38 +325,41 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.visible(false);
});
const stageFieldGroups = stage.find('.field-group') || [];
const stageFieldGroups = currentStage.find('.field-group') || [];
const box = selectionRectangle.getClientRect();
const selectedFieldGroups = stageFieldGroups.filter(
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
);
setSelectedFields(selectedFieldGroups);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a field if no items are selected or the size is too small.
if (
selectedFieldGroups.length === 0 &&
canvasElement.current &&
box.width > MIN_FIELD_WIDTH_PX &&
box.height > MIN_FIELD_HEIGHT_PX &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
) {
const pendingFieldCreation = new Konva.Rect({
name: 'pending-field-creation',
x: box.x,
y: box.y,
width: box.width,
height: box.height,
x: box.x / scale,
y: box.y / scale,
width: unscaledBoxWidth,
height: unscaledBoxHeight,
fill: 'rgba(24, 160, 251, 0.3)',
});
layer.add(pendingFieldCreation);
currentPageLayer.add(pendingFieldCreation);
setPendingFieldCreation(pendingFieldCreation);
}
});
// Clicks should select/deselect shapes
stage.on('click tap', function (e) {
currentStage.on('click tap', function (e) {
// if we are selecting with rect, do nothing
if (
selectionRectangle.visible() &&
@@ -419,7 +370,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
// If empty area clicked, remove all selections
if (e.target === stage) {
if (e.target === stage.current) {
setSelectedFields([]);
return;
}
@@ -555,15 +506,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
return;
}
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
width: pixelWidth,
height: pixelHeight,
positionX: pixelX,
positionY: pixelY,
pageWidth,
pageHeight,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
});
editorFields.addField({
@@ -597,7 +546,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging && (
@@ -654,8 +606,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
<div
style={{
position: 'absolute',
top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
top:
pendingFieldCreation.y() * scale +
pendingFieldCreation.getClientRect().height +
5 +
'px',
left:
pendingFieldCreation.x() * scale +
pendingFieldCreation.getClientRect().width / 2 +
'px',
transform: 'translateX(-50%)',
zIndex: 50,
}}
@@ -673,13 +632,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
</div>
)}
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
);
@@ -60,7 +60,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
};
export const EnvelopeEditorPageFields = () => {
export const EnvelopeEditorFieldsPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -109,7 +109,7 @@ export const EnvelopeEditorPageFields = () => {
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex justify-center">
<div className="mt-4 flex justify-center p-4">
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
) : (
@@ -1,176 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
export default function EnvelopeEditorPagePreviewRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageContext.pageNumber],
);
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
renderField({
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
...field,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
color: getRecipientColorKey(field.recipientId),
editable: false,
mode: 'export',
});
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
*/
const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
pageLayer.current.batchDraw();
};
/**
* Render fields when they are added or removed from the localFields.
*/
useEffect(() => {
if (!pageLayer.current || !stage.current) {
return;
}
// If doesn't exist in localFields, destroy it since it's been deleted.
pageLayer.current.find('Group').forEach((group) => {
if (
group.name() === 'field-group' &&
!localPageFields.some((field) => field.formId === group.id())
) {
console.log('Field removed, removing from canvas');
group.destroy();
}
});
// If it exists, rerender.
localPageFields.forEach((field) => {
console.log('Field created/updated, rendering on canvas');
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();
}, [localPageFields]);
if (!currentEnvelopeItem) {
return null;
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
/>
</div>
);
}
@@ -13,11 +13,9 @@ import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeEditorPagePreviewRenderer = lazy(
async () => import('./envelope-editor-page-preview-renderer'),
);
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
export const EnvelopeEditorPagePreview = () => {
export const EnvelopeEditorPreviewPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -51,7 +49,7 @@ export const EnvelopeEditorPagePreview = () => {
</Alert>
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
@@ -41,7 +41,7 @@ type LocalFile = {
isError: boolean;
};
export const EnvelopeEditorPageUpload = () => {
export const EnvelopeEditorUploadPage = () => {
const team = useCurrentTeam();
const { t } = useLingui();
@@ -39,10 +39,10 @@ import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-l
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
@@ -163,7 +163,9 @@ export default function EnvelopeEditor() {
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
Step {currentStepData.order}/{envelopeEditorSteps.length}
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
@@ -355,9 +357,9 @@ export default function EnvelopeEditor() {
<AnimateGenericFadeInOut key={currentStep}>
{match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()}
</AnimateGenericFadeInOut>
</div>
@@ -1,41 +1,31 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
export default function EnvelopeGenericPageRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const { _className, scale } = pageContext;
const localPageFields = useMemo(
() =>
@@ -46,44 +36,6 @@ export default function EnvelopeGenericPageRenderer() {
[fields, pageContext.pageNumber],
);
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
@@ -91,6 +43,7 @@ export default function EnvelopeGenericPageRenderer() {
}
renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.id.toString(),
@@ -103,8 +56,8 @@ export default function EnvelopeGenericPageRenderer() {
inserted: false,
fieldMeta: field.fieldMeta,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
// color: getRecipientColorKey(field.recipientId),
color: 'purple', // Todo
editable: false,
@@ -113,25 +66,15 @@ export default function EnvelopeGenericPageRenderer() {
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
pageLayer.current.batchDraw();
currentPageLayer.batchDraw();
};
/**
@@ -167,14 +110,19 @@ export default function EnvelopeGenericPageRenderer() {
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
);
@@ -1,14 +1,12 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { type Field, FieldType } from '@prisma/client';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import { type Field, FieldType, type Signature } from '@prisma/client';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
@@ -28,18 +26,6 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export default function EnvelopeSignerPageRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -58,21 +44,20 @@ export default function EnvelopeSignerPageRenderer() {
setSignature,
} = useRequiredEnvelopeSigningContext();
console.log({ fullName });
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const { envelope } = envelopeData;
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo(
() =>
recipientFields.filter(
@@ -82,45 +67,7 @@ export default function EnvelopeSignerPageRenderer() {
[recipientFields, pageContext.pageNumber],
);
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (unparsedField: Field) => {
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@@ -137,6 +84,7 @@ export default function EnvelopeSignerPageRenderer() {
}
const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: fieldToRender.id.toString(),
@@ -145,9 +93,10 @@ export default function EnvelopeSignerPageRenderer() {
height: Number(fieldToRender.height),
positionX: Number(fieldToRender.positionX),
positionY: Number(fieldToRender.positionY),
signature: unparsedField.signature,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color,
mode: 'sign',
});
@@ -357,29 +306,19 @@ export default function EnvelopeSignerPageRenderer() {
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
console.log({
localPageFields,
});
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
}
pageLayer.current.batchDraw();
currentPageLayer.batchDraw();
};
/**
@@ -392,7 +331,7 @@ export default function EnvelopeSignerPageRenderer() {
localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field);
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
});
pageLayer.current.batchDraw();
@@ -403,14 +342,19 @@ export default function EnvelopeSignerPageRenderer() {
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
);
@@ -19,6 +19,8 @@ import { DocumentUploadButton } from '~/components/general/document/document-upl
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
export type FolderGridProps = {
type: FolderType;
parentId: string | null;
@@ -98,7 +100,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
<div className="flex gap-4 sm:flex-row sm:justify-end">
{/* Todo: Envelopes - Feature flag */}
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
{type === FolderType.DOCUMENT ? (
<DocumentUploadButton />
@@ -136,6 +136,7 @@ export const useEditorFields = ({
const field: TLocalField = {
...fieldData,
formId: nanoid(12),
...restrictFieldPosValues(fieldData),
};
append(field);
@@ -165,7 +166,15 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, { ...localFields[index], ...updates });
const updatedField = {
...localFields[index],
...updates,
};
update(index, {
...updatedField,
...restrictFieldPosValues(updatedField),
});
triggerFieldsUpdate();
}
},
@@ -279,3 +288,14 @@ export const useEditorFields = ({
setSelectedRecipient,
};
};
const restrictFieldPosValues = (
field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>,
) => {
return {
positionX: Math.max(0, Math.min(100, field.positionX)),
positionY: Math.max(0, Math.min(100, field.positionY)),
width: Math.max(0, Math.min(100, field.width)),
height: Math.max(0, Math.min(100, field.height)),
};
};
@@ -0,0 +1,105 @@
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
export function usePageRenderer(renderFunction: RenderFunction) {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null);
const unscaledViewport = useMemo(
() => page.getViewport({ scale: 1, rotation: rotate }),
[page, rotate, scale],
);
const scaledViewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
/**
* Render the PDF and create the scaled Konva stage.
*/
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: kContainer } = konvaContainer;
if (!canvas || !kContainer) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: scaledViewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
stage.current = new Konva.Stage({
container: kContainer,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current.add(pageLayer.current);
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
});
return () => {
runningTask.cancel();
};
},
[page, scaledViewport],
);
return {
canvasElement,
konvaContainer,
stage,
pageLayer,
unscaledViewport,
scaledViewport,
pageContext,
};
}
@@ -56,6 +56,7 @@ export const sendDocument = async ({
recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
fields: true,
documentMeta: true,
envelopeItems: {
select: {
@@ -165,6 +166,16 @@ export const sendDocument = async ({
});
}
const fieldsToAutoInsert = [];
// Todo: Envelopes - Handle auto-signing
if (envelope.internalVersion === 2) {
// fieldsToAutoInsert = envelope.fields.filter((field) => !field.inserted);
// if (fieldsToAutoInsert.length > 0) {
// //
// }
}
const updatedEnvelope = await prisma.$transaction(async (tx) => {
if (envelope.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
@@ -156,9 +156,11 @@ export const setFieldsForDocument = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
numberFieldParsedMeta,
false,
);
if (errors.length > 0) {
@@ -3,7 +3,6 @@ import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import type { Canvas } from 'skia-canvas';
import { match } from 'ts-pattern';
@@ -86,6 +85,7 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
// Will render onto the layer.
renderField({
scale: 1,
field: {
renderId: field.id.toString(),
...field,
@@ -105,10 +105,10 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
const renderedField = await canvas.toBuffer('svg');
fs.writeFileSync(
`rendered-field-${field.envelopeId}--${field.id}.svg`,
renderedField.toString('utf-8'),
);
// fs.writeFileSync(
// `rendered-field-${field.envelopeId}--${field.id}.svg`,
// renderedField.toString('utf-8'),
// );
// Embed the SVG into the PDF
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
@@ -12,7 +12,7 @@ export const upsertFieldGroup = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Group => {
const { pageWidth, pageHeight, pageLayer, editable } = options;
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
@@ -27,6 +27,9 @@ export const upsertFieldGroup = (
name: 'field-group',
});
const maxXPosition = (pageWidth - fieldWidth) * scale;
const maxYPosition = (pageHeight - fieldHeight) * scale;
fieldGroup.setAttrs({
scaleX: 1,
scaleY: 1,
@@ -34,8 +37,9 @@ export const upsertFieldGroup = (
y: fieldY,
draggable: editable,
dragBoundFunc: (pos) => {
const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
return { x: newX, y: newY };
},
} satisfies Partial<Konva.GroupConfig>);
@@ -26,8 +26,9 @@ export type RenderFieldElementOptions = {
pageLayer: Konva.Layer;
pageWidth: number;
pageHeight: number;
mode?: 'edit' | 'sign' | 'export';
mode: 'edit' | 'sign' | 'export';
editable?: boolean;
scale: number;
color?: TRecipientColor;
};
@@ -1,4 +1,5 @@
import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta';
@@ -21,8 +22,9 @@ export const renderCheckboxFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
fieldGroup.add(upsertFieldRect(field, options));
@@ -31,95 +33,101 @@ export const renderCheckboxFieldElement = (
if (isFirstRender) {
pageLayer.add(fieldGroup);
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup
.find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup
.find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = squares.map((square, i) => ({
squareElement: square,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { squareElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: checkboxValues.length,
itemIndex: i,
itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox',
});
squareElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup
.find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup
.find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = squares.map((square, i) => ({
squareElement: square,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { squareElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: checkboxValues.length,
itemIndex: i,
itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox',
});
squareElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
checkboxValues.forEach(({ id, value, checked }, index) => {
const isCheckboxChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => value === field.customText)
.with('export', () => value === field.customText)
.exhaustive();
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth,
@@ -158,7 +166,7 @@ export const renderCheckboxFieldElement = (
strokeWidth: 2,
stroke: '#111827',
points: [3, 8, 7, 12, 13, 4],
visible: checked,
visible: isCheckboxChecked,
});
const text = new Konva.Text({
@@ -47,6 +47,7 @@ type RenderFieldOptions = {
*/
mode: 'edit' | 'sign' | 'export';
scale: number;
editable?: boolean;
};
@@ -56,6 +57,7 @@ export const renderField = ({
pageWidth,
pageHeight,
mode,
scale,
editable,
color,
}: RenderFieldOptions) => {
@@ -66,6 +68,7 @@ export const renderField = ({
mode,
color,
editable,
scale,
};
return match(field.type)
@@ -1,4 +1,5 @@
import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TRadioFieldMeta } from '../../types/field-meta';
@@ -31,86 +32,94 @@ export const renderRadioFieldElement = (
if (isFirstRender) {
pageLayer.add(fieldGroup);
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = circles.map((circle, i) => ({
circleElement: circle,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { circleElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
});
circleElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.width(rectWidth);
fieldRect.height(rectHeight);
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
fieldGroup.off('transform');
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = circles.map((circle, i) => ({
circleElement: circle,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { circleElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
});
circleElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.width(rectWidth);
fieldRect.height(rectHeight);
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
radioValues.forEach(({ value, checked }, index) => {
const isRadioValueChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => value === field.customText)
.with('export', () => value === field.customText)
.exhaustive();
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth,
@@ -146,9 +155,7 @@ export const renderRadioFieldElement = (
y: itemInputY,
radius: radioSize / 4,
fill: '#111827',
// Todo: Envelopes
visible: value === field.customText,
// visible: checked,
visible: isRadioValueChecked,
});
const text = new Konva.Text({
@@ -96,77 +96,80 @@ export const renderSignatureFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
console.log({
rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
console.log({
rectWidth,
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
}
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {
@@ -121,77 +121,80 @@ export const renderTextFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
console.log({
rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
console.log({
rectWidth,
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
}
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {
@@ -91,11 +91,11 @@ export const PdfViewerKonva = ({
}, []);
return (
<div ref={$el} className={cn('w-[800px] overflow-hidden', className)} {...props}>
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
{envelopeItemFile && Konva ? (
<PDFDocument
file={envelopeItemFile}
className={cn('w-full overflow-hidden rounded', {
className={cn('w-full rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
@@ -138,7 +138,7 @@ export const PdfViewerKonva = ({
.fill(null)
.map((_, i) => (
<div key={i} className="last:-mb-2">
<div className="border-border overflow-hidden rounded border will-change-transform">
<div className="border-border rounded border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}