/** * @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('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( 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( 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( async (statePartArg) => { const state = statePartArg.getState(); return { ...state, originalContract: state.contract ? structuredClone(state.contract) : null, isDirty: false, isSaving: false, }; } ); const discardChangesAction = statePart.createAction( 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( 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 }>( 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) => 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, 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)[arrayKey]; if (Array.isArray(current)) { current = current[parseInt(index, 10)]; } else { return undefined; } } else { current = (current as Record)[key]; } } return current; } /** * Set nested value in object by path (immutably) */ function setNestedValue>( obj: T, path: string, value: unknown ): T { const keys = path.split('.'); const result = { ...obj } as Record; 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 || {}) }; current[arrayKey] = arr; current = arr[parseInt(index, 10)] as Record; } } else { if (typeof current[key] !== 'object' || current[key] === null) { current[key] = {}; } else { current[key] = { ...(current[key] as Record) }; } current = current[key] as Record; } } 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>;