p4-vscode_designer_extension/src/webview/react/state.tsx
2025-12-26 19:28:22 +03:00

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>
);
}