@signature.digital/catalog 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signature.digital/catalog",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "private": false,
5
5
  "description": "A comprehensive catalog of customizable web components designed for building and managing e-signature applications.",
6
6
  "exports": {
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@signature.digital/catalog',
6
- version: '1.3.0',
6
+ version: '1.5.0',
7
7
  description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
8
8
  }
@@ -1,4 +1,5 @@
1
1
  // Signature components
2
+ export * from './sdig-contextmenu/index.js';
2
3
  export * from './sdig-signbox/index.js';
3
4
  export * from './sdig-signpad/index.js';
4
5
 
@@ -0,0 +1 @@
1
+ export * from './sdig-contextmenu.js';
@@ -0,0 +1,234 @@
1
+ import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
2
+
3
+ export interface ISdigContextMenuAction {
4
+ id: string;
5
+ label: string;
6
+ description?: string;
7
+ selected?: boolean;
8
+ disabled?: boolean;
9
+ danger?: boolean;
10
+ }
11
+
12
+ export interface ISdigContextMenuActionEventDetail {
13
+ id: string;
14
+ action: ISdigContextMenuAction;
15
+ }
16
+
17
+ type TMenuPosition = {
18
+ x: number;
19
+ y: number;
20
+ ready: boolean;
21
+ };
22
+
23
+ declare global {
24
+ interface HTMLElementTagNameMap {
25
+ 'sdig-contextmenu': SdigContextmenu;
26
+ }
27
+ }
28
+
29
+ @customElement('sdig-contextmenu')
30
+ export class SdigContextmenu extends DeesElement {
31
+ public static demo = () => html`
32
+ <div style="position: relative; min-height: 260px; padding: 24px; --bg-card: hsl(0 0% 7%); --bg-input: hsl(0 0% 9%); --border: hsl(0 0% 14.9%); --border-subtle: hsl(0 0% 11%); --text: hsl(0 0% 98%); --text-sec: hsl(0 0% 63.9%); --text-muted: hsl(0 0% 48%); --hover: rgba(255,255,255,0.06); --error: #ef4444;">
33
+ <sdig-contextmenu
34
+ .anchorX=${80}
35
+ .anchorY=${70}
36
+ .title=${'Recipient'}
37
+ .actions=${[
38
+ { id: 'signer', label: 'Needs signature', selected: true },
39
+ { id: 'copy', label: 'Final copy only' },
40
+ { id: 'updates', label: 'Every step update' },
41
+ ]}
42
+ ></sdig-contextmenu>
43
+ </div>
44
+ `;
45
+ public static demoGroups = ['Signature Digital Primitives'];
46
+
47
+ @property({ type: Number }) public accessor anchorX: number = 0;
48
+ @property({ type: Number }) public accessor anchorY: number = 0;
49
+ @property({ type: String }) public accessor title: string = '';
50
+ @property({ attribute: false }) public accessor actions: ISdigContextMenuAction[] = [];
51
+ @state() private accessor position: TMenuPosition = { x: 0, y: 0, ready: false };
52
+
53
+ private positionUpdateFrame: number | null = null;
54
+
55
+ public static styles = css`
56
+ :host { display: contents; }
57
+
58
+ .menu {
59
+ position: fixed;
60
+ z-index: 1000;
61
+ min-width: 190px;
62
+ max-width: min(280px, calc(100vw - 16px));
63
+ padding: 6px;
64
+ border: 1px solid var(--border, hsl(0 0% 14.9%));
65
+ border-radius: 8px;
66
+ background: var(--bg-card, hsl(0 0% 7%));
67
+ color: var(--text, hsl(0 0% 98%));
68
+ box-shadow: 0 16px 42px rgba(0,0,0,0.36);
69
+ box-sizing: border-box;
70
+ }
71
+
72
+ .title {
73
+ padding: 7px 8px;
74
+ margin-bottom: 4px;
75
+ border-bottom: 1px solid var(--border-subtle, hsl(0 0% 11%));
76
+ font-size: 11px;
77
+ font-weight: 700;
78
+ color: var(--text-sec, hsl(0 0% 63.9%));
79
+ }
80
+
81
+ .action {
82
+ width: 100%;
83
+ min-height: 34px;
84
+ padding: 8px;
85
+ border: 0;
86
+ border-radius: 6px;
87
+ background: transparent;
88
+ color: var(--text-sec, hsl(0 0% 63.9%));
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 8px;
92
+ text-align: left;
93
+ font: inherit;
94
+ font-size: 11px;
95
+ cursor: pointer;
96
+ }
97
+
98
+ .action:hover { background: var(--hover, rgba(255,255,255,0.06)); color: var(--text, hsl(0 0% 98%)); }
99
+ .action.danger { color: var(--error, #ef4444); }
100
+ .action[disabled] { opacity: 0.45; cursor: not-allowed; }
101
+ .action[disabled]:hover { background: transparent; color: var(--text-sec, hsl(0 0% 63.9%)); }
102
+
103
+ .action-mark {
104
+ width: 12px;
105
+ height: 12px;
106
+ display: inline-flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ flex-shrink: 0;
110
+ }
111
+
112
+ .action-mark.selected::before {
113
+ content: '';
114
+ width: 7px;
115
+ height: 4px;
116
+ border-left: 1.5px solid currentColor;
117
+ border-bottom: 1.5px solid currentColor;
118
+ transform: rotate(-45deg) translate(1px, -1px);
119
+ }
120
+
121
+ .action-copy {
122
+ min-width: 0;
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: 2px;
126
+ }
127
+
128
+ .action-label {
129
+ overflow: hidden;
130
+ white-space: nowrap;
131
+ text-overflow: ellipsis;
132
+ }
133
+
134
+ .action-description {
135
+ overflow: hidden;
136
+ white-space: nowrap;
137
+ text-overflow: ellipsis;
138
+ color: var(--text-muted, hsl(0 0% 48%));
139
+ font-size: 10px;
140
+ line-height: 1.25;
141
+ }
142
+ `;
143
+
144
+ public connectedCallback = async () => {
145
+ await super.connectedCallback();
146
+ window.addEventListener('resize', this.queuePositionUpdate);
147
+ };
148
+
149
+ public disconnectedCallback = async () => {
150
+ window.removeEventListener('resize', this.queuePositionUpdate);
151
+ if (this.positionUpdateFrame !== null) {
152
+ globalThis.cancelAnimationFrame(this.positionUpdateFrame);
153
+ this.positionUpdateFrame = null;
154
+ }
155
+ await super.disconnectedCallback();
156
+ };
157
+
158
+ public updated() {
159
+ this.queuePositionUpdate();
160
+ }
161
+
162
+ private queuePositionUpdate = () => {
163
+ if (this.positionUpdateFrame !== null) return;
164
+ this.positionUpdateFrame = globalThis.requestAnimationFrame(() => {
165
+ this.positionUpdateFrame = null;
166
+ this.positionMenu();
167
+ });
168
+ };
169
+
170
+ private positionMenu() {
171
+ const menu = this.shadowRoot?.querySelector('.menu') as HTMLElement | null;
172
+ if (!menu) return;
173
+
174
+ const margin = 8;
175
+ const gap = 4;
176
+ const rect = menu.getBoundingClientRect();
177
+ const viewportWidth = globalThis.innerWidth;
178
+ const viewportHeight = globalThis.innerHeight;
179
+ const spaceRight = viewportWidth - this.anchorX - margin;
180
+ const spaceLeft = this.anchorX - margin;
181
+ const spaceBelow = viewportHeight - this.anchorY - margin;
182
+ const spaceAbove = this.anchorY - margin;
183
+ let x = this.anchorX + gap;
184
+ let y = this.anchorY + gap;
185
+
186
+ if (spaceRight < rect.width + gap && spaceLeft > spaceRight) {
187
+ x = this.anchorX - rect.width - gap;
188
+ }
189
+
190
+ if (spaceBelow < rect.height + gap && spaceAbove > spaceBelow) {
191
+ y = this.anchorY - rect.height - gap;
192
+ }
193
+
194
+ const maxX = Math.max(margin, viewportWidth - rect.width - margin);
195
+ const maxY = Math.max(margin, viewportHeight - rect.height - margin);
196
+ const nextPosition = {
197
+ x: Math.round(Math.max(margin, Math.min(maxX, x))),
198
+ y: Math.round(Math.max(margin, Math.min(maxY, y))),
199
+ ready: true,
200
+ };
201
+
202
+ if (this.position.x !== nextPosition.x || this.position.y !== nextPosition.y || this.position.ready !== nextPosition.ready) {
203
+ this.position = nextPosition;
204
+ }
205
+ }
206
+
207
+ private selectAction(action: ISdigContextMenuAction) {
208
+ if (action.disabled) return;
209
+ this.dispatchEvent(new CustomEvent<ISdigContextMenuActionEventDetail>('contextmenu-action', {
210
+ detail: { id: action.id, action },
211
+ bubbles: true,
212
+ composed: true,
213
+ }));
214
+ }
215
+
216
+ public render(): TemplateResult {
217
+ const x = this.position.ready ? this.position.x : this.anchorX;
218
+ const y = this.position.ready ? this.position.y : this.anchorY;
219
+ return html`
220
+ <div class="menu" style="left: ${x}px; top: ${y}px; visibility: ${this.position.ready ? 'visible' : 'hidden'};" @click=${(event: Event) => event.stopPropagation()} @contextmenu=${(event: Event) => event.preventDefault()}>
221
+ ${this.title ? html`<div class="title">${this.title}</div>` : ''}
222
+ ${this.actions.map((action) => html`
223
+ <button class="action ${action.danger ? 'danger' : ''}" ?disabled=${action.disabled} @click=${() => this.selectAction(action)}>
224
+ <span class="action-mark ${action.selected ? 'selected' : ''}"></span>
225
+ <span class="action-copy">
226
+ <span class="action-label">${action.label}</span>
227
+ ${action.description ? html`<span class="action-description">${action.description}</span>` : ''}
228
+ </span>
229
+ </button>
230
+ `)}
231
+ </div>
232
+ `;
233
+ }
234
+ }
@@ -1,5 +1,7 @@
1
1
  import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
