291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
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<Widget> } }
|
|
| {
|
|
type: 'updateProps';
|
|
payload: { id: string; properties: Record<string, any> };
|
|
}
|
|
| { 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<AppState | undefined>(undefined);
|
|
const AppDispatchContext = createContext<React.Dispatch<Action> | 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 (
|
|
<AppStateContext.Provider value={state}>
|
|
<AppDispatchContext.Provider value={dispatch}>
|
|
{children}
|
|
</AppDispatchContext.Provider>
|
|
</AppStateContext.Provider>
|
|
);
|
|
}
|