370 lines
22 KiB
TypeScript
370 lines
22 KiB
TypeScript
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';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'sdig-workspace-compose': SdigWorkspaceCompose;
|
|
}
|
|
}
|
|
|
|
type TFieldDefinition = {
|
|
type: IFieldPlacement['type'];
|
|
icon: string;
|
|
label: string;
|
|
w: number;
|
|
h: number;
|
|
};
|
|
|
|
type TResizeHandle = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
|
|
|
|
type TFieldInteraction = {
|
|
fieldId: string;
|
|
mode: 'move' | 'resize';
|
|
handle?: TResizeHandle;
|
|
startClientX: number;
|
|
startClientY: number;
|
|
startField: IFieldPlacement;
|
|
pageWidth: number;
|
|
pageHeight: number;
|
|
};
|
|
|
|
const fieldDefinitions: TFieldDefinition[] = [
|
|
{ type: 'signature', icon: 'sign', label: 'Signature', w: 200, h: 50 },
|
|
{ type: 'initials', icon: 'type', label: 'Initials', w: 120, h: 32 },
|
|
{ type: 'date', icon: 'calendar', label: 'Date', w: 120, h: 32 },
|
|
{ type: 'text', icon: 'type', label: 'Text field', w: 220, h: 32 },
|
|
{ type: 'check', icon: 'check', label: 'Checkbox', w: 120, h: 32 },
|
|
];
|
|
|
|
const resizeHandles: TResizeHandle[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'];
|
|
|
|
@customElement('sdig-workspace-compose')
|
|
export class SdigWorkspaceCompose extends DeesElement {
|
|
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-compose></sdig-workspace-compose>`);
|
|
public static demoGroups = ['Signature Digital Workspace'];
|
|
|
|
@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];
|
|
private draggedFieldDefinition: TFieldDefinition | null = null;
|
|
private draggedFieldGrabOffset: { x: number; y: number } | null = null;
|
|
private fieldInteraction: TFieldInteraction | null = null;
|
|
|
|
public static styles = [workspaceBaseStyles, css`
|
|
.stepper { height: 44px; flex-shrink: 0; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; padding: 0 24px; gap: 24px; overflow-x: auto; }
|
|
.step { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); white-space: nowrap; background: transparent; }
|
|
.step-number { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); font-size: 10px; font-weight: 700; }
|
|
.step.active { color: var(--text); font-weight: 500; }
|
|
.step.active .step-number { background: var(--accent); border-color: var(--accent); color: white; }
|
|
.step.done .step-number { background: var(--success); border-color: var(--success); color: white; }
|
|
.compose-workspace { flex: 1; display: flex; overflow: hidden; }
|
|
.palette { width: 260px; border-right: 1px solid var(--border-subtle); padding: 16px; background: var(--bg-el); overflow-y: auto; flex-shrink: 0; }
|
|
.right-panel { width: 280px; border-left: 1px solid var(--border-subtle); padding: 16px; background: var(--bg-el); overflow-y: auto; flex-shrink: 0; }
|
|
.field-tool { width: var(--tool-w); height: var(--tool-h); display: flex; align-items: center; gap: 8px; padding: 0 10px; background: color-mix(in srgb, var(--recipient-color) 8%, var(--bg-card)); border: 1.5px dashed var(--recipient-color); border-radius: 4px; font-size: 12px; color: var(--recipient-color); margin-bottom: 8px; cursor: grab; }
|
|
.field-tool:active { cursor: grabbing; }
|
|
.field-tool span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.swatch { width: 10px; height: 10px; border-radius: 2px; background: var(--recipient-color, var(--accent)); flex-shrink: 0; }
|
|
.document-stage { flex: 1; overflow: auto; background: hsl(0 0% 8%); display: flex; flex-direction: column; align-items: center; padding: 32px; gap: 20px; }
|
|
:host-context(sdig-workspace[theme='light']) .document-stage { background: hsl(0 0% 92%); }
|
|
.recipient-line { cursor: grab; }
|
|
.recipient-line.dragging { opacity: 0.45; border-color: var(--accent); }
|
|
.page-drop-target { outline: 1px dashed transparent; outline-offset: 8px; }
|
|
.page-drop-target.drag-over { outline-color: var(--accent); }
|
|
.field-box { user-select: none; touch-action: none; }
|
|
.field-box.selected { z-index: 5; cursor: move; }
|
|
.field-content { width: 100%; height: 100%; display: flex; align-items: center; gap: 6px; pointer-events: none; overflow: hidden; }
|
|
.field-content span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.resize-handle { position: absolute; z-index: 2; width: 9px; height: 9px; border-radius: 50%; background: var(--bg-card); border: 1.5px solid var(--field-color); box-shadow: 0 0 0 2px var(--bg-card); touch-action: none; }
|
|
.resize-handle.n { top: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
|
|
.resize-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; }
|
|
.resize-handle.e { right: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; }
|
|
.resize-handle.se { right: -6px; bottom: -6px; cursor: nwse-resize; }
|
|
.resize-handle.s { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
|
|
.resize-handle.sw { left: -6px; bottom: -6px; cursor: nesw-resize; }
|
|
.resize-handle.w { left: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; }
|
|
.resize-handle.nw { left: -6px; top: -6px; cursor: nwse-resize; }
|
|
.field-editor { margin-top: 16px; padding: 12px; }
|
|
.field-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
.field-control { display: flex; flex-direction: column; gap: 4px; font-size: 10px; color: var(--text-muted); }
|
|
.field-control.full { grid-column: 1 / -1; }
|
|
.field-control input, .field-control select { width: 100%; height: 30px; padding: 0 8px; border: 1px solid var(--border); border-radius: 5px; background: var(--bg-input); color: var(--text); font-size: 12px; outline: none; }
|
|
.field-control input:focus, .field-control select:focus { border-color: var(--accent); }
|
|
@media (max-width: 920px) { .compose-workspace { flex-direction: column; overflow: auto; } .palette, .right-panel { width: 100%; border: 0; border-bottom: 1px solid var(--border-subtle); } .document-page { width: 560px; } }
|
|
`];
|
|
|
|
public disconnectedCallback = async () => {
|
|
this.stopFieldInteraction();
|
|
await super.disconnectedCallback();
|
|
};
|
|
|
|
private recipientColor(id: number): string {
|
|
return this.recipients.find((recipient) => recipient.id === id)?.color || 'var(--accent)';
|
|
}
|
|
|
|
private fieldIcon(type: IFieldPlacement['type']): string {
|
|
if (type === 'signature') return 'sign';
|
|
if (type === 'date') return 'calendar';
|
|
if (type === 'check') return 'check';
|
|
return 'type';
|
|
}
|
|
|
|
private fieldDefinition(type: IFieldPlacement['type']): TFieldDefinition {
|
|
return fieldDefinitions.find((definition) => definition.type === type) || fieldDefinitions[0];
|
|
}
|
|
|
|
private clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
private updateField(fieldId: string, patch: Partial<IFieldPlacement>) {
|
|
this.fields = this.fields.map((field) => field.id === fieldId ? { ...field, ...patch } : field);
|
|
}
|
|
|
|
private updateSelectedField(patch: Partial<IFieldPlacement>) {
|
|
if (!this.selectedFieldId) return;
|
|
this.updateField(this.selectedFieldId, patch);
|
|
}
|
|
|
|
private updateSelectedFieldNumber(property: 'x' | 'y' | 'w' | 'h', event: Event) {
|
|
const value = Number((event.target as HTMLInputElement).value);
|
|
if (!Number.isFinite(value)) return;
|
|
const min = property === 'w' || property === 'h' ? 16 : 0;
|
|
this.updateSelectedField({ [property]: Math.max(min, Math.round(value)) } as Partial<IFieldPlacement>);
|
|
}
|
|
|
|
private resetSelectedFieldSize(field: IFieldPlacement) {
|
|
const definition = this.fieldDefinition(field.type);
|
|
this.updateSelectedField({ w: definition.w, h: definition.h });
|
|
}
|
|
|
|
private removeSelectedField() {
|
|
if (!this.selectedFieldId) return;
|
|
this.fields = this.fields.filter((field) => field.id !== this.selectedFieldId);
|
|
this.selectedFieldId = null;
|
|
}
|
|
|
|
private handleDocumentClick = (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (target?.closest('.field-box')) return;
|
|
this.selectedFieldId = null;
|
|
};
|
|
|
|
private startFieldInteraction(event: PointerEvent, field: IFieldPlacement, mode: TFieldInteraction['mode'], handle?: TResizeHandle) {
|
|
if (event.button !== 0) return;
|
|
const page = this.shadowRoot?.querySelector('.document-page') as HTMLElement | null;
|
|
if (!page) return;
|
|
const pageRect = page.getBoundingClientRect();
|
|
this.selectedFieldId = field.id;
|
|
this.fieldInteraction = {
|
|
fieldId: field.id,
|
|
mode,
|
|
handle,
|
|
startClientX: event.clientX,
|
|
startClientY: event.clientY,
|
|
startField: { ...field },
|
|
pageWidth: pageRect.width,
|
|
pageHeight: pageRect.height,
|
|
};
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
window.addEventListener('pointermove', this.handleFieldPointerMove, { passive: false });
|
|
window.addEventListener('pointerup', this.stopFieldInteraction);
|
|
window.addEventListener('pointercancel', this.stopFieldInteraction);
|
|
}
|
|
|
|
private startFieldMove(event: PointerEvent, field: IFieldPlacement) {
|
|
this.startFieldInteraction(event, field, 'move');
|
|
}
|
|
|
|
private startFieldResize(event: PointerEvent, field: IFieldPlacement, handle: TResizeHandle) {
|
|
this.startFieldInteraction(event, field, 'resize', handle);
|
|
}
|
|
|
|
private handleFieldPointerMove = (event: PointerEvent) => {
|
|
if (!this.fieldInteraction) return;
|
|
event.preventDefault();
|
|
const interaction = this.fieldInteraction;
|
|
const dx = event.clientX - interaction.startClientX;
|
|
const dy = event.clientY - interaction.startClientY;
|
|
const start = interaction.startField;
|
|
|
|
if (interaction.mode === 'move') {
|
|
this.updateField(interaction.fieldId, {
|
|
x: Math.round(this.clamp(start.x + dx, 0, interaction.pageWidth - start.w)),
|
|
y: Math.round(this.clamp(start.y + dy, 0, interaction.pageHeight - start.h)),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const minWidth = 32;
|
|
const minHeight = 24;
|
|
let x = start.x;
|
|
let y = start.y;
|
|
let w = start.w;
|
|
let h = start.h;
|
|
const handle = interaction.handle || 'se';
|
|
|
|
if (handle.includes('e')) {
|
|
w = this.clamp(start.w + dx, minWidth, interaction.pageWidth - start.x);
|
|
}
|
|
if (handle.includes('s')) {
|
|
h = this.clamp(start.h + dy, minHeight, interaction.pageHeight - start.y);
|
|
}
|
|
if (handle.includes('w')) {
|
|
x = this.clamp(start.x + dx, 0, start.x + start.w - minWidth);
|
|
w = start.x + start.w - x;
|
|
}
|
|
if (handle.includes('n')) {
|
|
y = this.clamp(start.y + dy, 0, start.y + start.h - minHeight);
|
|
h = start.y + start.h - y;
|
|
}
|
|
|
|
this.updateField(interaction.fieldId, {
|
|
x: Math.round(x),
|
|
y: Math.round(y),
|
|
w: Math.round(w),
|
|
h: Math.round(h),
|
|
});
|
|
};
|
|
|
|
private stopFieldInteraction = () => {
|
|
this.fieldInteraction = null;
|
|
window.removeEventListener('pointermove', this.handleFieldPointerMove);
|
|
window.removeEventListener('pointerup', this.stopFieldInteraction);
|
|
window.removeEventListener('pointercancel', this.stopFieldInteraction);
|
|
};
|
|
|
|
private 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 addFieldFromDrop(event: DragEvent) {
|
|
event.preventDefault();
|
|
const page = event.currentTarget as HTMLElement;
|
|
page.classList.remove('drag-over');
|
|
const transferredType = event.dataTransfer?.getData('application/x-signature-field') as IFieldPlacement['type'];
|
|
if (!this.draggedFieldDefinition && !transferredType) return;
|
|
const definition = this.draggedFieldDefinition || this.fieldDefinition(transferredType);
|
|
const transferredOffset = event.dataTransfer?.getData('application/x-signature-field-offset');
|
|
const offset = this.draggedFieldGrabOffset || (transferredOffset ? JSON.parse(transferredOffset) as { x: number; y: number } : { x: definition.w / 2, y: definition.h / 2 });
|
|
const rect = page.getBoundingClientRect();
|
|
const x = Math.round(event.clientX - rect.left - offset.x);
|
|
const y = Math.round(event.clientY - rect.top - offset.y);
|
|
const nextField: IFieldPlacement = {
|
|
id: `field_${Date.now()}`,
|
|
type: definition.type,
|
|
x: Math.max(0, Math.min(Math.max(0, rect.width - definition.w), x)),
|
|
y: Math.max(0, Math.min(Math.max(0, rect.height - definition.h), y)),
|
|
w: definition.w,
|
|
h: definition.h,
|
|
page: 1,
|
|
recipient: this.activeRecipient,
|
|
label: definition.label,
|
|
};
|
|
this.fields = [...this.fields, nextField];
|
|
this.selectedFieldId = nextField.id;
|
|
this.draggedFieldDefinition = null;
|
|
this.draggedFieldGrabOffset = null;
|
|
}
|
|
|
|
private startFieldToolDrag(event: DragEvent, fieldType: TFieldDefinition) {
|
|
const toolRect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
const offset = {
|
|
x: Math.round(event.clientX - toolRect.left),
|
|
y: Math.round(event.clientY - toolRect.top),
|
|
};
|
|
this.draggedFieldDefinition = fieldType;
|
|
this.draggedFieldGrabOffset = offset;
|
|
event.dataTransfer?.setData('application/x-signature-field', fieldType.type);
|
|
event.dataTransfer?.setData('application/x-signature-field-offset', JSON.stringify(offset));
|
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'copy';
|
|
}
|
|
|
|
private endFieldToolDrag() {
|
|
this.draggedFieldDefinition = null;
|
|
this.draggedFieldGrabOffset = null;
|
|
}
|
|
|
|
private renderFieldEditor(field: IFieldPlacement): TemplateResult {
|
|
return html`
|
|
<div class="card field-editor">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
|
|
<div style="font-size: 11px; font-weight: 600;">Field editor</div>
|
|
${pill(this.fieldDefinition(field.type).label, 'info', true)}
|
|
</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">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>
|
|
<label class="field-control">Height<input type="number" min="16" .value=${String(field.h)} @input=${(event: Event) => this.updateSelectedFieldNumber('h', event)} /></label>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
|
<button class="btn outline small" style="flex: 1;" @click=${() => this.resetSelectedFieldSize(field)}>Reset size</button>
|
|
<button class="btn ghost small" style="color: var(--error);" @click=${() => this.removeSelectedField()}>Delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderResizeHandles(field: IFieldPlacement): TemplateResult {
|
|
return html`${resizeHandles.map((handle) => html`<span class="resize-handle ${handle}" @pointerdown=${(event: PointerEvent) => this.startFieldResize(event, field, handle)}></span>`)}`;
|
|
}
|
|
|
|
private renderStepper(): TemplateResult {
|
|
const labels = ['Upload', 'Place fields', 'Recipients & routing', 'Review & send'];
|
|
return html`
|
|
<div class="stepper">
|
|
${labels.map((label, index) => {
|
|
const stepNumber = index + 1;
|
|
return html`<button class="step ${stepNumber === this.step ? 'active' : stepNumber < this.step ? 'done' : ''}" @click=${() => this.step = stepNumber}><span class="step-number">${stepNumber < this.step ? '✓' : stepNumber}</span><span>${label}</span>${index < labels.length - 1 ? html`<span style="width: 24px; height: 1px; background: var(--border); margin-left: 8px;"></span>` : ''}</button>`;
|
|
})}
|
|
<div style="flex: 1;"></div><span class="mono" style="font-size: 11px; color: var(--text-muted);">doc_8mK3pL · 14 pages · 2.4 MB</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
public render(): TemplateResult {
|
|
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')}` })}
|
|
${this.renderStepper()}
|
|
<div class="compose-workspace">
|
|
<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>`)}
|
|
</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>
|
|
<div style="display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--text-muted);">${actionButton('Prev', 'outline')}${html`<span class="mono">1 / 14</span>`}${actionButton('Next', 'outline')}</div>
|
|
</div>
|
|
<div class="right-panel">
|
|
<div class="label-upper">Signing order · drag to reorder</div>
|
|
${this.recipients.map((recipient) => html`<div class="recipient-line ${this.draggedRecipientId === recipient.id ? 'dragging' : ''}" draggable="true" @dragstart=${() => this.draggedRecipientId = recipient.id} @dragover=${(event: DragEvent) => event.preventDefault()} @drop=${() => this.reorderRecipient(recipient.id)} @dragend=${() => this.draggedRecipientId = null}><span class="mono" style="width: 14px; font-size: 10px; color: var(--text-muted);">${recipient.order}</span><span class="avatar" style="background: ${recipient.color};">${recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('')}</span><div style="flex: 1; min-width: 0;"><div style="font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name}</div><div class="mono" style="font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.email}</div></div>${icon('more', 12)}</div>`)}
|
|
${selectedField ? this.renderFieldEditor(selectedField) : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|