import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element'; import { actionButton, demoDocuments, demoFields, demoRecipients, fakeDocument, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame, type IDocumentRow, type IFieldPlacement, type IRecipient, type TRecipientRole } from './sdig-workspace.shared.js'; import '../sdig-contextmenu/index.js'; import { type ISdigContextMenuAction, type ISdigContextMenuActionEventDetail } from '../sdig-contextmenu/index.js'; declare global { interface HTMLElementTagNameMap { 'sdig-workspace-compose': SdigWorkspaceCompose; } } type TFieldDefinition = { type: IFieldPlacement['type']; icon: string; label: string; w: number; h: number; }; type TResizeHandle = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw'; type TFieldInteraction = { fieldId: string; mode: 'move' | 'resize'; handle?: TResizeHandle; startClientX: number; startClientY: number; startField: IFieldPlacement; pageWidth: number; pageHeight: number; }; type TSigningOrderDrag = { recipientId: number; pointerY: number; listTop: number; grabOffsetY: number; itemHeight: number; itemStep: number; targetRole: TRecipientRole; targetIndex: number; }; type TRecipientContextMenu = { recipientId: number; x: number; y: number; }; const fieldDefinitions: TFieldDefinition[] = [ { type: 'signature', icon: 'sign', label: 'Signature', w: 200, h: 50 }, { type: 'initials', icon: 'type', label: 'Initials', w: 120, h: 32 }, { type: 'date', icon: 'calendar', label: 'Date', w: 120, h: 32 }, { type: 'text', icon: 'type', label: 'Text field', w: 220, h: 32 }, { type: 'check', icon: 'check', label: 'Checkbox', w: 120, h: 32 }, ]; const resizeHandles: TResizeHandle[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; const recipientRoleDefinitions: Array<{ role: TRecipientRole; label: string; shortLabel: string; description: string }> = [ { role: 'signer', label: 'Needs signature', shortLabel: 'Signer', description: 'Can receive fields and must sign in order.' }, { role: 'copy', label: 'Final copy only', shortLabel: 'Copy', description: 'Receives the completed document after signing.' }, { role: 'updates', label: 'Every step update', shortLabel: 'Updates', description: 'Receives notifications for every routing step.' }, ]; @customElement('sdig-workspace-compose') export class SdigWorkspaceCompose extends DeesElement { public static demo = () => workspaceDemoFrame(html``); public static demoGroups = ['Signature Digital Workspace']; @state() private accessor step: number = 2; @state() private accessor activeRecipient: number = 0; @state() private accessor selectedFieldId: string | null = null; @property({ attribute: false }) public accessor document: IDocumentRow = demoDocuments[0]; @property({ attribute: false }) public accessor recipients: IRecipient[] = [...demoRecipients]; @property({ attribute: false }) public accessor fields: IFieldPlacement[] = [...demoFields]; @state() private accessor signingOrderDrag: TSigningOrderDrag | null = null; @state() private accessor recipientContextMenu: TRecipientContextMenu | null = null; private draggedFieldDefinition: TFieldDefinition | null = null; private draggedFieldGrabOffset: { x: number; y: number } | null = null; private fieldInteraction: TFieldInteraction | null = null; public static styles = [workspaceBaseStyles, css` .stepper { height: 44px; flex-shrink: 0; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; padding: 0 24px; gap: 24px; overflow-x: auto; } .step { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); white-space: nowrap; background: transparent; } .step-number { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); font-size: 10px; font-weight: 700; } .step.active { color: var(--text); font-weight: 500; } .step.active .step-number { background: var(--accent); border-color: var(--accent); color: white; } .step.done .step-number { background: var(--success); border-color: var(--success); color: white; } .compose-workspace { flex: 1; display: flex; overflow: hidden; } .palette { width: 260px; border-right: 1px solid var(--border-subtle); padding: 16px; background: var(--bg-el); overflow-y: auto; flex-shrink: 0; } .right-panel { width: 280px; border-left: 1px solid var(--border-subtle); padding: 16px; background: var(--bg-el); overflow-y: auto; flex-shrink: 0; } .field-tool { width: var(--tool-w); height: var(--tool-h); display: flex; align-items: center; gap: 8px; padding: 0 10px; background: color-mix(in srgb, var(--recipient-color) 8%, var(--bg-card)); border: 1.5px dashed var(--recipient-color); border-radius: 4px; font-size: 12px; color: var(--recipient-color); margin-bottom: 8px; cursor: grab; } .field-tool:active { cursor: grabbing; } .field-tool span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .swatch { width: 10px; height: 10px; border-radius: 2px; background: var(--recipient-color, var(--accent)); flex-shrink: 0; } .document-stage { flex: 1; overflow: auto; background: hsl(0 0% 8%); display: flex; flex-direction: column; align-items: center; padding: 32px; gap: 20px; } :host-context(sdig-workspace[theme='light']) .document-stage { background: hsl(0 0% 92%); } .recipient-line { cursor: grab; } .routing-role-section { margin-bottom: 14px; padding: 10px; border: 1px solid var(--border-subtle); border-radius: 8px; background: color-mix(in srgb, var(--bg-card) 72%, transparent); transition: border-color 0.14s ease, background 0.14s ease; } .routing-role-section.active-drop { border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); } .routing-role-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } .routing-role-title { font-size: 11px; font-weight: 700; color: var(--text-sec); } .routing-role-description { margin-bottom: 8px; font-size: 10px; line-height: 1.35; color: var(--text-muted); } .role-count { min-width: 18px; height: 18px; padding: 0 6px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: var(--bg-input); color: var(--text-muted); font-size: 10px; } .role-chip { height: 22px; padding: 0 7px; border-radius: 999px; display: inline-flex; align-items: center; background: var(--bg-input); color: var(--text-muted); font-size: 10px; font-weight: 600; } .signing-order-list { position: relative; min-height: 44px; } .signing-order-list.dragging { height: var(--routing-list-height); min-height: var(--routing-list-height); } .signing-order-list::before { content: ''; position: absolute; left: 11px; top: 10px; bottom: 10px; width: 1px; background: var(--border); } .signing-recipient { position: relative; z-index: 1; transition: transform 0.14s ease, opacity 0.14s ease, border-color 0.14s ease; } .signing-order-list.dragging .signing-recipient:not(.signing-drag-overlay) { position: absolute; left: 0; right: 0; top: var(--routing-top); margin-bottom: 0; transition: top 0.16s ease, transform 0.14s ease, opacity 0.14s ease, border-color 0.14s ease; } .signing-placeholder { position: absolute; left: 0; right: 0; top: var(--routing-top); height: var(--routing-row-height); border: 1.5px dashed var(--accent); border-radius: 6px; background: transparent; pointer-events: none; transition: top 0.16s ease; } .signing-drag-overlay { position: absolute; left: 0; right: 0; z-index: 6; top: var(--routing-top); margin-bottom: 0; cursor: grabbing; pointer-events: none; border-color: var(--accent); box-shadow: 0 10px 28px rgba(0,0,0,0.28); transform: scale(1.015); } .role-hint { margin-top: -2px; margin-bottom: 10px; font-size: 10px; line-height: 1.45; color: var(--text-muted); } .page-drop-target { outline: 1px dashed transparent; outline-offset: 8px; } .page-drop-target.drag-over { outline-color: var(--accent); } .field-box { user-select: none; touch-action: none; } .field-box.selected { z-index: 5; cursor: move; } .field-content { width: 100%; height: 100%; display: flex; align-items: center; gap: 6px; pointer-events: none; overflow: hidden; } .field-content span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .resize-handle { position: absolute; z-index: 2; width: 9px; height: 9px; border-radius: 50%; background: var(--bg-card); border: 1.5px solid var(--field-color); box-shadow: 0 0 0 2px var(--bg-card); touch-action: none; } .resize-handle.n { top: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; } .resize-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; } .resize-handle.e { right: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; } .resize-handle.se { right: -6px; bottom: -6px; cursor: nwse-resize; } .resize-handle.s { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; } .resize-handle.sw { left: -6px; bottom: -6px; cursor: nesw-resize; } .resize-handle.w { left: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; } .resize-handle.nw { left: -6px; top: -6px; cursor: nwse-resize; } .field-editor { margin-top: 16px; padding: 12px; } .field-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .field-control { display: flex; flex-direction: column; gap: 4px; font-size: 10px; color: var(--text-muted); } .field-control.full { grid-column: 1 / -1; } .field-control input, .field-control select { width: 100%; height: 30px; padding: 0 8px; border: 1px solid var(--border); border-radius: 5px; background: var(--bg-input); color: var(--text); font-size: 12px; outline: none; } .field-control input:focus, .field-control select:focus { border-color: var(--accent); } @media (max-width: 920px) { .compose-workspace { flex-direction: column; overflow: auto; } .palette, .right-panel { width: 100%; border: 0; border-bottom: 1px solid var(--border-subtle); } .document-page { width: 560px; } } `]; public disconnectedCallback = async () => { this.stopFieldInteraction(); this.stopSigningOrderDrag(); window.removeEventListener('click', this.closeRecipientContextMenu); await super.disconnectedCallback(); }; private recipientColor(id: number): string { return this.recipients.find((recipient) => recipient.id === id)?.color || 'var(--accent)'; } private fieldIcon(type: IFieldPlacement['type']): string { if (type === 'signature') return 'sign'; if (type === 'date') return 'calendar'; if (type === 'check') return 'check'; return 'type'; } private fieldDefinition(type: IFieldPlacement['type']): TFieldDefinition { return fieldDefinitions.find((definition) => definition.type === type) || fieldDefinitions[0]; } private recipientRoleDefinition(role: TRecipientRole) { return recipientRoleDefinitions.find((definition) => definition.role === role) || recipientRoleDefinitions[0]; } private signingRecipients(): IRecipient[] { return this.recipients.filter((recipient) => recipient.role === 'signer'); } private clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } private emitFieldsChange() { this.dispatchEvent(new CustomEvent('fields-change', { detail: { fields: this.fields }, bubbles: true, composed: true, })); } private emitRecipientsChange() { this.dispatchEvent(new CustomEvent('recipients-change', { detail: { recipients: this.recipients }, bubbles: true, composed: true, })); this.dispatchEvent(new CustomEvent('routing-change', { detail: { recipients: this.recipients }, bubbles: true, composed: true, })); } private updateField(fieldId: string, patch: Partial) { this.fields = this.fields.map((field) => field.id === fieldId ? { ...field, ...patch } : field); const field = this.fields.find((currentField) => currentField.id === fieldId); this.dispatchEvent(new CustomEvent('field-update', { detail: { fieldId, field, patch, fields: this.fields }, bubbles: true, composed: true, })); this.emitFieldsChange(); } private updateSelectedField(patch: Partial) { if (!this.selectedFieldId) return; this.updateField(this.selectedFieldId, patch); } private updateSelectedFieldNumber(property: 'x' | 'y' | 'w' | 'h', event: Event) { const value = Number((event.target as HTMLInputElement).value); if (!Number.isFinite(value)) return; const min = property === 'w' || property === 'h' ? 16 : 0; this.updateSelectedField({ [property]: Math.max(min, Math.round(value)) } as Partial); } private resetSelectedFieldSize(field: IFieldPlacement) { const definition = this.fieldDefinition(field.type); this.updateSelectedField({ w: definition.w, h: definition.h }); } private removeSelectedField() { if (!this.selectedFieldId) return; const field = this.fields.find((currentField) => currentField.id === this.selectedFieldId); this.fields = this.fields.filter((field) => field.id !== this.selectedFieldId); this.dispatchEvent(new CustomEvent('field-delete', { detail: { fieldId: this.selectedFieldId, field, fields: this.fields }, bubbles: true, composed: true, })); this.selectedFieldId = null; this.emitFieldsChange(); } private updateRecipientRole(recipientId: number, role: TRecipientRole) { const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === recipientId); if (!recipient) return; const signerCount = this.signingRecipients().length; if (recipient.role === 'signer' && role !== 'signer' && signerCount <= 1) return; this.moveRecipientToRole(recipientId, role); } private moveRecipientToRole(recipientId: number, role: TRecipientRole, targetIndex?: number) { const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === recipientId); if (!recipient) return; const signerCount = this.signingRecipients().length; const nextRole = recipient.role === 'signer' && role !== 'signer' && signerCount <= 1 ? 'signer' : role; const withoutRecipient = this.recipients.filter((currentRecipient) => currentRecipient.id !== recipientId); const nextByRole = new Map(); for (const roleDefinition of recipientRoleDefinitions) { nextByRole.set(roleDefinition.role, withoutRecipient.filter((currentRecipient) => currentRecipient.role === roleDefinition.role)); } const targetMembers = [...(nextByRole.get(nextRole) || [])]; const insertIndex = targetIndex === undefined ? targetMembers.length : this.clamp(targetIndex, 0, targetMembers.length); targetMembers.splice(insertIndex, 0, { ...recipient, role: nextRole }); nextByRole.set(nextRole, targetMembers); this.recipients = recipientRoleDefinitions.flatMap((roleDefinition) => nextByRole.get(roleDefinition.role) || []).map((currentRecipient, index) => ({ ...currentRecipient, order: index + 1 })); this.emitRecipientsChange(); const nextSigners = this.recipients.filter((currentRecipient) => currentRecipient.role === 'signer'); const fallbackSigner = nextSigners[0]; if (nextRole !== 'signer' && fallbackSigner) { this.fields = this.fields.map((field) => field.recipient === recipientId ? { ...field, recipient: fallbackSigner.id } : field); if (this.activeRecipient === recipientId) { this.activeRecipient = fallbackSigner.id; } this.emitFieldsChange(); } } private openRecipientContextMenu(event: MouseEvent, recipient: IRecipient) { event.preventDefault(); event.stopPropagation(); this.recipientContextMenu = { recipientId: recipient.id, x: event.clientX, y: event.clientY }; window.removeEventListener('click', this.closeRecipientContextMenu); setTimeout(() => window.addEventListener('click', this.closeRecipientContextMenu, { once: true }), 0); } private closeRecipientContextMenu = () => { this.recipientContextMenu = null; }; private recipientContextMenuActions(recipient: IRecipient): ISdigContextMenuAction[] { const signerCount = this.signingRecipients().length; return recipientRoleDefinitions.map((roleDefinition) => ({ id: roleDefinition.role, label: roleDefinition.label, selected: recipient.role === roleDefinition.role, disabled: recipient.role === 'signer' && roleDefinition.role !== 'signer' && signerCount <= 1, })); } private handleRecipientContextMenuAction(event: CustomEvent, recipient: IRecipient) { const role = event.detail.id as TRecipientRole; if (!recipientRoleDefinitions.some((roleDefinition) => roleDefinition.role === role)) return; this.updateRecipientRole(recipient.id, role); this.closeRecipientContextMenu(); } private handleDocumentClick = (event: MouseEvent) => { const target = event.target as HTMLElement | null; if (target?.closest('.field-box')) return; this.selectedFieldId = null; }; private startFieldInteraction(event: PointerEvent, field: IFieldPlacement, mode: TFieldInteraction['mode'], handle?: TResizeHandle) { if (event.button !== 0) return; const page = this.shadowRoot?.querySelector('.document-page') as HTMLElement | null; if (!page) return; const pageRect = page.getBoundingClientRect(); this.selectedFieldId = field.id; this.fieldInteraction = { fieldId: field.id, mode, handle, startClientX: event.clientX, startClientY: event.clientY, startField: { ...field }, pageWidth: pageRect.width, pageHeight: pageRect.height, }; event.preventDefault(); event.stopPropagation(); window.addEventListener('pointermove', this.handleFieldPointerMove, { passive: false }); window.addEventListener('pointerup', this.stopFieldInteraction); window.addEventListener('pointercancel', this.stopFieldInteraction); } private startFieldMove(event: PointerEvent, field: IFieldPlacement) { this.startFieldInteraction(event, field, 'move'); } private startFieldResize(event: PointerEvent, field: IFieldPlacement, handle: TResizeHandle) { this.startFieldInteraction(event, field, 'resize', handle); } private handleFieldPointerMove = (event: PointerEvent) => { if (!this.fieldInteraction) return; event.preventDefault(); const interaction = this.fieldInteraction; const dx = event.clientX - interaction.startClientX; const dy = event.clientY - interaction.startClientY; const start = interaction.startField; if (interaction.mode === 'move') { this.updateField(interaction.fieldId, { x: Math.round(this.clamp(start.x + dx, 0, interaction.pageWidth - start.w)), y: Math.round(this.clamp(start.y + dy, 0, interaction.pageHeight - start.h)), }); return; } const minWidth = 32; const minHeight = 24; let x = start.x; let y = start.y; let w = start.w; let h = start.h; const handle = interaction.handle || 'se'; if (handle.includes('e')) { w = this.clamp(start.w + dx, minWidth, interaction.pageWidth - start.x); } if (handle.includes('s')) { h = this.clamp(start.h + dy, minHeight, interaction.pageHeight - start.y); } if (handle.includes('w')) { x = this.clamp(start.x + dx, 0, start.x + start.w - minWidth); w = start.x + start.w - x; } if (handle.includes('n')) { y = this.clamp(start.y + dy, 0, start.y + start.h - minHeight); h = start.y + start.h - y; } this.updateField(interaction.fieldId, { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h), }); }; private stopFieldInteraction = () => { this.fieldInteraction = null; window.removeEventListener('pointermove', this.handleFieldPointerMove); window.removeEventListener('pointerup', this.stopFieldInteraction); window.removeEventListener('pointercancel', this.stopFieldInteraction); }; private visualSigningOrder(): IRecipient[] { if (!this.signingOrderDrag) return this.recipients; const dragged = this.recipients.find((recipient) => recipient.id === this.signingOrderDrag?.recipientId); if (!dragged) return this.recipients; const others = this.recipients.filter((recipient) => recipient.role === this.signingOrderDrag?.targetRole && recipient.id !== dragged.id); const targetIndex = this.clamp(this.signingOrderDrag.targetIndex, 0, others.length); return [...others.slice(0, targetIndex), dragged, ...others.slice(targetIndex)]; } private recipientsForRole(role: TRecipientRole): IRecipient[] { if (!this.signingOrderDrag) return this.recipients.filter((recipient) => recipient.role === role); const dragged = this.recipients.find((recipient) => recipient.id === this.signingOrderDrag?.recipientId); const recipients = this.recipients.filter((recipient) => recipient.role === role && recipient.id !== dragged?.id); if (!dragged || this.signingOrderDrag.targetRole !== role) return recipients; const targetIndex = this.clamp(this.signingOrderDrag.targetIndex, 0, recipients.length); return [...recipients.slice(0, targetIndex), { ...dragged, role }, ...recipients.slice(targetIndex)]; } private startSigningOrderDrag(event: PointerEvent, recipient: IRecipient) { if (event.button !== 0) return; const target = event.target as HTMLElement | null; if (target?.closest('select, input, button')) return; const item = event.currentTarget as HTMLElement; const list = item.closest('.signing-order-list') as HTMLElement | null; if (!list) return; const section = item.closest('.routing-role-section') as HTMLElement | null; const role = (section?.dataset.role || recipient.role) as TRecipientRole; const itemRect = item.getBoundingClientRect(); const listRect = list.getBoundingClientRect(); const marginBottom = Number.parseFloat(globalThis.getComputedStyle(item).marginBottom || '0'); const startIndex = this.recipients.filter((currentRecipient) => currentRecipient.role === role).findIndex((currentRecipient) => currentRecipient.id === recipient.id); this.signingOrderDrag = { recipientId: recipient.id, pointerY: event.clientY, listTop: listRect.top, grabOffsetY: event.clientY - itemRect.top, itemHeight: itemRect.height, itemStep: itemRect.height + marginBottom, targetRole: role, targetIndex: Math.max(0, startIndex), }; event.preventDefault(); window.addEventListener('pointermove', this.handleSigningOrderPointerMove, { passive: false }); window.addEventListener('pointerup', this.stopSigningOrderDrag); window.addEventListener('pointercancel', this.stopSigningOrderDrag); } private handleSigningOrderPointerMove = (event: PointerEvent) => { if (!this.signingOrderDrag) return; event.preventDefault(); const drag = this.signingOrderDrag; const target = this.shadowRoot?.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; const section = target?.closest('.routing-role-section') as HTMLElement | null; const roleCandidate = (section?.dataset.role || drag.targetRole) as TRecipientRole; const draggedRecipient = this.recipients.find((recipient) => recipient.id === drag.recipientId); const targetRole = draggedRecipient?.role === 'signer' && roleCandidate !== 'signer' && this.signingRecipients().length <= 1 ? 'signer' : roleCandidate; const list = this.shadowRoot?.querySelector(`.routing-role-section[data-role="${targetRole}"] .signing-order-list`) as HTMLElement | null; const listRect = list?.getBoundingClientRect(); const listTop = listRect?.top ?? drag.listTop; const targetMemberCount = this.recipients.filter((recipient) => recipient.role === targetRole && recipient.id !== drag.recipientId).length; const draggedCenterY = event.clientY - listTop - drag.grabOffsetY + drag.itemStep / 2; const targetIndex = Math.round(this.clamp(draggedCenterY / drag.itemStep, 0, targetMemberCount)); this.signingOrderDrag = { ...drag, pointerY: event.clientY, listTop, targetRole, targetIndex }; }; private stopSigningOrderDrag = () => { if (this.signingOrderDrag) { this.moveRecipientToRole(this.signingOrderDrag.recipientId, this.signingOrderDrag.targetRole, this.signingOrderDrag.targetIndex); } this.signingOrderDrag = null; window.removeEventListener('pointermove', this.handleSigningOrderPointerMove); window.removeEventListener('pointerup', this.stopSigningOrderDrag); window.removeEventListener('pointercancel', this.stopSigningOrderDrag); }; private addFieldFromDrop(event: DragEvent) { event.preventDefault(); const page = event.currentTarget as HTMLElement; page.classList.remove('drag-over'); const transferredType = event.dataTransfer?.getData('application/x-signature-field') as IFieldPlacement['type']; if (!this.draggedFieldDefinition && !transferredType) return; const definition = this.draggedFieldDefinition || this.fieldDefinition(transferredType); const transferredOffset = event.dataTransfer?.getData('application/x-signature-field-offset'); const offset = this.draggedFieldGrabOffset || (transferredOffset ? JSON.parse(transferredOffset) as { x: number; y: number } : { x: definition.w / 2, y: definition.h / 2 }); const rect = page.getBoundingClientRect(); const x = Math.round(event.clientX - rect.left - offset.x); const y = Math.round(event.clientY - rect.top - offset.y); const nextField: IFieldPlacement = { id: `field_${Date.now()}`, type: definition.type, x: Math.max(0, Math.min(Math.max(0, rect.width - definition.w), x)), y: Math.max(0, Math.min(Math.max(0, rect.height - definition.h), y)), w: definition.w, h: definition.h, page: 1, recipient: this.activeRecipient, label: definition.label, }; this.fields = [...this.fields, nextField]; this.selectedFieldId = nextField.id; this.draggedFieldDefinition = null; this.draggedFieldGrabOffset = null; this.dispatchEvent(new CustomEvent('field-create', { detail: { field: nextField, fields: this.fields }, bubbles: true, composed: true, })); this.emitFieldsChange(); } private startFieldToolDrag(event: DragEvent, fieldType: TFieldDefinition) { const toolRect = (event.currentTarget as HTMLElement).getBoundingClientRect(); const offset = { x: Math.round(event.clientX - toolRect.left), y: Math.round(event.clientY - toolRect.top), }; this.draggedFieldDefinition = fieldType; this.draggedFieldGrabOffset = offset; event.dataTransfer?.setData('application/x-signature-field', fieldType.type); event.dataTransfer?.setData('application/x-signature-field-offset', JSON.stringify(offset)); if (event.dataTransfer) event.dataTransfer.effectAllowed = 'copy'; } private endFieldToolDrag() { this.draggedFieldDefinition = null; this.draggedFieldGrabOffset = null; } private renderFieldEditor(field: IFieldPlacement): TemplateResult { return html`
Field editor
${pill(this.fieldDefinition(field.type).label, 'info', true)}
`; } private renderResizeHandles(field: IFieldPlacement): TemplateResult { return html`${resizeHandles.map((handle) => html` this.startFieldResize(event, field, handle)}>`)}`; } private renderSigningRecipient(recipient: IRecipient, orderNumber: number, options: { overlayTop?: number; rowTop?: number; displayRole?: TRecipientRole } = {}): TemplateResult { const initials = recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join(''); const isOverlay = options.overlayTop !== undefined; const top = options.overlayTop ?? options.rowTop; const displayRole = options.displayRole || recipient.role; return html`
this.openRecipientContextMenu(event, recipient)} @pointerdown=${!isOverlay ? (event: PointerEvent) => this.startSigningOrderDrag(event, recipient) : undefined}>${orderNumber}${initials}
${recipient.name}
${this.recipientRoleDefinition(displayRole).description}
${this.recipientRoleDefinition(displayRole).shortLabel}
`; } private renderRoleSection(roleDefinition: typeof recipientRoleDefinitions[number]): TemplateResult { const role = roleDefinition.role; const members = this.recipientsForRole(role); const isTargetRole = this.signingOrderDrag?.targetRole === role; const draggedRecipientId = this.signingOrderDrag?.recipientId; const draggedRecipient = draggedRecipientId !== undefined ? this.recipients.find((recipient) => recipient.id === draggedRecipientId) : undefined; if (!this.signingOrderDrag) { return html`
${roleDefinition.label}${members.length}
${roleDefinition.description}
${members.map((recipient, index) => this.renderSigningRecipient(recipient, index + 1))}
`; } const visualIndexById = new Map(members.map((recipient, index) => [recipient.id, index])); const overlayTop = isTargetRole ? this.signingOrderDrag.pointerY - this.signingOrderDrag.listTop - this.signingOrderDrag.grabOffsetY : 0; const draggedOrder = draggedRecipient ? (members.findIndex((recipient) => recipient.id === draggedRecipient.id) + 1 || this.signingOrderDrag.targetIndex + 1) : 0; return html`
${roleDefinition.label}${members.filter((recipient) => recipient.id !== draggedRecipientId).length + (isTargetRole ? 1 : 0)}
${roleDefinition.description}
${members.filter((recipient) => recipient.id !== draggedRecipientId).map((recipient) => { const visualIndex = visualIndexById.get(recipient.id) ?? 0; return this.renderSigningRecipient(recipient, visualIndex + 1, { rowTop: visualIndex * this.signingOrderDrag!.itemStep, displayRole: role }); })} ${isTargetRole ? html`
` : ''} ${isTargetRole && draggedRecipient ? this.renderSigningRecipient(draggedRecipient, draggedOrder, { overlayTop, displayRole: role }) : ''}
`; } private renderSigningOrder(): TemplateResult { return html`${recipientRoleDefinitions.map((roleDefinition) => this.renderRoleSection(roleDefinition))}`; } private renderRecipientContextMenu(): TemplateResult { if (!this.recipientContextMenu) return html``; const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === this.recipientContextMenu?.recipientId); if (!recipient) return html``; return html` ) => this.handleRecipientContextMenuAction(event, recipient)} > `; } private renderStepper(): TemplateResult { const labels = ['Upload', 'Place fields', 'Recipients & routing', 'Review & send']; return html`
${labels.map((label, index) => { const stepNumber = index + 1; return html``; })}
doc_8mK3pL · 14 pages · 2.4 MB
`; } public render(): TemplateResult { const document = this.document || demoDocuments[0]; const selectedField = this.fields.find((field) => field.id === this.selectedFieldId); return html` ${topBar({ breadcrumb: ['signature.digital', 'Inbox', 'Compose'], title: document.title, subtitle: pill('Draft · auto-saved'), actions: html`${actionButton('Save draft', 'ghost')}${actionButton('Preview', 'outline', 'eye')}${actionButton('Send for signature', 'primary', 'send')}` })} ${this.renderStepper()}
${this.renderRecipientContextMenu()}
Drag onto document
${fieldDefinitions.map((fieldType) => html`
this.startFieldToolDrag(event, fieldType)} @dragend=${() => this.endFieldToolDrag()}>${icon(fieldType.icon, 14)}${fieldType.label}
`)}
Active for
${this.signingRecipients().map((recipient) => html`
this.activeRecipient = recipient.id}>${recipient.name.split(' ')[0]}${this.fields.filter((field) => field.recipient === recipient.id).length}
`)}
{ event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; (event.currentTarget as HTMLElement).classList.add('drag-over'); }} @dragleave=${(event: DragEvent) => (event.currentTarget as HTMLElement).classList.remove('drag-over')} @drop=${(event: DragEvent) => this.addFieldFromDrop(event)}> ${fakeDocument()} ${this.fields.map((field) => html`
this.selectedFieldId = field.id} @pointerdown=${(event: PointerEvent) => this.startFieldMove(event, field)}>
${icon(this.fieldIcon(field.type), 12)}${field.label}
${this.selectedFieldId === field.id ? this.renderResizeHandles(field) : ''}
`)}
Page 1 of ${document.pages}
${actionButton('Prev', 'outline')}${html`1 / ${document.pages}`}${actionButton('Next', 'outline')}
Routing order · drag to reorder
Choose who signs, who gets the completed copy, and who is notified at every step.
${this.renderSigningOrder()} ${selectedField ? this.renderFieldEditor(selectedField) : ''}
`; } }