Compare commits

..
Author SHA1 Message Date
Catalin PitandCursor 01b02db201 fix: envelope editor mobile view
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 12:43:58 +03:00
19 changed files with 2605 additions and 2465 deletions
@@ -21,6 +21,12 @@ export type DocumentAttachmentsPopoverProps = {
envelopeId: string;
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
/**
* Visually hide the button label below the `sm` breakpoint while keeping it
* available to screen readers.
*/
collapseLabelOnMobile?: boolean;
};
const ZAttachmentFormSchema = z.object({
@@ -35,6 +41,7 @@ export const DocumentAttachmentsPopover = ({
envelopeId,
buttonClassName,
buttonSize,
collapseLabelOnMobile = false,
}: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -129,7 +136,7 @@ export const DocumentAttachmentsPopover = ({
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<span className={cn(collapseLabelOnMobile && 'sr-only sm:not-sr-only')}>
<Trans>Attachments</Trans>
{attachments && attachments.data.length > 0 && <span className="ml-1">({attachments.data.length})</span>}
</span>
@@ -19,6 +19,12 @@ import { z } from 'zod';
export type EmbeddedEditorAttachmentPopoverProps = {
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
/**
* Visually hide the button label below the `sm` breakpoint while keeping it
* available to screen readers.
*/
collapseLabelOnMobile?: boolean;
};
const ZAttachmentFormSchema = z.object({
@@ -32,6 +38,7 @@ type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const EmbeddedEditorAttachmentPopover = ({
buttonClassName,
buttonSize,
collapseLabelOnMobile = false,
}: EmbeddedEditorAttachmentPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -90,7 +97,7 @@ export const EmbeddedEditorAttachmentPopover = ({
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<span className={cn(collapseLabelOnMobile && 'sr-only sm:not-sr-only')}>
<Trans>Attachments</Trans>
{attachments.length > 0 && <span className="ml-1">({attachments.length})</span>}
</span>
@@ -24,6 +24,7 @@ import {
UserIcon,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
@@ -88,11 +89,18 @@ export const fieldButtonList = [
type EnvelopeEditorFieldDragDropProps = {
selectedRecipientId: number | null;
selectedEnvelopeItemId: string | null;
/**
* Called when the user picks a field type from the palette, before the field
* is placed on the document.
*/
onFieldSelect?: (fieldType: FieldType) => void;
};
export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId,
selectedEnvelopeItemId,
onFieldSelect,
}: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
@@ -245,7 +253,10 @@ export const EnvelopeEditorFieldDragDrop = ({
disabled={isFieldsDisabled}
key={field.type}
type="button"
onClick={() => setSelectedField(field.type)}
onClick={() => {
setSelectedField(field.type);
onFieldSelect?.(field.type);
}}
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
className={cn(
@@ -267,27 +278,35 @@ export const EnvelopeEditorFieldDragDrop = ({
))}
</div>
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>
)}
{/*
* Portalled to the body because the fields panel ancestor carries a CSS
* transform (the slide-in overlay below `md`), which would otherwise
* become the containing block for this fixed-position preview and pin
* it inside the panel instead of the viewport.
*/}
{selectedField &&
createPortal(
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>,
document.body,
)}
</>
);
};
@@ -30,7 +30,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { AlertTriangleIcon, FileTextIcon, MousePointerClickIcon, PencilIcon, SparklesIcon, XIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
@@ -89,6 +89,10 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
// Whether the fields panel overlay is open. Only relevant below the `md`
// breakpoint, where the panel is hidden behind a floating trigger button.
const [isMobileFieldsPanelOpen, setIsMobileFieldsPanelOpen] = useState(false);
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
@@ -187,6 +191,10 @@ export const EnvelopeEditorFieldsPage = () => {
}, []);
const onDetectClick = () => {
// Close the panel overlay on small viewports so the document stays
// visible while the AI dialogs are open.
setIsMobileFieldsPanelOpen(false);
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
@@ -308,212 +316,264 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<>
{/* Floating trigger for the fields panel, visible below the `md` breakpoint. */}
<Button
type="button"
className="fixed right-4 bottom-4 z-30 shadow-lg md:hidden"
onClick={() => setIsMobileFieldsPanelOpen(true)}
>
<MousePointerClickIcon className="mr-2 h-4 w-4" />
<Trans>Fields</Trans>
</Button>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
{isMobileFieldsPanelOpen && (
<div
role="presentation"
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={() => setIsMobileFieldsPanelOpen(false)}
/>
)}
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
{/*
* Below `md` the panel is a slide-in overlay, from `md` upwards it is a
* static sidebar.
*
* This is deliberately one always-mounted element toggled with CSS rather
* than a Sheet/Drawer: the field palette registers window-level mouse
* listeners that must survive the panel closing mid click-to-place, and
* unmounting would also double-mount the field settings forms and AI
* dialogs. `invisible` (with the transition covering visibility) keeps the
* closed overlay out of the tab order and accessibility tree.
*/}
<div
className={cn(
'fixed inset-y-0 right-0 z-50 w-80 max-w-[85vw] overflow-y-auto border-border border-l bg-background py-4 transition-[transform,visibility] duration-200',
isMobileFieldsPanelOpen ? 'translate-x-0 shadow-xl' : 'translate-x-full max-md:invisible',
'md:sticky md:top-0 md:z-auto md:h-full md:w-80 md:max-w-none md:flex-shrink-0 md:translate-x-0 md:shadow-none md:transition-none',
)}
</section>
>
{/* Panel header with close button, visible below the `md` breakpoint. */}
<div className="mb-4 flex items-center justify-between px-4 md:hidden">
<h3 className="font-semibold text-foreground text-sm">
<Trans>Fields</Trans>
</h3>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
<Button variant="ghost" size="sm" onClick={() => setIsMobileFieldsPanelOpen(false)}>
<XIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Close</Trans>
</span>
</Button>
</div>
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
onFieldSelect={() => setIsMobileFieldsPanelOpen(false)}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.id}
{selectedField.recipientId}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
</div>
<Separator className="my-4" />
</>
)}
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
</>
)}
</div>
);
@@ -70,17 +70,18 @@ export default function EnvelopeEditorHeader() {
return (
<nav className="w-full border-border border-b bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center space-x-4">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-4">
{/* The logo and separator are hidden on small viewports to leave room for the title. */}
{editorConfig.embedded?.customBrandingLogo ? (
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="h-6 w-auto" />
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="hidden h-6 w-auto sm:block" />
) : (
<Link to="/">
<Link to="/" className="hidden sm:block">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Separator orientation="vertical" className="h-6 shrink-0" />
<Separator orientation="vertical" className="hidden h-6 shrink-0 sm:block" />
<div className="flex min-w-0 items-center space-x-2">
<div className="flex min-w-0 items-center gap-2">
<EnvelopeItemTitleInput
dataTestId="envelope-title-input"
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
@@ -98,19 +99,19 @@ export default function EnvelopeEditorHeader() {
{envelope.type === EnvelopeType.TEMPLATE && (
<>
{envelope.templateType === TemplateType.PRIVATE && (
<Badge variant="secondary" className="shrink-0">
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
<Trans>Private Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.ORGANISATION && (
<Badge variant="orange" className="shrink-0">
<Badge variant="orange" className="hidden shrink-0 sm:inline-flex">
<Building2Icon className="mr-2 size-4" />
<Trans>Organisation Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.PUBLIC && (
<Badge variant="default" className="shrink-0">
<Badge variant="default" className="hidden shrink-0 sm:inline-flex">
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
<Trans>Public Template</Trans>
</Badge>
@@ -118,7 +119,7 @@ export default function EnvelopeEditorHeader() {
{envelope.directLink?.token && (
<TemplateDirectLinkBadge
className="shrink-0 py-1"
className="hidden shrink-0 py-1 sm:flex"
token={envelope.directLink.token}
enabled={envelope.directLink.enabled}
/>
@@ -180,9 +181,9 @@ export default function EnvelopeEditorHeader() {
<div className="flex shrink-0 items-center space-x-2">
{allowAttachments &&
(isEmbedded ? (
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
<EmbeddedEditorAttachmentPopover buttonSize="sm" collapseLabelOnMobile />
) : (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" collapseLabelOnMobile />
))}
{editorConfig.settings && (
@@ -202,8 +203,10 @@ export default function EnvelopeEditorHeader() {
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
<SendIcon className="h-4 w-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
<Trans>Send Document</Trans>
</span>
</Button>
}
/>
@@ -212,8 +215,10 @@ export default function EnvelopeEditorHeader() {
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
<SendIcon className="h-4 w-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
<Trans>Resend Document</Trans>
</span>
</Button>
}
/>
@@ -592,7 +592,7 @@ export const EnvelopeEditorRecipientForm = () => {
return (
<Card backdropBlur={false} className="border">
<CardHeader className="flex flex-row justify-between">
<CardHeader className="flex flex-col justify-between gap-4 space-y-0 sm:flex-row">
<div>
<CardTitle>
<Trans>Recipients</Trans>
@@ -602,7 +602,7 @@ export const EnvelopeEditorRecipientForm = () => {
</CardDescription>
</div>
<div className="flex flex-row items-center space-x-2">
<div className="flex flex-row flex-wrap items-center gap-2">
{editorConfig.recipients?.allowAIDetection && (
<Tooltip>
<TooltipTrigger asChild>
@@ -842,9 +842,9 @@ export const EnvelopeEditorRecipientForm = () => {
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
<div className="flex flex-row flex-wrap items-center gap-2 sm:flex-nowrap">
{isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
<div className="mt-auto hidden h-10 w-[4.25rem] flex-shrink-0 sm:block" />
)}
{isSigningOrderSequential && !isCcRecipient(signer) && (
@@ -900,8 +900,9 @@ export const EnvelopeEditorRecipientForm = () => {
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
{/* Below `sm` the rows are stacked, so every row needs its own labels. */}
{!showAdvancedSettings && (
<FormLabel className={cn(index !== 0 && 'sm:hidden')}>
<Trans>Email</Trans>
</FormLabel>
)}
@@ -947,8 +948,8 @@ export const EnvelopeEditorRecipientForm = () => {
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
{!showAdvancedSettings && (
<FormLabel className={cn(index !== 0 && 'sm:hidden')}>
<Trans>Name</Trans>
</FormLabel>
)}
@@ -448,7 +448,7 @@ export const EnvelopeEditorUploadPage = () => {
};
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<div className="mx-auto max-w-4xl space-y-6 p-4 md:p-8">
<input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
@@ -1,3 +1,4 @@
import { useIsMobileViewport } from '@documenso/lib/client-only/hooks/use-media-query';
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
@@ -89,6 +90,8 @@ export const EnvelopeEditor = () => {
const [searchParams, setSearchParams] = useSearchParams();
const isMobileViewport = useIsMobileViewport();
const {
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
actions: {
@@ -101,6 +104,10 @@ export const EnvelopeEditor = () => {
},
} = editorConfig;
// Fall back to the icon-only sidebar on small viewports since the full
// sidebar would consume most of the screen.
const isSidebarMinimized = minimizeLeftSidebar || isMobileViewport;
const envelopeEditorSteps = useMemo(() => {
const steps: EnvelopeEditorStepData[] = [];
@@ -175,19 +182,19 @@ export const EnvelopeEditor = () => {
const currentStepData = envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0];
return (
<div className="h-screen w-screen bg-envelope-editor-background">
<div className="h-[100dvh] w-full bg-envelope-editor-background">
<EnvelopeEditorHeader />
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
<div className="flex h-[calc(100dvh-4rem)] w-full">
{/* Left Section - Step Navigation */}
<div
className={cn('flex w-80 flex-shrink-0 flex-col overflow-y-auto border-border border-r bg-background py-4', {
'w-14': minimizeLeftSidebar,
'w-14': isSidebarMinimized,
})}
>
{/* Left section step selector. */}
{minimizeLeftSidebar ? (
{isSidebarMinimized ? (
<div className="flex justify-center px-4">
<div className="relative flex h-10 w-10 items-center justify-center">
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
@@ -254,8 +261,8 @@ export const EnvelopeEditor = () => {
<div
className={cn('space-y-3', {
'px-4': !minimizeLeftSidebar,
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
'px-4': !isSidebarMinimized,
'mt-4 flex flex-col items-center': isSidebarMinimized,
})}
>
{envelopeEditorSteps.map((step) => {
@@ -274,7 +281,7 @@ export const EnvelopeEditor = () => {
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`,
{
'p-3': !minimizeLeftSidebar,
'p-3': !isSidebarMinimized,
},
)}
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
@@ -290,7 +297,7 @@ export const EnvelopeEditor = () => {
<Icon className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`} />
</div>
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<div>
<div
className={`font-medium text-sm ${
@@ -312,17 +319,17 @@ export const EnvelopeEditor = () => {
<Separator
className={cn('my-6', {
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
'mx-auto mb-4 w-4/5': isSidebarMinimized,
})}
/>
{/* Quick Actions. */}
<div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
'px-2': minimizeLeftSidebar,
'px-2': isSidebarMinimized,
})}
>
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<h4 className="font-semibold text-foreground text-sm">
<Trans>Quick Actions</Trans>
</h4>
@@ -334,7 +341,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Settings`)}>
<SettingsIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<span className="ml-2">
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</span>
@@ -352,7 +359,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Send Envelope`)}>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<span className="ml-2">
<Trans>Send Document</Trans>
</span>
@@ -367,7 +374,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Resend Envelope`)}>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<span className="ml-2">
<Trans>Resend Document</Trans>
</span>
@@ -389,7 +396,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Direct Link`)}>
<LinkIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<span className="ml-2">
<Trans>Direct Link</Trans>
</span>
@@ -407,7 +414,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Duplicate Envelope`)}>
<CopyPlusIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<span className="ml-2">
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
</span>
@@ -424,7 +431,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Save as Template`)}>
<FileOutputIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<span className="ml-2">
<Trans>Save as Template</Trans>
</span>
@@ -444,7 +451,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Download PDF`)}>
<DownloadCloudIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<span className="ml-2">
<Trans>Download PDF</Trans>
</span>
@@ -472,7 +479,7 @@ export const EnvelopeEditor = () => {
>
<Trash2Icon className="h-4 w-4" />
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<span className="ml-2">
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</span>
@@ -494,20 +501,20 @@ export const EnvelopeEditor = () => {
{!editorConfig.embedded && (
<div
className={cn('mt-auto px-4', {
'px-2': minimizeLeftSidebar,
'px-2': isSidebarMinimized,
})}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'flex items-center justify-center': minimizeLeftSidebar,
'flex items-center justify-center': isSidebarMinimized,
})}
asChild
>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
{!minimizeLeftSidebar && (
{!isSidebarMinimized && (
<span className="ml-2">
{isDocument ? <Trans>Return to documents</Trans> : <Trans>Return to templates</Trans>}
</span>
@@ -0,0 +1,34 @@
import { useCallback, useSyncExternalStore } from 'react';
/**
* Tracks whether the given CSS media query currently matches.
*
* Returns `false` on the server since the viewport is unknown until hydration.
*/
export const useMediaQuery = (query: string) => {
const subscribe = useCallback(
(onStoreChange: () => void) => {
const mediaQueryList = window.matchMedia(query);
mediaQueryList.addEventListener('change', onStoreChange);
return () => mediaQueryList.removeEventListener('change', onStoreChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
// The server snapshot: the viewport is unknown during SSR, so we assume
// the query does not match rather than throwing. React re-renders with
// the real value after hydration, so the only cost is a brief first
// paint with the non-matching variant.
() => false,
);
};
/**
* Whether the viewport is below the Tailwind `md` breakpoint (768px).
*/
export const useIsMobileViewport = () => useMediaQuery('(max-width: 767px)');
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff