feat(catalog): add ContractEditor and many editor subcomponents; implement SignPad and SignBox; update README and bump dependencies

This commit is contained in:
2025-12-18 15:27:22 +00:00
parent 6d53259b75
commit 56c087bc3a
35 changed files with 10914 additions and 1959 deletions

View File

@@ -0,0 +1,8 @@
/**
* @file index.ts
* @description Export barrel for sdig-contracteditor module
*/
export * from './sdig-contracteditor.js';
export * from './types.js';
export * from './state.js';

View File

@@ -0,0 +1,839 @@
/**
* @file sdig-contracteditor.ts
* @description Main contract editor orchestrator component
*/
import {
DeesElement,
property,
state,
html,
customElement,
type TemplateResult,
css,
cssManager,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
import { createEditorStore, type TEditorStore } from './state.js';
import {
type TEditorSection,
type IEditorState,
EDITOR_SECTIONS,
type IContractChangeEventDetail,
type ISectionChangeEventDetail,
} from './types.js';
// Import sub-components
import '../sdig-contract-header/sdig-contract-header.js';
import '../sdig-contract-metadata/sdig-contract-metadata.js';
import '../sdig-contract-parties/sdig-contract-parties.js';
import '../sdig-contract-content/sdig-contract-content.js';
import '../sdig-contract-terms/sdig-contract-terms.js';
import '../sdig-contract-signatures/sdig-contract-signatures.js';
import '../sdig-contract-attachments/sdig-contract-attachments.js';
import '../sdig-contract-collaboration/sdig-contract-collaboration.js';
import '../sdig-contract-audit/sdig-contract-audit.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contracteditor': SdigContracteditor;
}
}
@customElement('sdig-contracteditor')
export class SdigContracteditor extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contracteditor
.contract=${plugins.sdDemodata.demoContract}
></sdig-contracteditor>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
min-height: 600px;
}
.editor-container {
display: flex;
flex-direction: column;
height: 100%;
background: ${cssManager.bdTheme('#f8f9fa', '#09090b')};
border-radius: 8px;
overflow: hidden;
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
/* Header */
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.contract-title {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin: 0;
}
/* shadcn-style badge */
.contract-status {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
border: 1px solid transparent;
background: ${cssManager.bdTheme('hsl(214 95% 93%)', 'hsl(214 95% 15%)')};
color: ${cssManager.bdTheme('hsl(214 95% 35%)', 'hsl(214 95% 70%)')};
border-color: ${cssManager.bdTheme('hsl(214 95% 80%)', 'hsl(214 95% 25%)')};
}
.contract-status.draft {
background: ${cssManager.bdTheme('hsl(48 96% 89%)', 'hsl(48 96% 15%)')};
color: ${cssManager.bdTheme('hsl(25 95% 30%)', 'hsl(48 96% 70%)')};
border-color: ${cssManager.bdTheme('hsl(48 96% 76%)', 'hsl(48 96% 25%)')};
}
.contract-status.executed {
background: ${cssManager.bdTheme('hsl(142 76% 90%)', 'hsl(142 76% 15%)')};
color: ${cssManager.bdTheme('hsl(142 76% 28%)', 'hsl(142 76% 65%)')};
border-color: ${cssManager.bdTheme('hsl(142 76% 75%)', 'hsl(142 76% 25%)')};
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.dirty-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.dirty-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
}
.collaborators {
display: flex;
align-items: center;
gap: -8px;
}
.collaborator-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: white;
margin-left: -8px;
}
.collaborator-avatar:first-child {
margin-left: 0;
}
/* Navigation Tabs */
.editor-nav {
display: flex;
align-items: center;
gap: 4px;
padding: 0 24px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
overflow-x: auto;
}
.nav-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.nav-tab:hover {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.nav-tab.active {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
border-bottom-color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.nav-tab dees-icon {
font-size: 16px;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: ${cssManager.bdTheme('#ef4444', '#dc2626')};
color: white;
}
/* Main Content Area */
.editor-main {
display: flex;
flex: 1;
overflow: hidden;
}
.editor-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.editor-sidebar {
width: 320px;
border-left: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
overflow-y: auto;
}
/* Section placeholder */
.section-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-placeholder dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.section-placeholder h3 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.section-placeholder p {
margin: 0;
font-size: 14px;
}
/* Footer */
.editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.footer-left {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.footer-right {
display: flex;
align-items: center;
gap: 12px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Loading state */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: ${cssManager.bdTheme('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.8)')};
z-index: 100;
}
/* Overview section layout */
.overview-section {
display: flex;
flex-direction: column;
gap: 24px;
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor showSidebar: boolean = true;
@property({ type: String })
public accessor initialSection: TEditorSection = 'overview';
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor editorState: IEditorState | null = null;
// ============================================================================
// INSTANCE
// ============================================================================
private store: TEditorStore | null = null;
private unsubscribe: (() => void) | null = null;
private storeReady: Promise<void>;
private resolveStoreReady!: () => void;
constructor() {
super();
this.storeReady = new Promise((resolve) => {
this.resolveStoreReady = resolve;
});
}
// ============================================================================
// LIFECYCLE
// ============================================================================
public connectedCallback() {
super.connectedCallback();
this.initStore();
}
private async initStore() {
this.store = await createEditorStore();
this.unsubscribe = this.store.subscribe((state) => {
this.editorState = state;
});
// Set initial section
this.store.setActiveSection(this.initialSection);
this.resolveStoreReady();
// If contract was already set, apply it now
if (this.contract) {
this.store.setContract(this.contract);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this.unsubscribe) {
this.unsubscribe();
}
}
public async updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('contract') && this.contract) {
await this.storeReady;
this.store?.setContract(this.contract);
}
}
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleSectionChange(section: TEditorSection) {
const previousSection = this.editorState?.activeSection || 'overview';
this.store?.setActiveSection(section);
this.dispatchEvent(
new CustomEvent<ISectionChangeEventDetail>('section-change', {
detail: { section, previousSection },
bubbles: true,
composed: true,
})
);
}
private handleSave() {
if (!this.editorState?.contract) return;
this.store?.setSaving(true);
this.dispatchEvent(
new CustomEvent('contract-save', {
detail: {
contract: this.editorState.contract,
isDraft: this.editorState.contract.lifecycle.currentStatus === 'draft',
},
bubbles: true,
composed: true,
})
);
}
private handleDiscard() {
this.store?.discardChanges();
this.dispatchEvent(
new CustomEvent('contract-discard', {
bubbles: true,
composed: true,
})
);
}
private handleUndo() {
this.store?.undo();
}
private handleRedo() {
this.store?.redo();
}
// ============================================================================
// PUBLIC API
// ============================================================================
/**
* Update a field in the contract
*/
public updateField(path: string, value: unknown, description?: string) {
this.store?.updateContract(path, value, description);
this.dispatchEvent(
new CustomEvent<IContractChangeEventDetail>('contract-change', {
detail: { path, value, source: 'user' },
bubbles: true,
composed: true,
})
);
}
/**
* Get current contract state
*/
public getContract(): plugins.sdInterfaces.IPortableContract | null {
return this.editorState?.contract || null;
}
/**
* Mark contract as saved externally
*/
public markSaved() {
this.store?.markSaved();
}
// ============================================================================
// RENDER HELPERS
// ============================================================================
private getStatusClass(status: string): string {
if (status === 'draft' || status === 'internal_review') return 'draft';
if (status === 'executed' || status === 'active') return 'executed';
return '';
}
private formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
private handleFieldChange(e: CustomEvent<{ path: string; value: unknown }>) {
const { path, value } = e.detail;
this.updateField(path, value);
}
private renderSectionContent(): TemplateResult {
const section = this.editorState?.activeSection || 'overview';
const contract = this.editorState?.contract;
const sectionConfig = EDITOR_SECTIONS.find((s) => s.id === section);
// Render section based on active tab
switch (section) {
case 'overview':
return this.renderOverviewSection();
case 'parties':
return this.renderPartiesSection();
case 'content':
return this.renderContentSection();
case 'terms':
return this.renderTermsSection();
case 'signatures':
return this.renderSignaturesSection();
case 'attachments':
return this.renderAttachmentsSection();
case 'collaboration':
return this.renderCollaborationSection();
case 'audit':
return this.renderAuditSection();
default:
return this.renderPlaceholder(sectionConfig, 'This section is being implemented...');
}
}
private renderOverviewSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<div class="overview-section">
<sdig-contract-header
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-header>
<sdig-contract-metadata
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-metadata>
</div>
`;
}
private renderPartiesSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-parties
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-parties>
`;
}
private renderContentSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-content
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-content>
`;
}
private renderTermsSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-terms
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-terms>
`;
}
private renderSignaturesSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-signatures
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-signatures>
`;
}
private renderAttachmentsSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-attachments
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-attachments>
`;
}
private renderCollaborationSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-collaboration
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-collaboration>
`;
}
private renderAuditSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-audit
.contract=${contract}
></sdig-contract-audit>
`;
}
private renderPlaceholder(sectionConfig: typeof EDITOR_SECTIONS[0] | undefined, message: string): TemplateResult {
return html`
<div class="section-placeholder">
<dees-icon .iconFA=${sectionConfig?.icon || 'lucide:file'}></dees-icon>
<h3>${sectionConfig?.label || 'Section'}</h3>
<p>${message}</p>
</div>
`;
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
const contract = this.editorState?.contract;
const activeSection = this.editorState?.activeSection || 'overview';
const isDirty = this.editorState?.isDirty || false;
const isSaving = this.editorState?.isSaving || false;
const collaborators = this.editorState?.activeCollaborators || [];
return html`
<div class="editor-container">
<!-- Header -->
<div class="editor-header">
<div class="header-left">
<h1 class="contract-title">${contract?.title || 'Untitled Contract'}</h1>
${contract?.lifecycle?.currentStatus
? html`
<span class="contract-status ${this.getStatusClass(contract.lifecycle.currentStatus)}">
${this.formatStatus(contract.lifecycle.currentStatus)}
</span>
`
: ''}
</div>
<div class="header-right">
${isDirty
? html`
<div class="dirty-indicator">
<span class="dirty-dot"></span>
<span>Unsaved changes</span>
</div>
`
: ''}
${collaborators.length > 0
? html`
<div class="collaborators">
${collaborators.slice(0, 3).map(
(c) => html`
<div
class="collaborator-avatar"
style="background: ${c.color}"
title="${c.displayName}"
>
${c.displayName.charAt(0).toUpperCase()}
</div>
`
)}
${collaborators.length > 3
? html`
<div class="collaborator-avatar" style="background: #6b7280">
+${collaborators.length - 3}
</div>
`
: ''}
</div>
`
: ''}
<button class="btn btn-ghost" @click=${this.handleUndo} ?disabled=${!this.store?.canUndo()}>
<dees-icon .iconFA=${'lucide:undo-2'}></dees-icon>
</button>
<button class="btn btn-ghost" @click=${this.handleRedo} ?disabled=${!this.store?.canRedo()}>
<dees-icon .iconFA=${'lucide:redo-2'}></dees-icon>
</button>
</div>
</div>
<!-- Navigation -->
<nav class="editor-nav">
${EDITOR_SECTIONS.map(
(section) => html`
<button
class="nav-tab ${activeSection === section.id ? 'active' : ''}"
@click=${() => this.handleSectionChange(section.id)}
?disabled=${section.disabled}
>
<dees-icon .iconFA=${section.icon}></dees-icon>
<span>${section.label}</span>
${section.badge
? html`<span class="nav-badge">${section.badge}</span>`
: ''}
</button>
`
)}
</nav>
<!-- Main Content -->
<div class="editor-main">
<div class="editor-content">
${this.renderSectionContent()}
</div>
${this.showSidebar
? html`
<aside class="editor-sidebar">
<!-- Sidebar content - collaboration panel -->
</aside>
`
: ''}
</div>
<!-- Footer -->
<div class="editor-footer">
<div class="footer-left">
${contract?.updatedAt
? html`<span>Last updated: ${new Date(contract.updatedAt).toLocaleString()}</span>`
: ''}
${contract?.versionHistory?.currentVersionId
? html`<span>Version: ${contract.versionHistory.currentVersionId}</span>`
: ''}
</div>
<div class="footer-right">
${isDirty
? html`
<button class="btn btn-secondary" @click=${this.handleDiscard}>
Discard
</button>
`
: ''}
<button
class="btn btn-primary"
@click=${this.handleSave}
?disabled=${!isDirty || isSaving}
>
${isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
${this.editorState?.isLoading
? html`
<div class="loading-overlay">
<dees-spinner></dees-spinner>
</div>
`
: ''}
</div>
`;
}
}

View File

@@ -0,0 +1,407 @@
/**
* @file state.ts
* @description Smartstate store for contract editor
*/
import { domtools } from '@design.estate/dees-element';
import type * as sdInterfaces from '@signature.digital/tools/interfaces';
import {
type IEditorState,
type TEditorSection,
type TEditorMode,
type IContractChange,
type IValidationError,
type IEditorUser,
createInitialEditorState,
} from './types.js';
// ============================================================================
// STATE STORE
// ============================================================================
/**
* Create a new editor state store instance
*/
export async function createEditorStore() {
const smartstate = new domtools.plugins.smartstate.Smartstate<{ editor: IEditorState }>();
// Initialize with default state (getStatePart is now async)
const statePart = await smartstate.getStatePart<IEditorState>('editor', createInitialEditorState(), 'soft');
// Create actions for state modifications
const setContractAction = statePart.createAction<{ contract: sdInterfaces.IPortableContract }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
contract: structuredClone(payload.contract),
originalContract: structuredClone(payload.contract),
isDirty: false,
undoStack: [],
redoStack: [],
})
);
const updateContractAction = statePart.createAction<{ path: string; value: unknown; description?: string; userId?: string }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
if (!state.contract) return state;
const previousValue = getNestedValue(state.contract, payload.path);
const change: IContractChange = {
id: crypto.randomUUID(),
timestamp: Date.now(),
path: payload.path,
previousValue,
newValue: payload.value,
description: payload.description || `Updated ${payload.path}`,
userId: payload.userId,
};
const updatedContract = setNestedValue(
structuredClone(state.contract),
payload.path,
payload.value
);
return {
...state,
contract: updatedContract,
isDirty: true,
undoStack: [...state.undoStack, change],
redoStack: [],
};
}
);
const setActiveSectionAction = statePart.createAction<{ section: TEditorSection }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
activeSection: payload.section,
})
);
const setEditorModeAction = statePart.createAction<{ mode: TEditorMode }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
editorMode: payload.mode,
})
);
const selectParagraphAction = statePart.createAction<{ paragraphId: string | null }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
selectedParagraphId: payload.paragraphId,
})
);
const selectPartyAction = statePart.createAction<{ partyId: string | null }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
selectedPartyId: payload.partyId,
})
);
const selectSignatureFieldAction = statePart.createAction<{ fieldId: string | null }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
selectedSignatureFieldId: payload.fieldId,
})
);
const undoAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
if (state.undoStack.length === 0 || !state.contract) return state;
const change = state.undoStack[state.undoStack.length - 1];
const updatedContract = setNestedValue(
structuredClone(state.contract),
change.path,
change.previousValue
);
return {
...state,
contract: updatedContract,
undoStack: state.undoStack.slice(0, -1),
redoStack: [...state.redoStack, change],
isDirty: state.undoStack.length > 1,
};
}
);
const redoAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
if (state.redoStack.length === 0 || !state.contract) return state;
const change = state.redoStack[state.redoStack.length - 1];
const updatedContract = setNestedValue(
structuredClone(state.contract),
change.path,
change.newValue
);
return {
...state,
contract: updatedContract,
undoStack: [...state.undoStack, change],
redoStack: state.redoStack.slice(0, -1),
isDirty: true,
};
}
);
const setLoadingAction = statePart.createAction<{ isLoading: boolean }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
isLoading: payload.isLoading,
})
);
const setSavingAction = statePart.createAction<{ isSaving: boolean }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
isSaving: payload.isSaving,
})
);
const markSavedAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
return {
...state,
originalContract: state.contract ? structuredClone(state.contract) : null,
isDirty: false,
isSaving: false,
};
}
);
const discardChangesAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
return {
...state,
contract: state.originalContract ? structuredClone(state.originalContract) : null,
isDirty: false,
undoStack: [],
redoStack: [],
};
}
);
const setValidationErrorsAction = statePart.createAction<{ errors: IValidationError[] }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
validationErrors: payload.errors,
})
);
const clearValidationErrorsAction = statePart.createAction<void>(
async (statePartArg) => ({
...statePartArg.getState(),
validationErrors: [],
})
);
const setCurrentUserAction = statePart.createAction<{ user: IEditorUser }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
currentUser: payload.user,
})
);
const setActiveCollaboratorsAction = statePart.createAction<{ collaborators: sdInterfaces.IUserPresence[] }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
activeCollaborators: payload.collaborators,
})
);
const addCollaboratorAction = statePart.createAction<{ collaborator: sdInterfaces.IUserPresence }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
if (state.activeCollaborators.find(c => c.userId === payload.collaborator.userId)) {
return state;
}
return {
...state,
activeCollaborators: [...state.activeCollaborators, payload.collaborator],
};
}
);
const removeCollaboratorAction = statePart.createAction<{ userId: string }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
return {
...state,
activeCollaborators: state.activeCollaborators.filter(c => c.userId !== payload.userId),
};
}
);
const updateCollaboratorAction = statePart.createAction<{ userId: string; updates: Partial<sdInterfaces.IUserPresence> }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
return {
...state,
activeCollaborators: state.activeCollaborators.map(c =>
c.userId === payload.userId ? { ...c, ...payload.updates } : c
),
};
}
);
return {
smartstate,
statePart,
// Getters
getContract: () => statePart.getState().contract,
getActiveSection: () => statePart.getState().activeSection,
isDirty: () => statePart.getState().isDirty,
isLoading: () => statePart.getState().isLoading,
isSaving: () => statePart.getState().isSaving,
// Contract operations
setContract: (contract: sdInterfaces.IPortableContract) => setContractAction.trigger({ contract }),
updateContract: (path: string, value: unknown, description?: string) => {
const state = statePart.getState();
return updateContractAction.trigger({ path, value, description, userId: state.currentUser?.userId });
},
// Navigation
setActiveSection: (section: TEditorSection) => setActiveSectionAction.trigger({ section }),
setEditorMode: (mode: TEditorMode) => setEditorModeAction.trigger({ mode }),
// Selection
selectParagraph: (paragraphId: string | null) => selectParagraphAction.trigger({ paragraphId }),
selectParty: (partyId: string | null) => selectPartyAction.trigger({ partyId }),
selectSignatureField: (fieldId: string | null) => selectSignatureFieldAction.trigger({ fieldId }),
// Undo/Redo
undo: () => undoAction.trigger(),
redo: () => redoAction.trigger(),
canUndo: () => statePart.getState().undoStack.length > 0,
canRedo: () => statePart.getState().redoStack.length > 0,
// Loading/Saving state
setLoading: (isLoading: boolean) => setLoadingAction.trigger({ isLoading }),
setSaving: (isSaving: boolean) => setSavingAction.trigger({ isSaving }),
markSaved: () => markSavedAction.trigger(),
// Discard changes
discardChanges: () => discardChangesAction.trigger(),
// Validation
setValidationErrors: (errors: IValidationError[]) => setValidationErrorsAction.trigger({ errors }),
clearValidationErrors: () => clearValidationErrorsAction.trigger(),
// User/Collaboration
setCurrentUser: (user: IEditorUser) => setCurrentUserAction.trigger({ user }),
setActiveCollaborators: (collaborators: sdInterfaces.IUserPresence[]) => setActiveCollaboratorsAction.trigger({ collaborators }),
addCollaborator: (collaborator: sdInterfaces.IUserPresence) => addCollaboratorAction.trigger({ collaborator }),
removeCollaborator: (userId: string) => removeCollaboratorAction.trigger({ userId }),
updateCollaborator: (userId: string, updates: Partial<sdInterfaces.IUserPresence>) => updateCollaboratorAction.trigger({ userId, updates }),
// Subscribe to state changes (using new API)
subscribe: (callback: (state: IEditorState) => void) => {
const subscription = statePart.select().subscribe(callback);
return () => subscription.unsubscribe();
},
};
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* Get nested value from object by path
*/
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
// Handle array index
const arrayMatch = key.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, index] = arrayMatch;
current = (current as Record<string, unknown>)[arrayKey];
if (Array.isArray(current)) {
current = current[parseInt(index, 10)];
} else {
return undefined;
}
} else {
current = (current as Record<string, unknown>)[key];
}
}
return current;
}
/**
* Set nested value in object by path (immutably)
*/
function setNestedValue<T extends Record<string, unknown>>(
obj: T,
path: string,
value: unknown
): T {
const keys = path.split('.');
const result = { ...obj } as Record<string, unknown>;
let current = result;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
// Handle array index
const arrayMatch = key.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, index] = arrayMatch;
const arr = [...((current[arrayKey] as unknown[]) || [])];
if (i === keys.length - 2) {
arr[parseInt(index, 10)] = value;
current[arrayKey] = arr;
return result as T;
} else {
arr[parseInt(index, 10)] = { ...(arr[parseInt(index, 10)] as Record<string, unknown> || {}) };
current[arrayKey] = arr;
current = arr[parseInt(index, 10)] as Record<string, unknown>;
}
} else {
if (typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
} else {
current[key] = { ...(current[key] as Record<string, unknown>) };
}
current = current[key] as Record<string, unknown>;
}
}
const lastKey = keys[keys.length - 1];
const arrayMatch = lastKey.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, index] = arrayMatch;
const arr = [...((current[arrayKey] as unknown[]) || [])];
arr[parseInt(index, 10)] = value;
current[arrayKey] = arr;
} else {
current[lastKey] = value;
}
return result as T;
}
/**
* Type for editor store
*/
export type TEditorStore = Awaited<ReturnType<typeof createEditorStore>>;

View File

@@ -0,0 +1,228 @@
/**
* @file types.ts
* @description Editor-specific types and event interfaces
*/
import type * as sdInterfaces from '@signature.digital/tools/interfaces';
// ============================================================================
// EDITOR NAVIGATION
// ============================================================================
/**
* Available editor sections/tabs
*/
export type TEditorSection =
| 'overview'
| 'parties'
| 'content'
| 'terms'
| 'signatures'
| 'attachments'
| 'collaboration'
| 'audit';
/**
* Section configuration
*/
export interface IEditorSectionConfig {
id: TEditorSection;
label: string;
icon: string;
badge?: number | string;
disabled?: boolean;
}
/**
* Default section configurations
*/
export const EDITOR_SECTIONS: IEditorSectionConfig[] = [
{ id: 'overview', label: 'Overview', icon: 'lucide:file-text' },
{ id: 'parties', label: 'Parties & Roles', icon: 'lucide:users' },
{ id: 'content', label: 'Content', icon: 'lucide:file-edit' },
{ id: 'terms', label: 'Terms', icon: 'lucide:calculator' },
{ id: 'signatures', label: 'Signatures', icon: 'lucide:pen-tool' },
{ id: 'attachments', label: 'Attachments', icon: 'lucide:paperclip' },
{ id: 'collaboration', label: 'Collaboration', icon: 'lucide:message-circle' },
{ id: 'audit', label: 'Audit & History', icon: 'lucide:history' },
];
// ============================================================================
// EDITOR STATE
// ============================================================================
/**
* Current user in the editor
*/
export interface IEditorUser {
userId: string;
displayName: string;
email: string;
avatarUrl?: string;
color: string;
}
/**
* Editor mode
*/
export type TEditorMode = 'edit' | 'view' | 'review' | 'sign';
/**
* Editor state interface
*/
export interface IEditorState {
// Contract data
contract: sdInterfaces.IPortableContract | null;
originalContract: sdInterfaces.IPortableContract | null;
// UI state
activeSection: TEditorSection;
editorMode: TEditorMode;
isDirty: boolean;
isSaving: boolean;
isLoading: boolean;
// Selection state
selectedParagraphId: string | null;
selectedPartyId: string | null;
selectedSignatureFieldId: string | null;
// Collaboration
currentUser: IEditorUser | null;
activeCollaborators: sdInterfaces.IUserPresence[];
// Validation
validationErrors: IValidationError[];
// History
undoStack: IContractChange[];
redoStack: IContractChange[];
}
/**
* Initial editor state factory
*/
export function createInitialEditorState(): IEditorState {
return {
contract: null,
originalContract: null,
activeSection: 'overview',
editorMode: 'edit',
isDirty: false,
isSaving: false,
isLoading: false,
selectedParagraphId: null,
selectedPartyId: null,
selectedSignatureFieldId: null,
currentUser: null,
activeCollaborators: [],
validationErrors: [],
undoStack: [],
redoStack: [],
};
}
// ============================================================================
// CHANGE TRACKING
// ============================================================================
/**
* Contract change for undo/redo
*/
export interface IContractChange {
id: string;
timestamp: number;
path: string;
previousValue: unknown;
newValue: unknown;
description: string;
userId?: string;
}
/**
* Validation error
*/
export interface IValidationError {
path: string;
message: string;
severity: 'error' | 'warning' | 'info';
fieldLabel?: string;
}
// ============================================================================
// EVENTS
// ============================================================================
/**
* Contract change event detail
*/
export interface IContractChangeEventDetail {
path: string;
value: unknown;
previousValue?: unknown;
source?: 'user' | 'collaboration' | 'system';
}
/**
* Section change event detail
*/
export interface ISectionChangeEventDetail {
section: TEditorSection;
previousSection: TEditorSection;
}
/**
* Save event detail
*/
export interface ISaveEventDetail {
contract: sdInterfaces.IPortableContract;
isDraft: boolean;
}
/**
* Custom event types
*/
export interface IEditorEvents {
'contract-change': CustomEvent<IContractChangeEventDetail>;
'section-change': CustomEvent<ISectionChangeEventDetail>;
'contract-save': CustomEvent<ISaveEventDetail>;
'contract-discard': CustomEvent<void>;
'validation-error': CustomEvent<IValidationError[]>;
}
// ============================================================================
// UTILITY TYPES
// ============================================================================
/**
* Deep path type for nested object access
*/
export type TDeepPath<T, K extends keyof T = keyof T> = K extends string
? T[K] extends Record<string, unknown>
? `${K}` | `${K}.${TDeepPath<T[K]>}`
: `${K}`
: never;
/**
* Contract field path
*/
export type TContractPath = string; // Simplified for runtime use
/**
* Field metadata for UI rendering
*/
export interface IFieldMetadata {
path: TContractPath;
label: string;
description?: string;
required: boolean;
type: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'multiselect' | 'checkbox' | 'custom';
options?: Array<{ value: string; label: string }>;
validation?: {
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
};
}