Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ac46728a7 | |||
| b6b4698028 | |||
| 7baff5004c | |||
| b3a30bfb96 | |||
| 79ca3a07f1 | |||
| a6324f867f | |||
| a0dd552628 | |||
| d693af8a26 | |||
| 26bf48e87a | |||
| fd53bc3db8 | |||
| 87940efdef | |||
| f08c4bfb7a |
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-05 - 1.7.0 - feat(workspace)
|
||||
make compose and sign views accept document, recipient, and field state via properties and emit field and routing change events
|
||||
|
||||
- Pass active document, recipients, and fields into workspace compose and sign views instead of relying on demo-only state.
|
||||
- Emit field-create, field-update, field-delete, fields-change, recipients-change, and routing-change events from the compose workspace.
|
||||
- Use document metadata for compose titles and page counts so the UI reflects the selected document.
|
||||
|
||||
## 2026-05-05 - 1.6.1 - fix(workspace)
|
||||
pass the active document into sign and audit views
|
||||
|
||||
- Add activeDocumentId support in the workspace container to resolve the selected document for nested views.
|
||||
- Update sign and audit components to accept a document property and render dynamic title, sender, page count, breadcrumb, and status data instead of hardcoded values.
|
||||
|
||||
## 2026-05-02 - 1.6.0 - feat(workspace)
|
||||
support configurable inbox documents and emit document-open events
|
||||
|
||||
- Adds a documents property to the workspace and inbox components so document lists, counts, filters, and attention metrics use injected data instead of demo data.
|
||||
- Passes documents from the workspace container into the inbox view for consistent sidebar and inbox counts.
|
||||
- Emits a bubbling document-open event when an inbox item is opened to enable parent integrations and external handling.
|
||||
|
||||
## 2026-05-02 - 1.5.1 - fix(sdig-workspace)
|
||||
make workspace view externally controllable and preserve explicit initial state
|
||||
|
||||
- change the workspace view from internal state to a reflected public property
|
||||
- only apply initialView during connection when no non-default view has already been set
|
||||
|
||||
## 2026-05-02 - 1.5.0 - feat(elements)
|
||||
add reusable context menu element for recipient role selection
|
||||
|
||||
- introduces a new sdig-contextmenu web component with configurable actions, selection state, and viewport-aware positioning
|
||||
- exports the new context menu from the shared elements index
|
||||
- refactors workspace compose to use the reusable context menu for recipient role changes while preserving signer role safeguards
|
||||
|
||||
## 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@signature.digital/catalog",
|
||||
"version": "1.3.0",
|
||||
"version": "1.7.0",
|
||||
"private": false,
|
||||
"description": "A comprehensive catalog of customizable web components designed for building and managing e-signature applications.",
|
||||
"exports": {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@signature.digital/catalog',
|
||||
version: '1.3.0',
|
||||
version: '1.7.0',
|
||||
description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Signature components
|
||||
export * from './sdig-contextmenu/index.js';
|
||||
export * from './sdig-signbox/index.js';
|
||||
export * from './sdig-signpad/index.js';
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './sdig-contextmenu.js';
|
||||
@@ -0,0 +1,234 @@
|
||||
import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
|
||||
export interface ISdigContextMenuAction {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface ISdigContextMenuActionEventDetail {
|
||||
id: string;
|
||||
action: ISdigContextMenuAction;
|
||||
}
|
||||
|
||||
type TMenuPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sdig-contextmenu': SdigContextmenu;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sdig-contextmenu')
|
||||
export class SdigContextmenu extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<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;">
|
||||
<sdig-contextmenu
|
||||
.anchorX=${80}
|
||||
.anchorY=${70}
|
||||
.title=${'Recipient'}
|
||||
.actions=${[
|
||||
{ id: 'signer', label: 'Needs signature', selected: true },
|
||||
{ id: 'copy', label: 'Final copy only' },
|
||||
{ id: 'updates', label: 'Every step update' },
|
||||
]}
|
||||
></sdig-contextmenu>
|
||||
</div>
|
||||
`;
|
||||
public static demoGroups = ['Signature Digital Primitives'];
|
||||
|
||||
@property({ type: Number }) public accessor anchorX: number = 0;
|
||||
@property({ type: Number }) public accessor anchorY: number = 0;
|
||||
@property({ type: String }) public accessor title: string = '';
|
||||
@property({ attribute: false }) public accessor actions: ISdigContextMenuAction[] = [];
|
||||
@state() private accessor position: TMenuPosition = { x: 0, y: 0, ready: false };
|
||||
|
||||
private positionUpdateFrame: number | null = null;
|
||||
|
||||
public static styles = css`
|
||||
:host { display: contents; }
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 190px;
|
||||
max-width: min(280px, calc(100vw - 16px));
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border, hsl(0 0% 14.9%));
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, hsl(0 0% 7%));
|
||||
color: var(--text, hsl(0 0% 98%));
|
||||
box-shadow: 0 16px 42px rgba(0,0,0,0.36);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 7px 8px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-subtle, hsl(0 0% 11%));
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-sec, hsl(0 0% 63.9%));
|
||||
}
|
||||
|
||||
.action {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-sec, hsl(0 0% 63.9%));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover { background: var(--hover, rgba(255,255,255,0.06)); color: var(--text, hsl(0 0% 98%)); }
|
||||
.action.danger { color: var(--error, #ef4444); }
|
||||
.action[disabled] { opacity: 0.45; cursor: not-allowed; }
|
||||
.action[disabled]:hover { background: transparent; color: var(--text-sec, hsl(0 0% 63.9%)); }
|
||||
|
||||
.action-mark {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-mark.selected::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 4px;
|
||||
border-left: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: rotate(-45deg) translate(1px, -1px);
|
||||
}
|
||||
|
||||
.action-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.action-description {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-muted, hsl(0 0% 48%));
|
||||
font-size: 10px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
`;
|
||||
|
||||
public connectedCallback = async () => {
|
||||
await super.connectedCallback();
|
||||
window.addEventListener('resize', this.queuePositionUpdate);
|
||||
};
|
||||
|
||||
public disconnectedCallback = async () => {
|
||||
window.removeEventListener('resize', this.queuePositionUpdate);
|
||||
if (this.positionUpdateFrame !== null) {
|
||||
globalThis.cancelAnimationFrame(this.positionUpdateFrame);
|
||||
this.positionUpdateFrame = null;
|
||||
}
|
||||
await super.disconnectedCallback();
|
||||
};
|
||||
|
||||
public updated() {
|
||||
this.queuePositionUpdate();
|
||||
}
|
||||
|
||||
private queuePositionUpdate = () => {
|
||||
if (this.positionUpdateFrame !== null) return;
|
||||
this.positionUpdateFrame = globalThis.requestAnimationFrame(() => {
|
||||
this.positionUpdateFrame = null;
|
||||
this.positionMenu();
|
||||
});
|
||||
};
|
||||
|
||||
private positionMenu() {
|
||||
const menu = this.shadowRoot?.querySelector('.menu') as HTMLElement | null;
|
||||
if (!menu) return;
|
||||
|
||||
const margin = 8;
|
||||
const gap = 4;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const viewportWidth = globalThis.innerWidth;
|
||||
const viewportHeight = globalThis.innerHeight;
|
||||
const spaceRight = viewportWidth - this.anchorX - margin;
|
||||
const spaceLeft = this.anchorX - margin;
|
||||
const spaceBelow = viewportHeight - this.anchorY - margin;
|
||||
const spaceAbove = this.anchorY - margin;
|
||||
let x = this.anchorX + gap;
|
||||
let y = this.anchorY + gap;
|
||||
|
||||
if (spaceRight < rect.width + gap && spaceLeft > spaceRight) {
|
||||
x = this.anchorX - rect.width - gap;
|
||||
}
|
||||
|
||||
if (spaceBelow < rect.height + gap && spaceAbove > spaceBelow) {
|
||||
y = this.anchorY - rect.height - gap;
|
||||
}
|
||||
|
||||
const maxX = Math.max(margin, viewportWidth - rect.width - margin);
|
||||
const maxY = Math.max(margin, viewportHeight - rect.height - margin);
|
||||
const nextPosition = {
|
||||
x: Math.round(Math.max(margin, Math.min(maxX, x))),
|
||||
y: Math.round(Math.max(margin, Math.min(maxY, y))),
|
||||
ready: true,
|
||||
};
|
||||
|
||||
if (this.position.x !== nextPosition.x || this.position.y !== nextPosition.y || this.position.ready !== nextPosition.ready) {
|
||||
this.position = nextPosition;
|
||||
}
|
||||
}
|
||||
|
||||
private selectAction(action: ISdigContextMenuAction) {
|
||||
if (action.disabled) return;
|
||||
this.dispatchEvent(new CustomEvent<ISdigContextMenuActionEventDetail>('contextmenu-action', {
|
||||
detail: { id: action.id, action },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const x = this.position.ready ? this.position.x : this.anchorX;
|
||||
const y = this.position.ready ? this.position.y : this.anchorY;
|
||||
return html`
|
||||
<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()}>
|
||||
${this.title ? html`<div class="title">${this.title}</div>` : ''}
|
||||
${this.actions.map((action) => html`
|
||||
<button class="action ${action.danger ? 'danger' : ''}" ?disabled=${action.disabled} @click=${() => this.selectAction(action)}>
|
||||
<span class="action-mark ${action.selected ? 'selected' : ''}"></span>
|
||||
<span class="action-copy">
|
||||
<span class="action-label">${action.label}</span>
|
||||
${action.description ? html`<span class="action-description">${action.description}</span>` : ''}
|
||||
</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeesElement, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { actionButton, demoRecipients, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame } from './sdig-workspace.shared.js';
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { actionButton, demoDocuments, demoRecipients, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame, type IDocumentRow } from './sdig-workspace.shared.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -12,6 +12,8 @@ export class SdigWorkspaceAudit extends DeesElement {
|
||||
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-audit></sdig-workspace-audit>`);
|
||||
public static demoGroups = ['Signature Digital Workspace'];
|
||||
|
||||
@property({ attribute: false }) public accessor document: IDocumentRow = demoDocuments[1];
|
||||
|
||||
public static styles = [workspaceBaseStyles, css`
|
||||
.audit-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 20px; }
|
||||
.event-row { display: grid; grid-template-columns: 24px 180px 1fr 200px; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border-subtle); align-items: center; }
|
||||
@@ -19,6 +21,7 @@ export class SdigWorkspaceAudit extends DeesElement {
|
||||
`];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const document = this.document || demoDocuments[1];
|
||||
const events = [
|
||||
['2026-05-02 14:32:18 UTC', 'Sarah Chen', 'Document signed', '81.221.4.18 · Brussels, BE', '0x4a7b…f29c', 'success'],
|
||||
['2026-05-02 14:31:54 UTC', 'Sarah Chen', 'Signature adopted (typed)', '81.221.4.18 · Brussels, BE', '0x4a7b…f29c', 'info'],
|
||||
@@ -27,7 +30,7 @@ export class SdigWorkspaceAudit extends DeesElement {
|
||||
['2026-05-02 10:54:22 UTC', 'Philipp K.', 'Document created', '92.42.114.7 · Berlin, DE', '0x1c8a…3b6f', 'default'],
|
||||
];
|
||||
return html`
|
||||
${topBar({ breadcrumb: ['signature.digital', 'Inbox', 'doc_8mK3pL', 'Audit Trail'], title: 'Audit Trail', subtitle: pill('completed · cryptographically sealed', 'success', true), actions: html`${actionButton('Certificate (PDF)', 'outline', 'download')}${actionButton('Verify on chain', 'outline', 'hash')}` })}
|
||||
${topBar({ breadcrumb: ['signature.digital', 'Inbox', document.id, 'Audit Trail'], title: `Audit Trail · ${document.title}`, subtitle: pill(`${document.status} · cryptographically sealed`, document.status === 'declined' ? 'error' : document.status === 'signed' ? 'success' : 'info', true), actions: html`${actionButton('Certificate (PDF)', 'outline', 'download')}${actionButton('Verify on chain', 'outline', 'hash')}` })}
|
||||
<div class="content-scroll audit-grid">
|
||||
<div class="card"><div style="height: 36px; padding: 0 16px; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; justify-content: space-between;"><span style="font-size: 12px; font-weight: 600;">Event log</span><span class="mono" style="font-size: 10px; color: var(--text-muted);">${events.length} events · immutable</span></div>${events.map((event) => html`<div class="event-row"><div><span style="display: block; width: 8px; height: 8px; border-radius: 50%; background: ${event[5] === 'success' ? 'var(--success)' : event[5] === 'info' ? 'var(--accent)' : 'var(--text-dim)'};"></span></div><div class="mono hide-mobile" style="font-size: 11px; color: var(--text-muted);">${event[0]}</div><div><div style="font-size: 12px; font-weight: 500;">${event[2]}</div><div style="font-size: 10px; color: var(--text-muted); margin-top: 2px;">by ${event[1]} ${event[4] ? html`<span class="mono" style="color: var(--accent); margin-left: 8px;">${event[4]}</span>` : ''}</div></div><div class="mono hide-mobile" style="font-size: 10px; color: var(--text-muted); text-align: right;">${event[3]}</div></div>`)}</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;"><div class="card" style="padding: 16px;"><div class="label-upper">Document hash</div><div class="mono" style="font-size: 11px; color: var(--accent); word-break: break-all; line-height: 1.5; padding: 10px; background: var(--bg-el); border-radius: 4px; border: 1px solid var(--border-subtle);">0x4a7b8f29c91e3d2a5b6c8e0f1d3c5a7b9d2e4f6a8c1e3d5f7b9c1e3a5b7d9f0e</div></div><div class="card" style="padding: 16px;"><div class="label-upper">Signers</div>${demoRecipients.map((recipient) => html`<div class="recipient-line"><span class="avatar" style="background: ${recipient.color};">${recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('')}</span><div style="flex: 1;"><div style="font-size: 12px;">${recipient.name}</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">${recipient.email}</div></div>${icon('check', 12)}</div>`)}</div><div class="card" style="padding: 16px; border-color: rgba(34,197,94,0.2);"><div style="display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--success); margin-bottom: 6px;">${icon('shield', 13)} eIDAS Qualified · ESIGN Act compliant</div><div style="font-size: 11px; color: var(--text-muted); line-height: 1.55;">Open-source verifier available. Anyone can independently validate this signature against the public ledger.</div></div></div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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 { 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 {
|
||||
@@ -28,6 +30,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 +57,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 +70,12 @@ 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];
|
||||
@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;
|
||||
@@ -70,7 +97,21 @@ 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); }
|
||||
.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 +138,8 @@ export class SdigWorkspaceCompose extends DeesElement {
|
||||
|
||||
public disconnectedCallback = async () => {
|
||||
this.stopFieldInteraction();
|
||||
this.stopSigningOrderDrag();
|
||||
window.removeEventListener('click', this.closeRecipientContextMenu);
|
||||
await super.disconnectedCallback();
|
||||
};
|
||||
|
||||
@@ -115,12 +158,48 @@ 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));
|
||||
}
|
||||
|
||||
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<IFieldPlacement>) {
|
||||
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<IFieldPlacement>) {
|
||||
@@ -142,8 +221,82 @@ export class SdigWorkspaceCompose extends DeesElement {
|
||||
|
||||
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<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 }));
|
||||
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<ISdigContextMenuActionEventDetail>, 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) => {
|
||||
@@ -237,18 +390,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;
|
||||
@@ -276,6 +492,12 @@ export class SdigWorkspaceCompose extends DeesElement {
|
||||
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) {
|
||||
@@ -305,7 +527,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 +545,58 @@ 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``;
|
||||
return html`
|
||||
<sdig-contextmenu
|
||||
.anchorX=${this.recipientContextMenu.x}
|
||||
.anchorY=${this.recipientContextMenu.y}
|
||||
.title=${recipient.name}
|
||||
.actions=${this.recipientContextMenuActions(recipient)}
|
||||
@contextmenu-action=${(event: CustomEvent<ISdigContextMenuActionEventDetail>) => this.handleRecipientContextMenuAction(event, recipient)}
|
||||
></sdig-contextmenu>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStepper(): TemplateResult {
|
||||
const labels = ['Upload', 'Place fields', 'Recipients & routing', 'Review & send'];
|
||||
return html`
|
||||
@@ -337,30 +611,33 @@ export class SdigWorkspaceCompose extends DeesElement {
|
||||
}
|
||||
|
||||
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: 'Master Services Agreement', subtitle: pill('Draft · auto-saved'), actions: html`${actionButton('Save draft', 'ghost')}${actionButton('Preview', 'outline', 'eye')}${actionButton('Send for signature', 'primary', 'send')}` })}
|
||||
${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()}
|
||||
<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)}>
|
||||
${fakeDocument()}
|
||||
${this.fields.map((field) => html`<div class="field-box ${this.selectedFieldId === field.id ? 'selected' : ''}" style="--x: ${field.x}px; --y: ${field.y}px; --w: ${field.w}px; --h: ${field.h}px; --field-color: ${this.recipientColor(field.recipient)};" @click=${() => this.selectedFieldId = field.id} @pointerdown=${(event: PointerEvent) => this.startFieldMove(event, field)}><div class="field-content">${icon(this.fieldIcon(field.type), 12)}<span>${field.label}</span></div>${this.selectedFieldId === field.id ? this.renderResizeHandles(field) : ''}</div>`)}
|
||||
<div class="mono" style="position: absolute; bottom: 12px; right: 16px; font-size: 9px; color: hsl(0 0% 60%);">Page 1 of 14</div>
|
||||
<div class="mono" style="position: absolute; bottom: 12px; right: 16px; font-size: 9px; color: hsl(0 0% 60%);">Page 1 of ${document.pages}</div>
|
||||
</div>
|
||||
<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 style="display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--text-muted);">${actionButton('Prev', 'outline')}${html`<span class="mono">1 / ${document.pages}</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>
|
||||
|
||||
@@ -14,6 +14,7 @@ export class SdigWorkspaceInbox extends DeesElement {
|
||||
public static demoGroups = ['Signature Digital Workspace'];
|
||||
|
||||
@property({ type: String }) public accessor density: TDensity = 'comfortable';
|
||||
@property({ attribute: false }) public accessor documents: IDocumentRow[] = demoDocuments;
|
||||
@state() private accessor filter: string = 'all';
|
||||
@state() private accessor search: string = '';
|
||||
|
||||
@@ -45,7 +46,7 @@ export class SdigWorkspaceInbox extends DeesElement {
|
||||
`];
|
||||
|
||||
private get filteredDocuments(): IDocumentRow[] {
|
||||
return demoDocuments
|
||||
return this.documents
|
||||
.filter((doc) => this.filter === 'all' || doc.status === this.filter)
|
||||
.filter((doc) => !this.search || doc.title.toLowerCase().includes(this.search.toLowerCase()));
|
||||
}
|
||||
@@ -62,23 +63,30 @@ export class SdigWorkspaceInbox extends DeesElement {
|
||||
}
|
||||
|
||||
private openDocument(doc: IDocumentRow) {
|
||||
this.dispatchEvent(new CustomEvent('document-open', {
|
||||
detail: { document: doc },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
requestWorkspaceView(this, doc.status === 'signed' ? 'audit' : 'sign');
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const documents = this.documents;
|
||||
const filters = [
|
||||
{ id: 'all', label: 'All', count: demoDocuments.length },
|
||||
{ id: 'awaiting', label: 'Awaiting', count: demoDocuments.filter((doc) => doc.status === 'awaiting').length },
|
||||
{ id: 'signed', label: 'Completed', count: demoDocuments.filter((doc) => doc.status === 'signed').length },
|
||||
{ id: 'draft', label: 'Drafts', count: demoDocuments.filter((doc) => doc.status === 'draft').length },
|
||||
{ id: 'declined', label: 'Declined', count: demoDocuments.filter((doc) => doc.status === 'declined').length },
|
||||
{ id: 'all', label: 'All', count: documents.length },
|
||||
{ id: 'awaiting', label: 'Awaiting', count: documents.filter((doc) => doc.status === 'awaiting').length },
|
||||
{ id: 'signed', label: 'Completed', count: documents.filter((doc) => doc.status === 'signed').length },
|
||||
{ id: 'draft', label: 'Drafts', count: documents.filter((doc) => doc.status === 'draft').length },
|
||||
{ id: 'declined', label: 'Declined', count: documents.filter((doc) => doc.status === 'declined').length },
|
||||
];
|
||||
const attentionCount = documents.filter((doc) => doc.status === 'awaiting').length;
|
||||
|
||||
return html`
|
||||
${topBar({
|
||||
breadcrumb: ['signature.digital', 'Lossless GmbH', 'Inbox'],
|
||||
title: 'Inbox',
|
||||
subtitle: pill(`${demoDocuments.filter((doc) => doc.status === 'awaiting').length} need attention`, 'info'),
|
||||
subtitle: pill(`${attentionCount} need attention`, 'info'),
|
||||
actions: html`${actionButton('Import', 'outline', 'upload')}${actionButton('New document', 'primary', 'plus', () => requestWorkspaceView(this, 'compose'))}`,
|
||||
})}
|
||||
<div class="filterbar">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { actionButton, demoFields, fakeDocument, icon, pill, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement } from './sdig-workspace.shared.js';
|
||||
import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { actionButton, demoDocuments, demoFields, fakeDocument, icon, pill, workspaceBaseStyles, workspaceDemoFrame, type IDocumentRow, type IFieldPlacement } from './sdig-workspace.shared.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -12,6 +12,8 @@ export class SdigWorkspaceSign extends DeesElement {
|
||||
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-sign></sdig-workspace-sign>`);
|
||||
public static demoGroups = ['Signature Digital Workspace'];
|
||||
|
||||
@property({ attribute: false }) public accessor document: IDocumentRow = demoDocuments[0];
|
||||
@property({ attribute: false }) public accessor fields: IFieldPlacement[] = demoFields;
|
||||
@state() private accessor activeFieldId: string = 'f1';
|
||||
@state() private accessor signedFieldIds: string[] = [];
|
||||
|
||||
@@ -27,7 +29,7 @@ export class SdigWorkspaceSign extends DeesElement {
|
||||
`];
|
||||
|
||||
private get signFields() {
|
||||
return demoFields.slice(0, 3);
|
||||
return this.fields.slice(0, 3);
|
||||
}
|
||||
|
||||
private fieldIcon(type: IFieldPlacement['type']): string {
|
||||
@@ -51,13 +53,14 @@ export class SdigWorkspaceSign extends DeesElement {
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const document = this.document || demoDocuments[0];
|
||||
const completed = this.signedFieldIds.length;
|
||||
const progress = Math.round((completed / this.signFields.length) * 100);
|
||||
const activeField = this.signFields.find((field) => field.id === this.activeFieldId) || this.signFields[0];
|
||||
|
||||
return html`
|
||||
<div class="recipient-header">
|
||||
<div style="display: flex; align-items: center; gap: 12px;"><span class="logomark">s</span><div><div style="font-size: 12px; font-weight: 600;">Master Services Agreement</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">From Lossless GmbH · doc_8mK3pL · 14 pages</div></div></div>
|
||||
<div style="display: flex; align-items: center; gap: 12px;"><span class="logomark">s</span><div><div style="font-size: 12px; font-weight: 600;">${document.title}</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">From ${document.sender} · ${document.id} · ${document.pages} pages</div></div></div>
|
||||
<div class="actions"><span class="pill success">${icon('shield', 12)} Verified sender · DKIM ✓</span>${actionButton('Decline', 'outline')}${actionButton('PDF', 'outline', 'download')}</div>
|
||||
</div>
|
||||
<div class="progress-track"><div class="progress-fill" style="width: ${progress}%"></div></div>
|
||||
@@ -70,7 +73,7 @@ export class SdigWorkspaceSign extends DeesElement {
|
||||
const active = this.activeFieldId === field.id && !filled;
|
||||
return html`<div class="field-box ${active ? 'selected' : ''}" style="--x: ${field.x}px; --y: ${field.y}px; --w: ${field.w}px; --h: ${field.h}px; --field-color: ${filled ? 'transparent' : 'var(--accent)'}; color: ${filled ? 'hsl(220 50% 30%)' : 'var(--accent)'}; background: ${filled ? 'transparent' : 'color-mix(in srgb, var(--accent) 12%, transparent)'};" @click=${() => !filled ? this.signField(field.id) : undefined}>${filled ? this.renderSignedValue(field) : html`${icon(this.fieldIcon(field.type), 12)}<span>${active ? html`<span style="display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); animation: pulse 1.4s infinite;"></span>` : ''}${field.label}</span>`}</div>`;
|
||||
})}
|
||||
<div class="mono" style="position: absolute; bottom: 14px; right: 18px; font-size: 9px; color: hsl(0 0% 60%);">Page 1 of 14</div>
|
||||
<div class="mono" style="position: absolute; bottom: 14px; right: 18px; font-size: 9px; color: hsl(0 0% 60%);">Page 1 of ${document.pages}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sign-panel">
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { icon, type TDensity, type TWorkspaceTheme, type TWorkspaceView } from './sdig-workspace.shared.js';
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { demoDocuments, demoFields, demoRecipients, icon, type IDocumentRow, type IFieldPlacement, type IRecipient, type TDensity, type TWorkspaceTheme, type TWorkspaceView } from './sdig-workspace.shared.js';
|
||||
import './sdig-workspace-inbox.js';
|
||||
import './sdig-workspace-compose.js';
|
||||
import './sdig-workspace-sign.js';
|
||||
@@ -22,11 +22,17 @@ export class SdigWorkspace extends DeesElement {
|
||||
@property({ type: String }) public accessor density: TDensity = 'comfortable';
|
||||
@property({ type: String, reflect: true }) public accessor theme: TWorkspaceTheme = 'dark';
|
||||
@property({ type: String }) public accessor initialView: TWorkspaceView = 'inbox';
|
||||
@state() private accessor view: TWorkspaceView = 'inbox';
|
||||
@property({ type: String, reflect: true }) public accessor view: TWorkspaceView = 'inbox';
|
||||
@property({ attribute: false }) public accessor documents: IDocumentRow[] = demoDocuments;
|
||||
@property({ type: String }) public accessor activeDocumentId: string = '';
|
||||
@property({ attribute: false }) public accessor recipients: IRecipient[] = demoRecipients;
|
||||
@property({ attribute: false }) public accessor fields: IFieldPlacement[] = demoFields;
|
||||
|
||||
public connectedCallback = async () => {
|
||||
await super.connectedCallback();
|
||||
this.view = this.initialView || 'inbox';
|
||||
if (this.view === 'inbox' && this.initialView !== 'inbox') {
|
||||
this.view = this.initialView;
|
||||
}
|
||||
this.addEventListener('workspace-view-request', this.handleViewRequest as EventListener);
|
||||
};
|
||||
|
||||
@@ -131,7 +137,7 @@ export class SdigWorkspace extends DeesElement {
|
||||
|
||||
private renderSidebar(): TemplateResult {
|
||||
const navItems = [
|
||||
{ id: 'inbox', label: 'Inbox', icon: 'inbox', count: 4 },
|
||||
{ id: 'inbox', label: 'Inbox', icon: 'inbox', count: this.documents.length },
|
||||
{ id: 'compose', label: 'Compose', icon: 'plus' },
|
||||
{ id: 'templates', label: 'Templates', icon: 'folder', count: 12 },
|
||||
{ id: 'audit', label: 'Audit Trail', icon: 'shield' },
|
||||
@@ -156,16 +162,17 @@ export class SdigWorkspace extends DeesElement {
|
||||
}
|
||||
|
||||
private renderView(): TemplateResult {
|
||||
const activeDocument = this.documents.find((document) => document.id === this.activeDocumentId) || this.documents[0] || demoDocuments[0];
|
||||
switch (this.view) {
|
||||
case 'inbox': return html`<sdig-workspace-inbox class="view-host" .density=${this.density}></sdig-workspace-inbox>`;
|
||||
case 'compose': return html`<sdig-workspace-compose class="view-host"></sdig-workspace-compose>`;
|
||||
case 'sign': return html`<sdig-workspace-sign class="view-host"></sdig-workspace-sign>`;
|
||||
case 'audit': return html`<sdig-workspace-audit class="view-host"></sdig-workspace-audit>`;
|
||||
case 'inbox': return html`<sdig-workspace-inbox class="view-host" .density=${this.density} .documents=${this.documents}></sdig-workspace-inbox>`;
|
||||
case 'compose': return html`<sdig-workspace-compose class="view-host" .document=${activeDocument} .recipients=${this.recipients} .fields=${this.fields}></sdig-workspace-compose>`;
|
||||
case 'sign': return html`<sdig-workspace-sign class="view-host" .document=${activeDocument} .fields=${this.fields}></sdig-workspace-sign>`;
|
||||
case 'audit': return html`<sdig-workspace-audit class="view-host" .document=${activeDocument}></sdig-workspace-audit>`;
|
||||
case 'developers': return html`<sdig-workspace-developers class="view-host"></sdig-workspace-developers>`;
|
||||
case 'templates': return html`<sdig-workspace-placeholder class="view-host" label="Templates" subtitle="Reusable agreement templates"></sdig-workspace-placeholder>`;
|
||||
case 'team': return html`<sdig-workspace-placeholder class="view-host" label="Team" subtitle="Workspace members & roles"></sdig-workspace-placeholder>`;
|
||||
case 'settings': return html`<sdig-workspace-placeholder class="view-host" label="Settings" subtitle="Workspace, billing, security"></sdig-workspace-placeholder>`;
|
||||
default: return html`<sdig-workspace-inbox class="view-host" .density=${this.density}></sdig-workspace-inbox>`;
|
||||
default: return html`<sdig-workspace-inbox class="view-host" .density=${this.density} .documents=${this.documents}></sdig-workspace-inbox>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user