2
- import { actionButton, demoFields, demoRecipients, fakeDocument, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement, type IRecipient } from './sdig-workspace.shared.js';
2
+ import { actionButton, demoFields, demoRecipients, fakeDocument, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement, type IRecipient, type TRecipientRole } from './sdig-workspace.shared.js';
3
+ import '../sdig-contextmenu/index.js';
4
+ import { type ISdigContextMenuAction, type ISdigContextMenuActionEventDetail } from '../sdig-contextmenu/index.js';
3
5
 
4
6
  declare global {
5
7
  interface HTMLElementTagNameMap {
@@ -28,6 +30,23 @@ type TFieldInteraction = {
28
30
  pageHeight: number;
29
31
  };
30
32
 
33
+ type TSigningOrderDrag = {
34
+ recipientId: number;
35
+ pointerY: number;
36
+ listTop: number;
37
+ grabOffsetY: number;
38
+ itemHeight: number;
39
+ itemStep: number;
40
+ targetRole: TRecipientRole;
41
+ targetIndex: number;
42
+ };
43
+
44
+ type TRecipientContextMenu = {
45
+ recipientId: number;
46
+ x: number;
47
+ y: number;
48
+ };
49
+
31
50
  const fieldDefinitions: TFieldDefinition[] = [
32
51
  { type: 'signature', icon: 'sign', label: 'Signature', w: 200, h: 50 },
33
52
  { type: 'initials', icon: 'type', label: 'Initials', w: 120, h: 32 },
@@ -38,6 +57,12 @@ const fieldDefinitions: TFieldDefinition[] = [
38
57
 
39
58
  const resizeHandles: TResizeHandle[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'];
40
59
 
60
+ const recipientRoleDefinitions: Array<{ role: TRecipientRole; label: string; shortLabel: string; description: string }> = [
61
+ { role: 'signer', label: 'Needs signature', shortLabel: 'Signer', description: 'Can receive fields and must sign in order.' },
62
+ { role: 'copy', label: 'Final copy only', shortLabel: 'Copy', description: 'Receives the completed document after signing.' },
63
+ { role: 'updates', label: 'Every step update', shortLabel: 'Updates', description: 'Receives notifications for every routing step.' },
64
+ ];
65
+
41
66
  @customElement('sdig-workspace-compose')
42
67
  export class SdigWorkspaceCompose extends DeesElement {
43
68
  public static demo = () => workspaceDemoFrame(html`<sdig-workspace-compose></sdig-workspace-compose>`);
@@ -45,10 +70,11 @@ export class SdigWorkspaceCompose extends DeesElement {
45
70
 
46
71
  @state() private accessor step: number = 2;
47
72
  @state() private accessor activeRecipient: number = 0;
48
- @state() private accessor draggedRecipientId: number | null = null;
49
73
  @state() private accessor selectedFieldId: string | null = null;
50
74
  @state() private accessor recipients: IRecipient[] = [...demoRecipients];
51
75
  @state() private accessor fields: IFieldPlacement[] = [...demoFields];
76
+ @state() private accessor signingOrderDrag: TSigningOrderDrag | null = null;
77
+ @state() private accessor recipientContextMenu: TRecipientContextMenu | null = null;
52
78
  private draggedFieldDefinition: TFieldDefinition | null = null;
53
79
  private draggedFieldGrabOffset: { x: number; y: number } | null = null;
54
80
  private fieldInteraction: TFieldInteraction | null = null;
@@ -70,7 +96,21 @@ export class SdigWorkspaceCompose extends DeesElement {
70
96
  .document-stage { flex: 1; overflow: auto; background: hsl(0 0% 8%); display: flex; flex-direction: column; align-items: center; padding: 32px; gap: 20px; }
71
97
  :host-context(sdig-workspace[theme='light']) .document-stage { background: hsl(0 0% 92%); }
72
98
  .recipient-line { cursor: grab; }
73
- .recipient-line.dragging { opacity: 0.45; border-color: var(--accent); }
99
+ .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; }
100
+ .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)); }
101
+ .routing-role-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
102
+ .routing-role-title { font-size: 11px; font-weight: 700; color: var(--text-sec); }
103
+ .routing-role-description { margin-bottom: 8px; font-size: 10px; line-height: 1.35; color: var(--text-muted); }
104
+ .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; }
105
+ .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; }
106
+ .signing-order-list { position: relative; min-height: 44px; }
107
+ .signing-order-list.dragging { height: var(--routing-list-height); min-height: var(--routing-list-height); }
108
+ .signing-order-list::before { content: ''; position: absolute; left: 11px; top: 10px; bottom: 10px; width: 1px; background: var(--border); }
109
+ .signing-recipient { position: relative; z-index: 1; transition: transform 0.14s ease, opacity 0.14s ease, border-color 0.14s ease; }
110
+ .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; }
111
+ .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; }
112
+ .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); }
113
+ .role-hint { margin-top: -2px; margin-bottom: 10px; font-size: 10px; line-height: 1.45; color: var(--text-muted); }
74
114
  .page-drop-target { outline: 1px dashed transparent; outline-offset: 8px; }
