408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
/**
|
|
* @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>>;
|