Skip to content
This repository was archived by the owner on Jul 8, 2025. It is now read-only.

Commit 9f45cd4

Browse files
committed
feat(editor): supporting undo & redo
1 parent 6ec36da commit 9f45cd4

File tree

17 files changed

+359
-184
lines changed

17 files changed

+359
-184
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"json-schema-defaults": "^0.4.0",
4747
"lodash.template": "^4.5.0",
4848
"mobx": "^5.15.4",
49+
"mobx-time-traveler": "^0.1.0",
4950
"mobx-react": "^6.1.8",
5051
"promise-timeout": "^1.3.0",
5152
"qrcode.react": "^1.0.1",

packages/editor/src/components/Header/OperationBar/Items/UndoAndClear.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
import * as React from 'react';
2+
import { observer } from 'mobx-react';
3+
import { useEffect } from 'react';
24
import { FiCornerUpLeft, FiCornerUpRight, FiX } from 'react-icons/fi';
35
import { Trans, useTranslation } from 'react-i18next';
46
import { unImplemented } from 'utils';
7+
import { hotkeyEvents, HotKeyEventTypes } from 'libs';
8+
import { timeTraveler } from 'libs/history';
59
import { OperationItem } from './OperationItem';
610
import { hotKeyPrefix } from './utils';
711