75
115
  .page-drop-target.drag-over { outline-color: var(--accent); }
76
116
  .field-box { user-select: none; touch-action: none; }
@@ -97,6 +137,8 @@ export class SdigWorkspaceCompose extends DeesElement {
97
137
 
98
138
  public disconnectedCallback = async () => {
99
139
  this.stopFieldInteraction();
140
+ this.stopSigningOrderDrag();
141
+ window.removeEventListener('click', this.closeRecipientContextMenu);
100
142
  await super.disconnectedCallback();
101
143
  };
102
144
 
@@ -115,6 +157,14 @@ export class SdigWorkspaceCompose extends DeesElement {
115
157
  return fieldDefinitions.find((definition) => definition.type === type) || fieldDefinitions[0];
116
158
  }
117
159
 
160
+ private recipientRoleDefinition(role: TRecipientRole) {
161
+ return recipientRoleDefinitions.find((definition) => definition.role === role) || recipientRoleDefinitions[0];
162
+ }
163
+
164
+ private signingRecipients(): IRecipient[] {
165
+ return this.recipients.filter((recipient) => recipient.role === 'signer');
166
+ }
167
+
118
168
  private clamp(value: number, min: number, max: number): number {
119
169
  return Math.max(min, Math.min(max, value));
120
170
  }
@@ -146,6 +196,71 @@ export class SdigWorkspaceCompose extends DeesElement {
146
196
  this.selectedFieldId = null;
147
197
  }
148
198
 
199
+ private updateRecipientRole(recipientId: number, role: TRecipientRole) {
200
+ const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === recipientId);
201
+ if (!recipient) return;
202
+ const signerCount = this.signingRecipients().length;
203
+ if (recipient.role === 'signer' && role !== 'signer' && signerCount <= 1) return;
204
+
205
+ this.moveRecipientToRole(recipientId, role);
206
+ }
207
+
208
+ private moveRecipientToRole(recipientId: number, role: TRecipientRole, targetIndex?: number) {
209
+ const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === recipientId);
210
+ if (!recipient) return;
211
+ const signerCount = this.signingRecipients().length;
212
+ const nextRole = recipient.role === 'signer' && role !== 'signer' && signerCount <= 1 ? 'signer' : role;
213
+ const withoutRecipient = this.recipients.filter((currentRecipient) => currentRecipient.id !== recipientId);
214
+ const nextByRole = new Map<TRecipientRole, IRecipient[]>();
215
+ for (const roleDefinition of recipientRoleDefinitions) {
216
+ nextByRole.set(roleDefinition.role, withoutRecipient.filter((currentRecipient) => currentRecipient.role === roleDefinition.role));
217
+ }
218
+ const targetMembers = [...(nextByRole.get(nextRole) || [])];
219
+ const insertIndex = targetIndex === undefined ? targetMembers.length : this.clamp(targetIndex, 0, targetMembers.length);
220
+ targetMembers.splice(insertIndex, 0, { ...recipient, role: nextRole });
221
+ nextByRole.set(nextRole, targetMembers);
222
+ this.recipients = recipientRoleDefinitions.flatMap((roleDefinition) => nextByRole.get(roleDefinition.role) || []).map((currentRecipient, index) => ({ ...currentRecipient, order: index + 1 }));
223
+
224
+ const nextSigners = this.recipients.filter((currentRecipient) => currentRecipient.role === 'signer');
225
+ const fallbackSigner = nextSigners[0];
226
+
227
+ if (nextRole !== 'signer' && fallbackSigner) {
228
+ this.fields = this.fields.map((field) => field.recipient === recipientId ? { ...field, recipient: fallbackSigner.id } : field);
229
+ if (this.activeRecipient === recipientId) {
230
+ this.activeRecipient = fallbackSigner.id;
231
+ }
232
+ }
233
+ }
234
+
235
+ private openRecipientContextMenu(event: MouseEvent, recipient: IRecipient) {
236
+ event.preventDefault();
237
+ event.stopPropagation();
238
+ this.recipientContextMenu = { recipientId: recipient.id, x: event.clientX, y: event.clientY };
239
+ window.removeEventListener('click', this.closeRecipientContextMenu);
240
+ setTimeout(() => window.addEventListener('click', this.closeRecipientContextMenu, { once: true }), 0);
241
+ }
242
+
243
+ private closeRecipientContextMenu = () => {
244
+ this.recipientContextMenu = null;
245
+ };
246
+
247
+ private recipientContextMenuActions(recipient: IRecipient): ISdigContextMenuAction[] {
248
+ const signerCount = this.signingRecipients().length;
249
+ return recipientRoleDefinitions.map((roleDefinition) => ({
250
+ id: roleDefinition.role,
251
+ label: roleDefinition.label,
252
+ selected: recipient.role === roleDefinition.role,
253
+ disabled: recipient.role === 'signer' && roleDefinition.role !== 'signer' && signerCount <= 1,
254
+ }));
255
+ }
256
+
257
+ private handleRecipientContextMenuAction(event: CustomEvent<ISdigContextMenuActionEventDetail>, recipient: IRecipient) {
258
+ const role = event.detail.id as TRecipientRole;
259
+ if (!recipientRoleDefinitions.some((roleDefinition) => roleDefinition.role === role)) return;
260
+ this.updateRecipientRole(recipient.id, role);
261
+ this.closeRecipientContextMenu();
262
+ }
263
+
149
264
  private handleDocumentClick = (event: MouseEvent) => {
150
265
  const target = event.target as HTMLElement | null;
151
266
  if (target?.closest('.field-box')) return;
@@ -237,18 +352,81 @@ export class SdigWorkspaceCompose extends DeesElement {
237
352
  window.removeEventListener('pointercancel', this.stopFieldInteraction);
238
353
  };
239
354
 
240
- private reorderRecipient(targetId: number) {
241
- if (this.draggedRecipientId === null || this.draggedRecipientId === targetId) return;
242
- const next = [...this.recipients];
243
- const fromIndex = next.findIndex((recipient) => recipient.id === this.draggedRecipientId);
244
- const toIndex = next.findIndex((recipient) => recipient.id === targetId);
245
- if (fromIndex === -1 || toIndex === -1) return;
246
- const [moved] = next.splice(fromIndex, 1);
247
- next.splice(toIndex, 0, moved);
248
- this.recipients = next.map((recipient, index) => ({ ...recipient, order: index + 1 }));
249
- this.draggedRecipientId = null;
355
+ private visualSigningOrder(): IRecipient[] {
356
+ if (!this.signingOrderDrag) return this.recipients;
357
+ const dragged = this.recipients.find((recipient) => recipient.id === this.signingOrderDrag?.recipientId);
358
+ if (!dragged) return this.recipients;
359
+ const others = this.recipients.filter((recipient) => recipient.role === this.signingOrderDrag?.targetRole && recipient.id !== dragged.id);
360
+ const targetIndex = this.clamp(this.signingOrderDrag.targetIndex, 0, others.length);
361
+ return [...others.slice(0, targetIndex), dragged, ...others.slice(targetIndex)];
250
362
  }
251
363
 
364
+ private recipientsForRole(role: TRecipientRole): IRecipient[] {
365
+ if (!this.signingOrderDrag) return this.recipients.filter((recipient) => recipient.role === role);
366
+ const dragged = this.recipients.find((recipient) => recipient.id === this.signingOrderDrag?.recipientId);
367
+ const recipients = this.recipients.filter((recipient) => recipient.role === role && recipient.id !== dragged?.id);
368
+ if (!dragged || this.signingOrderDrag.targetRole !== role) return recipients;
369
+ const targetIndex = this.clamp(this.signingOrderDrag.targetIndex, 0, recipients.length);
370
+ return [...recipients.slice(0, targetIndex), { ...dragged, role }, ...recipients.slice(targetIndex)];
371
+ }
372
+
373
+ private startSigningOrderDrag(event: PointerEvent, recipient: IRecipient) {
374
+ if (event.button !== 0) return;
375
+ const target = event.target as HTMLElement | null;
376
+ if (target?.closest('select, input, button')) return;
377
+ const item = event.currentTarget as HTMLElement;
378
+ const list = item.closest('.signing-order-list') as HTMLElement | null;
379
+ if (!list) return;
380
+ const section = item.closest('.routing-role-section') as HTMLElement | null;
381
+ const role = (section?.dataset.role || recipient.role) as TRecipientRole;
382
+ const itemRect = item.getBoundingClientRect();
383
+ const listRect = list.getBoundingClientRect();
384
+ const marginBottom = Number.parseFloat(globalThis.getComputedStyle(item).marginBottom || '0');
385
+ const startIndex = this.recipients.filter((currentRecipient) => currentRecipient.role === role).findIndex((currentRecipient) => currentRecipient.id === recipient.id);
386
+ this.signingOrderDrag = {
387
+ recipientId: recipient.id,
388
+ pointerY: event.clientY,
389
+ listTop: listRect.top,
390
+ grabOffsetY: event.clientY - itemRect.top,
391
+ itemHeight: itemRect.height,
392
+ itemStep: itemRect.height + marginBottom,
393
+ targetRole: role,
394
+ targetIndex: Math.max(0, startIndex),
395
+ };
396
+ event.preventDefault();
397
+ window.addEventListener('pointermove', this.handleSigningOrderPointerMove, { passive: false });
398
+ window.addEventListener('pointerup', this.stopSigningOrderDrag);
399
+ window.addEventListener('pointercancel', this.stopSigningOrderDrag);
400
+ }
401
+
402
+ private handleSigningOrderPointerMove = (event: PointerEvent) => {
403
+ if (!this.signingOrderDrag) return;
404
+ event.preventDefault();
405
+ const drag = this.signingOrderDrag;
406
+ const target = this.shadowRoot?.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
407
+ const section = target?.closest('.routing-role-section') as HTMLElement | null;
408
+ const roleCandidate = (section?.dataset.role || drag.targetRole) as TRecipientRole;
409
+ const draggedRecipient = this.recipients.find((recipient) => recipient.id === drag.recipientId);
410
+ const targetRole = draggedRecipient?.role === 'signer' && roleCandidate !== 'signer' && this.signingRecipients().length <= 1 ? 'signer' : roleCandidate;
411
+ const list = this.shadowRoot?.querySelector(`.routing-role-section[data-role="${targetRole}"] .signing-order-list`) as HTMLElement | null;
412
+ const listRect = list?.getBoundingClientRect();
413
+ const listTop = listRect?.top ?? drag.listTop;
414
+ const targetMemberCount = this.recipients.filter((recipient) => recipient.role === targetRole && recipient.id !== drag.recipientId).length;
415
+ const draggedCenterY = event.clientY - listTop - drag.grabOffsetY + drag.itemStep / 2;
416
+ const targetIndex = Math.round(this.clamp(draggedCenterY / drag.itemStep, 0, targetMemberCount));
417
+ this.signingOrderDrag = { ...drag, pointerY: event.clientY, listTop, targetRole, targetIndex };
418
+ };
419
+
420
+ private stopSigningOrderDrag = () => {
421
+ if (this.signingOrderDrag) {
422
+ this.moveRecipientToRole(this.signingOrderDrag.recipientId, this.signingOrderDrag.targetRole, this.signingOrderDrag.targetIndex);
423
+ }
424
+ this.signingOrderDrag = null;
425
+ window.removeEventListener('pointermove', this.handleSigningOrderPointerMove);
426
+ window.removeEventListener('pointerup', this.stopSigningOrderDrag);
427
+ window.removeEventListener('pointercancel', this.stopSigningOrderDrag);
428
+ };
429
+
252
430
  private addFieldFromDrop(event: DragEvent) {
253
431
  event.preventDefault();
254
432
  const page = event.currentTarget as HTMLElement;
@@ -305,7 +483,7 @@ export class SdigWorkspaceCompose extends DeesElement {
305
483
  </div>
306
484
  <div class="field-editor-grid">
307
485
  <label class="field-control full">Label<input .value=${field.label} @input=${(event: Event) => this.updateSelectedField({ label: (event.target as HTMLInputElement).value })} /></label>
308
- <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>
486
+ <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>
309
487
  <label class="field-control">X<input type="number" min="0" .value=${String(field.x)} @input=${(event: Event) => this.updateSelectedFieldNumber('x', event)} /></label>
310
488
  <label class="field-control">Y<input type="number" min="0" .value=${String(field.y)} @input=${(event: Event) => this.updateSelectedFieldNumber('y', event)} /></label>
311
489
  <label class="field-control">Width<input type="number" min="16" .value=${String(field.w)} @input=${(event: Event) => this.updateSelectedFieldNumber('w', event)} /></label>
@@ -323,6 +501,58 @@ export class SdigWorkspaceCompose extends DeesElement {
323
501
  return html`${resizeHandles.map((handle) => html`<span class="resize-handle ${handle}" @pointerdown=${(event: PointerEvent) => this.startFieldResize(event, field, handle)}></span>`)}`;
324
502
  }
325
503
 
504
+ private renderSigningRecipient(recipient: IRecipient, orderNumber: number, options: { overlayTop?: number; rowTop?: number; displayRole?: TRecipientRole } = {}): TemplateResult {
505
+ const initials = recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('');
506
+ const isOverlay = options.overlayTop !== undefined;
507
+ const top = options.overlayTop ?? options.rowTop;
508
+ const displayRole = options.displayRole || recipient.role;
509
+ 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>`;
510
+ }
511
+
512
+ private renderRoleSection(roleDefinition: typeof recipientRoleDefinitions[number]): TemplateResult {
513
+ const role = roleDefinition.role;
514
+ const members = this.recipientsForRole(role);
515
+ const isTargetRole = this.signingOrderDrag?.targetRole === role;
516
+ const draggedRecipientId = this.signingOrderDrag?.recipientId;
517
+ const draggedRecipient = draggedRecipientId !== undefined ? this.recipients.find((recipient) => recipient.id === draggedRecipientId) : undefined;
518
+
519
+ if (!this.signingOrderDrag) {
520
+ 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>`;
521
+ }
522
+
523
+ const visualIndexById = new Map(members.map((recipient, index) => [recipient.id, index]));
524
+ const overlayTop = isTargetRole ? this.signingOrderDrag.pointerY - this.signingOrderDrag.listTop - this.signingOrderDrag.grabOffsetY : 0;
525
+ const draggedOrder = draggedRecipient ? (members.findIndex((recipient) => recipient.id === draggedRecipient.id) + 1 || this.signingOrderDrag.targetIndex + 1) : 0;
526
+
527
+ 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;">
528
+ ${members.filter((recipient) => recipient.id !== draggedRecipientId).map((recipient) => {
529
+ const visualIndex = visualIndexById.get(recipient.id) ?? 0;
530
+ return this.renderSigningRecipient(recipient, visualIndex + 1, { rowTop: visualIndex * this.signingOrderDrag!.itemStep, displayRole: role });
531
+ })}
532
+ ${isTargetRole ? html`<div class="signing-placeholder" style="--routing-top: ${this.signingOrderDrag.targetIndex * this.signingOrderDrag.itemStep}px; --routing-row-height: ${this.signingOrderDrag.itemHeight}px;"></div>` : ''}
533
+ ${isTargetRole && draggedRecipient ? this.renderSigningRecipient(draggedRecipient, draggedOrder, { overlayTop, displayRole: role }) : ''}
534
+ </div></div>`;
535
+ }
536
+
537
+ private renderSigningOrder(): TemplateResult {
538
+ return html`${recipientRoleDefinitions.map((roleDefinition) => this.renderRoleSection(roleDefinition))}`;
539
+ }
540
+
541
+ private renderRecipientContextMenu(): TemplateResult {
542
+ if (!this.recipientContextMenu) return html``;
543
+ const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === this.recipientContextMenu?.recipientId);
544
+ if (!recipient) return html``;
545
+ return html`
546
+ <sdig-contextmenu
547
+ .anchorX=${this.recipientContextMenu.x}
548
+ .anchorY=${this.recipientContextMenu.y}
549
+ .title=${recipient.name}
550
+ .actions=${this.recipientContextMenuActions(recipient)}
551
+ @contextmenu-action=${(event: CustomEvent<ISdigContextMenuActionEventDetail>) => this.handleRecipientContextMenuAction(event, recipient)}
552
+ ></sdig-contextmenu>
553
+ `;
554
+ }
555
+
326
556
  private renderStepper(): TemplateResult {
327
557
  const labels = ['Upload', 'Place fields', 'Recipients & routing', 'Review & send'];
328
558
  return html`
@@ -343,12 +573,13 @@ export class SdigWorkspaceCompose extends DeesElement {
343
573
  ${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')}` })}
344
574
  ${this.renderStepper()}
345
575
  <div class="compose-workspace">
576
+ ${this.renderRecipientContextMenu()}
346
577
  <div class="palette">
347
578
  <div class="label-upper">Drag onto document</div>
348
579
  ${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>`)}
349
580
  <div style="height: 1px; background: var(--border-subtle); margin: 20px 0 16px;"></div>
350
581
  <div class="label-upper">Active for</div>
351
- ${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>`)}
582
+ ${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>`)}
352
583
  </div>
353
584
  <div class="document-stage">
354
585
  <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 +590,9 @@ export class SdigWorkspaceCompose extends DeesElement {
359
590
  <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>
360
591
  </div>
361
592
  <div class="right-panel">
362
- <div class="label-upper">Signing order · drag to reorder</div>
363
- ${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>`)}
593
+ <div class="label-upper">Routing order · drag to reorder</div>
594
+ <div class="role-hint">Choose who signs, who gets the completed copy, and who is notified at every step.</div>
595
+ ${this.renderSigningOrder()}
364
596
  ${selectedField ? this.renderFieldEditor(selectedField) : ''}
365
597
  </div>
366
598
  </div>
@@ -13,6 +13,7 @@ export type TWorkspaceView =
13
13
 
14
14
  export type TWorkspaceTheme = 'dark' | 'light';
15
15
  export type TDensity = 'compact' | 'comfortable';
16
+ export type TRecipientRole = 'signer' | 'copy' | 'updates';
16
17
 
17
18
  export interface IDocumentRow {
18
19
  id: string;
@@ -31,6 +32,7 @@ export interface IRecipient {
31
32
  email: string;
32
33
  color: string;
33
34
  order: number;
35
+ role: TRecipientRole;
34
36
  }
35
37
 
36
38
  export interface IFieldPlacement {
@@ -55,9 +57,9 @@ export const demoDocuments: IDocumentRow[] = [
55
57
  ];
56
58
 
57
59
  export const demoRecipients: IRecipient[] = [
58
- { id: 0, name: 'Sarah Chen', email: 'sarah@acme.com', color: '#60a5fa', order: 1 },
59
- { id: 1, name: 'David Park', email: 'd.park@acme.com', color: '#fbbf24', order: 2 },
60
- { id: 2, name: 'Philipp K.', email: 'philipp@lossless.com', color: '#3b82f6', order: 3 },
60
+ { id: 0, name: 'Sarah Chen', email: 'sarah@acme.com', color: '#60a5fa', order: 1, role: 'signer' },
61
+ { id: 1, name: 'David Park', email: 'd.park@acme.com', color: '#fbbf24', order: 2, role: 'signer' },
62
+ { id: 2, name: 'Philipp K.', email: 'philipp@lossless.com', color: '#3b82f6', order: 3, role: 'updates' },
61
63
  ];
62
64
 
63
65
  export const demoFields: IFieldPlacement[] = [