import React, { createContext, useContext, useReducer, useMemo } from 'react'; import { produce } from 'immer'; import type { DesignData, Widget, WidgetType, EventBinding } from './types'; export interface AppState { vscode: { postMessage: (msg: any) => void }; design: DesignData; selectedWidgetId: string | null; history: DesignData[]; historyIndex: number; } type Action = | { type: 'init'; payload: DesignData } | { type: 'addWidget'; payload: { type: WidgetType; x: number; y: number } } | { type: 'selectWidget'; payload: { id: string | null } } | { type: 'updateWidget'; payload: { id: string; patch: Partial } } | { type: 'updateProps'; payload: { id: string; properties: Record }; } | { type: 'deleteWidget'; payload: { id: string } } | { type: 'addEvent'; payload: EventBinding } | { type: 'removeEvent'; payload: { widget: string; type: string; name?: string }; } | { type: 'clear' } | { type: 'undo' } | { type: 'redo' } | { type: 'setForm'; payload: { name?: string; title?: string; size?: { width: number; height: number }; className?: string; }; } | { type: 'pushHistory' }; const AppStateContext = createContext(undefined); const AppDispatchContext = createContext | undefined>( undefined ); // Helper to check if two design states are effectively different to warrant a history entry // This helps prevent spamming history with tiny drag updates if we were to push every frame (though we usually push on mouse up) function hasDesignChanged(prev: DesignData, next: DesignData): boolean { return JSON.stringify(prev) !== JSON.stringify(next); } function reducer(state: AppState, action: Action): AppState { if (action.type === 'undo') { if (state.historyIndex <= 0) return state; const idx = state.historyIndex - 1; return { ...state, historyIndex: idx, design: JSON.parse(JSON.stringify(state.history[idx])), }; } if (action.type === 'redo') { if (state.historyIndex >= state.history.length - 1) return state; const idx = state.historyIndex + 1; return { ...state, historyIndex: idx, design: JSON.parse(JSON.stringify(state.history[idx])), }; } if (action.type === 'pushHistory') { // This action is explicitly called when an operation is "finished" (e.g. onMouseUp after drag) if (state.historyIndex >= 0 && !hasDesignChanged(state.history[state.historyIndex], state.design)) { return state; } return produce(state, (draft) => { pushHistoryDraft(draft); }); } // For other actions, we update the state but DO NOT push to history automatically // unless it's a discrete action like 'addWidget' or 'deleteWidget'. // Continuous actions like 'updateWidget' (drag) should only update state, // and rely on a subsequent 'pushHistory' or explicit logic to save state. // However, to keep backward compatibility with existing components that might not call pushHistory, // we will still push history for "one-off" actions, but we should be careful with high-frequency updates. // In the previous implementation, every update pushed history. // We will optimize this by only pushing history for significant actions, // or letting the UI trigger history pushes for drag operations. return produce(state, (draft) => { let shouldPushHistory = false; switch (action.type) { case 'init': { draft.design = action.payload; draft.selectedWidgetId = null; if (!draft.design.events) draft.design.events = []; // Init resets history usually draft.history = [JSON.parse(JSON.stringify(draft.design))]; draft.historyIndex = 0; break; } case 'addWidget': { const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const w: Widget = { id, type: action.payload.type, x: action.payload.x, y: action.payload.y, width: 120, height: 40, properties: { text: action.payload.type }, }; draft.design.widgets.push(w); draft.selectedWidgetId = id; shouldPushHistory = true; break; } case 'selectWidget': { draft.selectedWidgetId = action.payload.id; // Selection change doesn't need history break; } case 'updateWidget': { const idx = draft.design.widgets.findIndex( (w) => w.id === action.payload.id ); if (idx >= 0) { Object.assign( draft.design.widgets[idx], action.payload.patch ); } // Don't push history here for every drag frame. // Components should dispatch 'pushHistory' on drag end. break; } case 'updateProps': { const idx = draft.design.widgets.findIndex( (w) => w.id === action.payload.id ); if (idx >= 0) { draft.design.widgets[idx].properties = { ...draft.design.widgets[idx].properties, ...action.payload.properties, }; } shouldPushHistory = true; break; } case 'deleteWidget': { draft.design.widgets = draft.design.widgets.filter( (w) => w.id !== action.payload.id ); if (!draft.design.events) draft.design.events = []; draft.design.events = draft.design.events.filter( (e) => e.widget !== action.payload.id ); draft.selectedWidgetId = null; shouldPushHistory = true; break; } case 'addEvent': { if (!draft.design.events) draft.design.events = []; const existsIndex = draft.design.events.findIndex( (e) => e.widget === action.payload.widget && e.type === action.payload.type && e.name === action.payload.name ); if (existsIndex >= 0) { draft.design.events[existsIndex].code = action.payload.code; } else { draft.design.events.push(action.payload); } shouldPushHistory = true; break; } case 'removeEvent': { if (!draft.design.events) draft.design.events = []; draft.design.events = draft.design.events.filter((e) => { const matchWidget = e.widget === action.payload.widget; const matchType = e.type === action.payload.type; const matchName = action.payload.name ? e.name === action.payload.name : true; return !(matchWidget && matchType && matchName); }); shouldPushHistory = true; break; } case 'clear': { draft.design.widgets = []; draft.design.events = []; draft.selectedWidgetId = null; shouldPushHistory = true; break; } case 'setForm': { if (action.payload.name) draft.design.form.name = action.payload.name; if (action.payload.title) draft.design.form.title = action.payload.title; if (action.payload.size) draft.design.form.size = action.payload.size; if (action.payload.className) draft.design.form.className = action.payload.className; shouldPushHistory = true; break; } } if (shouldPushHistory) { pushHistoryDraft(draft); } }); } function pushHistoryDraft(draft: AppState) { // Limit history stack size to prevent memory issues const MAX_HISTORY = 50; // Remove future history if we were in the middle of the stack if (draft.historyIndex < draft.history.length - 1) { draft.history = draft.history.slice(0, draft.historyIndex + 1); } draft.history.push(JSON.parse(JSON.stringify(draft.design))); if (draft.history.length > MAX_HISTORY) { draft.history.shift(); // Remove oldest } draft.historyIndex = draft.history.length - 1; } export function useAppState() { const ctx = useContext(AppStateContext); if (!ctx) throw new Error('useAppState must be used within AppProvider'); return ctx; } export function useAppDispatch() { const ctx = useContext(AppDispatchContext); if (!ctx) throw new Error('useAppDispatch must be used within AppProvider'); return ctx; } export function AppProvider({ children, vscode, }: { children: React.ReactNode; vscode: { postMessage: (msg: any) => void }; }) { const initialDesign: DesignData = useMemo( () => ({ form: { name: 'Form1', title: 'My App', size: { width: 800, height: 600 }, }, widgets: [], events: [], }), [] ); const initial: AppState = { vscode, design: initialDesign, selectedWidgetId: null, history: [initialDesign], historyIndex: 0, }; const [state, dispatch] = useReducer(reducer, initial); return ( {children} ); }