8-
export function UndoAndClear() {
12+
function IUndoAndClear() {
913
const { t } = useTranslation();
14+
const { canUndo, canRedo, undo, redo } = timeTraveler;
15+
16+
useEffect(() => {
17+
hotkeyEvents.only(HotKeyEventTypes.UNDO, undo);
18+
hotkeyEvents.only(HotKeyEventTypes.REDO, redo);
19+
}, []);
1020

1121
return (
1222
<>
@@ -20,7 +30,8 @@ export function UndoAndClear() {
2030
</>
2131
}
2232
icon={FiCornerUpLeft}
23-
action={unImplemented}
33+
action={undo}
34+
disabled={!canUndo}
2435
/>
2536
<OperationItem
2637
title={
@@ -32,10 +43,13 @@ export function UndoAndClear() {
3243
</>
3344
}
3445
icon={FiCornerUpRight}
35-
action={unImplemented}
46+
action={redo}
47+
disabled={!canRedo}
3648
/>
3749
<OperationItem title={t('clear')} icon={FiX} action={unImplemented} />
3850
<span className="operation_black" />
3951
</>
4052
);
4153
}
54+
55+
export const UndoAndClear = observer(IUndoAndClear);

packages/editor/src/libs/dsl/restore/index.ts

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,11 @@ function restoreStateFromDSL(dsl: string) {
6767
if (sharedComponentInstances) {
6868
restoreSharedComponentInstances(sharedComponentInstances);
6969
}
70+
71+
regenerateAllEventDeps();
7072
}
7173

7274
function restoreGlobal({ data, style, events, meta }: ReturnType<typeof parseDSL>) {
73-
restoreEventDep(DepsFromType.Global, { events });
7475
return globalStore.setState(store => {
7576
store.globalData = data;
7677
store.globalStyle = style;
@@ -84,7 +85,6 @@ function restorePageInstances(pages: PageInstance[]) {
8485
const { key, componentInstances, pluginInstances } = page;
8586
page.componentInstances = restoreComponentInstances(key, componentInstances);
8687
page.pluginInstances = restorePluginInstances(key, pluginInstances!);
87-
restoreEventDep(DepsFromType.Page, page);
8888
});
8989
return pagesStore.setState(store => (store.pages = pages));
9090
}
@@ -93,15 +93,13 @@ function restoreComponentInstances(pageKey: number, iComponentInstances: Compone
9393
const componentInstances = iComponentInstances.filter(filterComponent);
9494
const indexMap = generateComponentsIndex(componentInstances);
9595
addPageComponentInstanceIndexMap(pageKey, indexMap);
96-
componentInstances.forEach(R.unary(R.partial(restoreEventDep, [DepsFromType.Component])));
9796
return componentInstances;
9897
}
9998

10099
function restorePluginInstances(pageKey: number, iPluginInstances: PluginInstance[]) {
101100
const pluginInstances = iPluginInstances.filter(filterPlugin);
102101
const indexMap = generatePluginsIndex(pluginInstances);
103102
addPagePluginInstanceIndexMap(pageKey, indexMap);
104-
pluginInstances.forEach(R.unary(R.partial(restoreEventDep, [DepsFromType.Plugin])));
105103
return pluginInstances;
106104
}
107105

@@ -111,10 +109,44 @@ function restoreSharedComponentInstances(iComponentInstances: ComponentInstance[
111109

112110
const indexMap = generateComponentsIndex(componentInstances);
113111
setSharedComponentIndexMap(indexMap);
114-
componentInstances.forEach(R.unary(R.partial(restoreEventDep, [DepsFromType.Component])));
115112
return componentInstances;
116113
}
117114

115+
function restoreEditInfo({ maxKeys, layoutMode, pageMode }: EditInfoDSL) {
116+
editStore.setState(editStore => {
117+
editStore.layoutMode = layoutMode;
118+
editStore.pageMode = pageMode;
119+
});
120+
121+
if (maxKeys) {
122+
setMaxKey(InstanceKeyType.Page, maxKeys[InstanceKeyType.Page]);
123+
setMaxKey(InstanceKeyType.Component, maxKeys[InstanceKeyType.Component]);
124+
setMaxKey(InstanceKeyType.HotArea, maxKeys[InstanceKeyType.HotArea]);
125+
setMaxKey(InstanceKeyType.Plugin, maxKeys[InstanceKeyType.Plugin]);
126+
setMaxKey(InstanceKeyType.Action, maxKeys[InstanceKeyType.Action]);
127+
}
128+
}
129+
130+
interface ExtraInfo {
131+
owner: UserRecord;
132+
}
133+
134+
function restoreExtraInfo({ owner }: ExtraInfo) {
135+
return editStore.setState(store => {
136+
store.owner = owner;
137+
}, true);
138+
}
139+
140+
export function regenerateAllEventDeps() {
141+
restoreEventDep(DepsFromType.Global, { events: globalStore.globalEvents });
142+
pagesStore.pages.forEach(page => {
143+
restoreEventDep(DepsFromType.Page, page);
144+
page.componentInstances.forEach(R.unary(R.partial(restoreEventDep, [DepsFromType.Component])));
145+
page.pluginInstances.forEach(R.unary(R.partial(restoreEventDep, [DepsFromType.Plugin])));
146+
});
147+
sharedStore.sharedComponentInstances.forEach(R.unary(R.partial(restoreEventDep, [DepsFromType.Component])));
148+
}
149+
118150
function restoreEventDep(
119151
depsFromType: DepsFromType,
120152
instance: PageInstance | ComponentInstance | PluginInstance | HotArea | { events: EventInstance[] },
@@ -141,28 +173,3 @@ function restoreEventDep(
141173
}
142174
});
143175
}
144-
145-
function restoreEditInfo({ maxKeys, layoutMode, pageMode }: EditInfoDSL) {
146-
editStore.setState(editStore => {
147-
editStore.layoutMode = layoutMode;
148-
editStore.pageMode = pageMode;
149-
});
150-
151-
if (maxKeys) {
152-
setMaxKey(InstanceKeyType.Page, maxKeys[InstanceKeyType.Page]);
153-
setMaxKey(InstanceKeyType.Component, maxKeys[InstanceKeyType.Component]);
154-
setMaxKey(InstanceKeyType.HotArea, maxKeys[InstanceKeyType.HotArea]);
155-
setMaxKey(InstanceKeyType.Plugin, maxKeys[InstanceKeyType.Plugin]);
156-
setMaxKey(InstanceKeyType.Action, maxKeys[InstanceKeyType.Action]);
157-
}
158-
}
159-
160-
interface ExtraInfo {
161-
owner: UserRecord;
162-
}
163-
164-
function restoreExtraInfo({ owner }: ExtraInfo) {
165-
return editStore.setState(store => {
166-
store.owner = owner;
167-
}, true);
168-
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { TimeTravelerConfig } from './types';
2+
3+
export const config: TimeTravelerConfig = {
4+
maxStacks: 20,
5+
};
6+
7+
export function configure(newConfig: Partial<TimeTravelerConfig>) {
8+
return Object.assign(config, newConfig);
9+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { action } from 'mobx';
2+
import { timeTraveler } from './timeTraveling';
3+
import { recordStoreHistory, recordedStores } from './record';
4+
5+
export const withTimeTravel = <T extends { new (...args: unknown[]): any }>(target: T): T => {
6+
const original = target;
7+
const f = function(...args: ConstructorParameters<typeof target>) {
8+
const { name } = original;
9+
if (recordedStores[name]) {
10+
throw new Error(
11+
`Store "${name}" has already been recorded. Make sure that every store has an unique class name and instantiated only once`,
12+
);
13+
}
14+
15+
const instance = new original(...args);
16+
recordStoreHistory({ [name]: instance as object });
17+
return instance;
18+
};
19+
f.prototype = original.prototype;
20+
return f as any;
21+
};
22+
23+
let updating = false;
24+
25+
export const withSnapshot = (payload?: Record<string, any>) => (
26+
target: object,
27+
propertyKey: string,
28+
descriptor?: PropertyDescriptor,
29+
): void => {
30+
const { initializer } = descriptor as any;
31+
descriptor!.value = function(...args: Parameters<ReturnType<typeof initializer>>) {
32+
const inUpdating = updating;
33+
if (!inUpdating) {
34+
updating = true;
35+
}
36+
const result = initializer.apply(this).apply(this, args);
37+
if (!inUpdating) {
38+
setTimeout(async () => {
39+
if (result instanceof Promise) {
40+
await result;
41+
}
42+
timeTraveler.updateSnapshots(payload);
43+
updating = false;
44+
}, 0);
45+
}
46+
return result;
47+
};
48+
};
49+
50+
export function actionWithSnapshot(target: object, propertyKey: string, descriptor?: PropertyDescriptor): void;
51+
export function actionWithSnapshot(
52+
payload: Record<string, any>,
53+
): (target: object, propertyKey: string, descriptor?: PropertyDescriptor) => void;
54+
export function actionWithSnapshot(
55+
payloadOrTarget: Record<string, any> | object,
56+
propertyKey?: string,
57+
descriptor?: PropertyDescriptor,
58+
) {
59+
if (!propertyKey) {
60+
const payload = payloadOrTarget as Record<string, any>;
61+
return (target: object, propertyKey: string, descriptor?: PropertyDescriptor) => {
62+
withSnapshot(payload)(target, propertyKey, descriptor);
63+
return action(target, propertyKey, descriptor);
64+
};
65+
}
66+
67+
const target = payloadOrTarget as object;
68+
withSnapshot(undefined)(target, propertyKey, descriptor);
69+
return action(target, propertyKey, descriptor);
70+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { isArray, isMap, isObject, isFunction } from './utils';
2+
3+
export function dehydrate(model: any) {
4+
if (isArray(model)) {
5+
return model.length ? model.map(dehydrate) : [];
6+
}
7+
8+
if (isMap(model)) {
9+
if (model.size) {
10+
const map: { [key: string]: any } = {};
11+
model.forEach((value: any, key: string) => {
12+
map[key] = dehydrate(value);
13+
});
14+
return map;
15+
}
16+
return {};
17+
}
18+
19+
if (isObject(model)) {
20+
return Object.keys(model).reduce<Record<string, any>>((acc, stateName) => {
21+
const value = dehydrate(model[stateName]);
22+
if (value !== undefined) {
23+
acc[stateName] = value;
24+
}
25+
return acc;
26+
}, {});
27+
}
28+
29+
if (isFunction(model)) {
30+
return undefined;
31+
}
32+
33+
return model;
34+
}
Lines changed: 4 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,4 @@
1-
import { walkAndSerialize } from './utils';
2-
import { runInAction } from 'mobx';
3-
4-
const recordStates: { [key: string]: object } = {};
5-
const stacks: typeof recordStates[] = [];
6-
let cursor = 0;
7-
8-
(window as any).stacks = stacks;
9-
(window as any).undo = undo;
10-
(window as any).redo = redo;
11-
12-
export function undo() {
13-
if (stacks.length <= 1 || cursor === 0) {
14-
return;
15-
}
16-
const snapshot = stacks[--cursor];
17-
return restoreSnapshot(snapshot);
18-
}
19-
20-
export function redo() {
21-
if (stacks.length <= 1 || cursor === stacks.length) {
22-
return;
23-
}
24-
const snapshot = stacks[++cursor];
25-
return restoreSnapshot(snapshot);
26-
}
27-
28-
function restoreSnapshot(snapshot: typeof recordStates) {
29-
return runInAction(() => {
30-
Object.entries(snapshot).forEach(([key, v]) => {
31-
Object.assign(recordStates[key], v);
32-
});
33-
});
34-
}
35-
36-
export function recordHistory(states: typeof recordStates) {
37-
Object.assign(recordStates, states);
38-
updateSnapshots(true);
39-
}
40-
41-
let inReaction = false;
42-
export const withHistory = (
43-
target: { [key: string]: any },
44-
propertyKey: string,
45-
descriptor?: PropertyDescriptor,
46-
): void => {
47-
const { initializer } = descriptor as any;
48-
descriptor!.value = function(...args: Parameters<ReturnType<typeof initializer>>) {
49-
const reaction = inReaction;
50-
if (!reaction) {
51-
inReaction = true;
52-
}
53-
const result = initializer.apply(this).apply(this, args);
54-
if (!reaction) {
55-
setTimeout(async () => {
56-
if (result instanceof Promise) {
57-
await result;
58-
}
59-
updateSnapshots();
60-
inReaction = false;
61-
}, 0);
62-
}
63-
return result;
64-
};
65-
};
66-
67-
function updateSnapshots(assign = false) {
68-
const snapshot = Object.entries(recordStates).reduce<{ [key: string]: object }>((accu, [k, v]) => {
69-
accu[k] = walkAndSerialize(v);
70-
return accu;
71-
}, {});
72-
if (assign && stacks.length) {
73-
Object.assign(stacks[stacks.length - 1], snapshot);
74-
} else {
75-
stacks.push(snapshot);
76-
}
77-
cursor = stacks.length - 1;
78-
}
1+
export { configure } from './configure';
2+
export * from './decorators';
3+
export * from './timeTraveling';
4+
export * from './types';
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Snapshots } from './types';
2+
import { runInAction } from 'mobx';
3+
4+
export const recordedStores: Snapshots = {};
5+
6+
export function recordStoreHistory(states: Snapshots) {
7+
return Object.assign(recordedStores, states);
8+
}
9+
10+
export function restoreSnapshot(snapshot: Snapshots, callback?: Function) {
11+
return runInAction(() => {
12+
Object.entries(snapshot).forEach(([key, v]) => {
13+
if (key === 'payload') {
14+
return;
15+
}
16+
Object.assign(recordedStores[key], v);
17+
});
18+
callback?.();
19+
});
20+
}

0 commit comments

Comments
 (0)