feat(catalog): add ContractEditor and many editor subcomponents; implement SignPad and SignBox; update README and bump dependencies
This commit is contained in:
407
ts_web/elements/sdig-contracteditor/state.ts
Normal file
407
ts_web/elements/sdig-contracteditor/state.ts
Normal 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>>;
|
||||
Reference in New Issue
Block a user