feat(workspace-compose): add recipient routing roles and drag-and-drop routing management

This commit is contained in:
2026-05-02 18:54:34 +00:00
parent f5cc69ed53
commit f08c4bfb7a
4 changed files with 244 additions and 21 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 2026-05-02 - 1.4.0 - feat(workspace-compose)
add recipient routing roles and drag-and-drop routing management
- Introduce recipient roles for signers, final-copy recipients, and step-update recipients.
- Replace simple recipient reordering with role-based drag-and-drop routing sections and contextual role assignment.
- Limit field assignment and active field tools to signing recipients, with automatic field reassignment when a signer is moved out of the signing flow.
## 2026-05-02 - 1.3.0 - feat(workspace)
introduce a responsive signature workspace demo and remove legacy contract editor components
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@signature.digital/catalog',
version: '1.3.0',
version: '1.4.0',
description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
}
@@ -1,5 +1,5 @@
import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, demoFields, demoRecipients, fakeDocument, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement, type IRecipient } from './sdig-workspace.shared.js';
import { actionButton, demoFields, demoRecipients, fakeDocument, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement, type IRecipient, type TRecipientRole } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
@@ -28,6 +28,23 @@ type TFieldInteraction = {
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 },
@@ -38,6 +55,12 @@ const fieldDefinitions: TFieldDefinition[] = [
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`<sdig-workspace-compose></sdig-workspace-compose>`);
@@ -45,10 +68,11 @@ export class SdigWorkspaceCompose extends DeesElement {
@state() private accessor step: number = 2;
@state() private accessor activeRecipient: number = 0;
@state() private accessor draggedRecipientId: number | null = null;
@state() private accessor selectedFieldId: string | null = null;
@state() private accessor recipients: IRecipient[] = [...demoRecipients];
@state() private 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;
@@ -70,7 +94,26 @@ export class SdigWorkspaceCompose extends DeesElement {
.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; }
.recipient-line.dragging { opacity: 0.45; border-color: var(--accent); }
.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); }
.recipient-context-menu { position: fixed; z-index: 100; min-width: 190px; padding: 6px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-card); box-shadow: 0 16px 42px rgba(0,0,0,0.36); }
.recipient-context-title { padding: 7px 8px; font-size: 11px; font-weight: 700; color: var(--text-sec); border-bottom: 1px solid var(--border-subtle); margin-bottom: 4px; }
.context-action { width: 100%; padding: 8px; border-radius: 6px; background: transparent; color: var(--text-sec); display: flex; align-items: center; gap: 8px; text-align: left; font-size: 11px; }
.context-action:hover { background: var(--hover); color: var(--text); }
.context-action[disabled] { opacity: 0.45; cursor: not-allowed; }
.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; }
@@ -97,6 +140,8 @@ export class SdigWorkspaceCompose extends DeesElement {
public disconnectedCallback = async () => {
this.stopFieldInteraction();
this.stopSigningOrderDrag();
window.removeEventListener('click', this.closeRecipientContextMenu);
await super.disconnectedCallback();
};
@@ -115,6 +160,14 @@ export class SdigWorkspaceCompose extends DeesElement {
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));
}
@@ -146,6 +199,54 @@ export class SdigWorkspaceCompose extends DeesElement {
this.selectedFieldId = null;
}
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<TRecipientRole, IRecipient[]>();
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 }));
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;
}
}
}
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 handleDocumentClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest('.field-box')) return;
@@ -237,18 +338,81 @@ export class SdigWorkspaceCompose extends DeesElement {
window.removeEventListener('pointercancel', this.stopFieldInteraction);
};
private reorderRecipient(targetId: number) {
if (this.draggedRecipientId === null || this.draggedRecipientId === targetId) return;
const next = [...this.recipients];
const fromIndex = next.findIndex((recipient) => recipient.id === this.draggedRecipientId);
const toIndex = next.findIndex((recipient) => recipient.id === targetId);
if (fromIndex === -1 || toIndex === -1) return;
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
this.recipients = next.map((recipient, index) => ({ ...recipient, order: index + 1 }));
this.draggedRecipientId = null;
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;
@@ -305,7 +469,7 @@ export class SdigWorkspaceCompose extends DeesElement {
</div>
<div class="field-editor-grid">
<label class="field-control full">Label<input .value=${field.label} @input=${(event: Event) => this.updateSelectedField({ label: (event.target as HTMLInputElement).value })} /></label>
<label class="field-control full">Assigned signer<select .value=${String(field.recipient)} @change=${(event: Event) => this.updateSelectedField({ recipient: Number((event.target as HTMLSelectElement).value) })}>${this.recipients.map((recipient) => html`<option value=${String(recipient.id)}>${recipient.order}. ${recipient.name}</option>`)}</select></label>
<label class="field-control full">Assigned signer<select .value=${String(field.recipient)} @change=${(event: Event) => this.updateSelectedField({ recipient: Number((event.target as HTMLSelectElement).value) })}>${this.signingRecipients().map((recipient) => html`<option value=${String(recipient.id)}>${recipient.order}. ${recipient.name}</option>`)}</select></label>
<label class="field-control">X<input type="number" min="0" .value=${String(field.x)} @input=${(event: Event) => this.updateSelectedFieldNumber('x', event)} /></label>
<label class="field-control">Y<input type="number" min="0" .value=${String(field.y)} @input=${(event: Event) => this.updateSelectedFieldNumber('y', event)} /></label>
<label class="field-control">Width<input type="number" min="16" .value=${String(field.w)} @input=${(event: Event) => this.updateSelectedFieldNumber('w', event)} /></label>
@@ -323,6 +487,54 @@ export class SdigWorkspaceCompose extends DeesElement {
return html`${resizeHandles.map((handle) => html`<span class="resize-handle ${handle}" @pointerdown=${(event: PointerEvent) => this.startFieldResize(event, field, handle)}></span>`)}`;
}
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`<div class="recipient-line signing-recipient ${isOverlay ? 'signing-drag-overlay' : ''}" style="${top !== undefined ? `--routing-top: ${top}px;` : ''}" @contextmenu=${(event: MouseEvent) => this.openRecipientContextMenu(event, recipient)} @pointerdown=${!isOverlay ? (event: PointerEvent) => this.startSigningOrderDrag(event, recipient) : undefined}><span class="mono" style="width: 14px; font-size: 10px; color: ${isOverlay ? 'var(--accent)' : 'var(--text-muted)'};">${orderNumber}</span><span class="avatar" style="background: ${recipient.color};">${initials}</span><div style="flex: 1; min-width: 0;"><div style="font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name}</div><div class="mono" style="font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${this.recipientRoleDefinition(displayRole).description}</div></div><span class="role-chip">${this.recipientRoleDefinition(displayRole).shortLabel}</span></div>`;
}
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`<div class="routing-role-section" data-role=${role}><div class="routing-role-head"><span class="routing-role-title">${roleDefinition.label}</span><span class="role-count">${members.length}</span></div><div class="routing-role-description">${roleDefinition.description}</div><div class="signing-order-list">${members.map((recipient, index) => this.renderSigningRecipient(recipient, index + 1))}</div></div>`;
}
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`<div class="routing-role-section ${isTargetRole ? 'active-drop' : ''}" data-role=${role}><div class="routing-role-head"><span class="routing-role-title">${roleDefinition.label}</span><span class="role-count">${members.filter((recipient) => recipient.id !== draggedRecipientId).length + (isTargetRole ? 1 : 0)}</span></div><div class="routing-role-description">${roleDefinition.description}</div><div class="signing-order-list dragging" style="--routing-list-height: ${Math.max(1, members.length) * this.signingOrderDrag.itemStep}px;">
${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`<div class="signing-placeholder" style="--routing-top: ${this.signingOrderDrag.targetIndex * this.signingOrderDrag.itemStep}px; --routing-row-height: ${this.signingOrderDrag.itemHeight}px;"></div>` : ''}
${isTargetRole && draggedRecipient ? this.renderSigningRecipient(draggedRecipient, draggedOrder, { overlayTop, displayRole: role }) : ''}
</div></div>`;
}
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``;
const signerCount = this.signingRecipients().length;
return html`<div class="recipient-context-menu" style="left: ${this.recipientContextMenu.x}px; top: ${this.recipientContextMenu.y}px;" @click=${(event: Event) => event.stopPropagation()}>
<div class="recipient-context-title">${recipient.name}</div>
${recipientRoleDefinitions.map((roleDefinition) => html`<button class="context-action" ?disabled=${recipient.role === 'signer' && roleDefinition.role !== 'signer' && signerCount <= 1} @click=${() => { this.updateRecipientRole(recipient.id, roleDefinition.role); this.closeRecipientContextMenu(); }}>${recipient.role === roleDefinition.role ? icon('check', 12) : html`<span style="width: 12px;"></span>`}<span>${roleDefinition.label}</span></button>`)}
</div>`;
}
private renderStepper(): TemplateResult {
const labels = ['Upload', 'Place fields', 'Recipients & routing', 'Review & send'];
return html`
@@ -343,12 +555,13 @@ export class SdigWorkspaceCompose extends DeesElement {
${topBar({ breadcrumb: ['signature.digital', 'Inbox', 'Compose'], title: 'Master Services Agreement', subtitle: pill('Draft · auto-saved'), actions: html`${actionButton('Save draft', 'ghost')}${actionButton('Preview', 'outline', 'eye')}${actionButton('Send for signature', 'primary', 'send')}` })}
${this.renderStepper()}
<div class="compose-workspace">
${this.renderRecipientContextMenu()}
<div class="palette">
<div class="label-upper">Drag onto document</div>
${fieldDefinitions.map((fieldType) => html`<div class="field-tool" style="--tool-w: ${fieldType.w}px; --tool-h: ${fieldType.h}px; --recipient-color: ${this.recipientColor(this.activeRecipient)};" draggable="true" @dragstart=${(event: DragEvent) => this.startFieldToolDrag(event, fieldType)} @dragend=${() => this.endFieldToolDrag()}>${icon(fieldType.icon, 14)}<span style="flex: 1;">${fieldType.label}</span></div>`)}
<div style="height: 1px; background: var(--border-subtle); margin: 20px 0 16px;"></div>
<div class="label-upper">Active for</div>
${this.recipients.map((recipient) => html`<div class="recipient-line ${this.activeRecipient === recipient.id ? 'active' : ''}" @click=${() => this.activeRecipient = recipient.id}><span class="swatch" style="--recipient-color: ${recipient.color};"></span><span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name.split(' ')[0]}</span><span class="mono" style="font-size: 10px; color: var(--text-muted);">${this.fields.filter((field) => field.recipient === recipient.id).length}</span></div>`)}
${this.signingRecipients().map((recipient) => html`<div class="recipient-line ${this.activeRecipient === recipient.id ? 'active' : ''}" @click=${() => this.activeRecipient = recipient.id}><span class="swatch" style="--recipient-color: ${recipient.color};"></span><span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name.split(' ')[0]}</span><span class="mono" style="font-size: 10px; color: var(--text-muted);">${this.fields.filter((field) => field.recipient === recipient.id).length}</span></div>`)}
</div>
<div class="document-stage">
<div class="document-page page-drop-target" @click=${this.handleDocumentClick} @dragover=${(event: DragEvent) => { 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)}>
@@ -359,8 +572,9 @@ export class SdigWorkspaceCompose extends DeesElement {
<div style="display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--text-muted);">${actionButton('Prev', 'outline')}${html`<span class="mono">1 / 14</span>`}${actionButton('Next', 'outline')}</div>
</div>
<div class="right-panel">
<div class="label-upper">Signing order · drag to reorder</div>
${this.recipients.map((recipient) => html`<div class="recipient-line ${this.draggedRecipientId === recipient.id ? 'dragging' : ''}" draggable="true" @dragstart=${() => this.draggedRecipientId = recipient.id} @dragover=${(event: DragEvent) => event.preventDefault()} @drop=${() => this.reorderRecipient(recipient.id)} @dragend=${() => this.draggedRecipientId = null}><span class="mono" style="width: 14px; font-size: 10px; color: var(--text-muted);">${recipient.order}</span><span class="avatar" style="background: ${recipient.color};">${recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('')}</span><div style="flex: 1; min-width: 0;"><div style="font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name}</div><div class="mono" style="font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.email}</div></div>${icon('more', 12)}</div>`)}
<div class="label-upper">Routing order · drag to reorder</div>
<div class="role-hint">Choose who signs, who gets the completed copy, and who is notified at every step.</div>
${this.renderSigningOrder()}
${selectedField ? this.renderFieldEditor(selectedField) : ''}
</div>
</div>
@@ -13,6 +13,7 @@ export type TWorkspaceView =
export type TWorkspaceTheme = 'dark' | 'light';
export type TDensity = 'compact' | 'comfortable';
export type TRecipientRole = 'signer' | 'copy' | 'updates';
export interface IDocumentRow {
id: string;
@@ -31,6 +32,7 @@ export interface IRecipient {
email: string;
color: string;
order: number;
role: TRecipientRole;
}
export interface IFieldPlacement {
@@ -55,9 +57,9 @@ export const demoDocuments: IDocumentRow[] = [
];
export const demoRecipients: IRecipient[] = [
{ id: 0, name: 'Sarah Chen', email: 'sarah@acme.com', color: '#60a5fa', order: 1 },
{ id: 1, name: 'David Park', email: 'd.park@acme.com', color: '#fbbf24', order: 2 },
{ id: 2, name: 'Philipp K.', email: 'philipp@lossless.com', color: '#3b82f6', order: 3 },
{ id: 0, name: 'Sarah Chen', email: 'sarah@acme.com', color: '#60a5fa', order: 1, role: 'signer' },
{ id: 1, name: 'David Park', email: 'd.park@acme.com', color: '#fbbf24', order: 2, role: 'signer' },
{ id: 2, name: 'Philipp K.', email: 'philipp@lossless.com', color: '#3b82f6', order: 3, role: 'updates' },
];
export const demoFields: IFieldPlacement[] = [