Skip to content

Commit 086932d

Browse files
committed
feat(editor2): entity IDs conflicting when restoring a state saved in previous sessions
1 parent 23d4450 commit 086932d

File tree

4 files changed

+93
-43
lines changed

4 files changed

+93
-43
lines changed

src/components/editor2/EditorToolbar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { AppToaster } from '../Toaster'
2424
import { Settings } from './Settings'
2525
import { editorAtoms, historyAtom, useEdit } from './editor-state'
2626
import { useHistoryControls, useHistoryValue } from './history'
27+
import { hydrateOperation } from './reconciliation'
2728
import { SourceEditorButton } from './source/SourceEditor'
2829
import {
2930
AUTO_SAVE_INTERVAL,
@@ -191,7 +192,10 @@ const AutoSaveButton = (buttonProps: ButtonProps) => {
191192
key={record.t}
192193
onClick={() => {
193194
edit(() => {
194-
setEditorState(record.v)
195+
setEditorState({
196+
...record.v,
197+
operation: hydrateOperation(record.v.operation),
198+
})
195199
return {
196200
action: 'restore',
197201
desc: i18n.actions.editor2.restore_from_autosave,

src/components/editor2/reconciliation.ts

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import camelcaseKeys from 'camelcase-keys'
22
import { atom } from 'jotai'
33
import { defaults, uniqueId } from 'lodash-es'
4-
import {
5-
CamelCasedPropertiesDeep,
6-
PartialDeep,
7-
SetOptional,
8-
SetRequired,
9-
} from 'type-fest'
4+
import { PartialDeep, SetOptional, SetRequired } from 'type-fest'
105

116
import { CopilotDocV1 } from '../../models/copilot.schema'
127
import { FavGroup, favGroupAtom } from '../../store/useFavGroups'
@@ -36,6 +31,14 @@ export type WithPartialCoordinates<T> = T extends {
3631

3732
export type WithId<T = {}> = T extends never ? never : T & { id: string }
3833

34+
type DehydratedEditorOperation = WithoutIdDeep<EditorOperation>
35+
36+
type WithoutIdDeep<T> = T extends unknown[]
37+
? { [K in keyof T]: WithoutIdDeep<T[K]> }
38+
: T extends object
39+
? Omit<{ [K in keyof T]: WithoutIdDeep<T[K]> }, 'id'>
40+
: T
41+
3942
export function createAction(
4043
initialValues: SetRequired<Partial<Omit<EditorAction, 'id'>>, 'type'>,
4144
) {
@@ -168,33 +171,64 @@ export const editorFavGroupsAtom = atom(
168171
},
169172
)
170173

171-
export function toEditorOperation(
172-
source: CopilotOperationLoose,
174+
/**
175+
* Converts the operation to a dehydrated format that is suitable
176+
* for storage or transmission. Essentially, it strips all `id` fields
177+
* which only makes sense in the context of the editor.
178+
*/
179+
export function dehydrateOperation(
180+
source: EditorOperation,
181+
): DehydratedEditorOperation {
182+
return {
183+
...source,
184+
opers: source.opers.map(({ id, ...operator }) => operator),
185+
groups: source.groups.map(({ id, opers, ...group }) => ({
186+
...group,
187+
opers: opers.map(({ id, ...operator }) => operator),
188+
})),
189+
actions: source.actions.map(({ id, ...action }) => action),
190+
}
191+
}
192+
193+
export function hydrateOperation(
194+
source: DehydratedEditorOperation,
173195
): EditorOperation {
174-
const camelCased = camelcaseKeys(source, { deep: true })
175-
const operation = JSON.parse(JSON.stringify(camelCased))
176-
const converted = {
177-
...operation,
178-
opers: operation.opers.map((operator) => ({
196+
return {
197+
...source,
198+
opers: source.opers.map((operator) => ({
179199
...operator,
180200
id: uniqueId(),
181201
})),
182-
groups: operation.groups.map((group) => ({
202+
groups: source.groups.map((group) => ({
183203
...group,
184204
id: uniqueId(),
185-
opers: group.opers.map((operator) => ({ ...operator, id: uniqueId() })),
205+
opers: group.opers.map((operator) => ({
206+
...operator,
207+
id: uniqueId(),
208+
})),
186209
})),
210+
actions: source.actions.map((action) => ({
211+
...action,
212+
id: uniqueId(),
213+
})),
214+
}
215+
}
216+
217+
export function toEditorOperation(
218+
source: CopilotOperationLoose,
219+
): EditorOperation {
220+
const camelCased = camelcaseKeys(source, { deep: true })
221+
const operation = JSON.parse(JSON.stringify(camelCased)) as typeof camelCased
222+
const converted = {
223+
...operation,
187224
actions: operation.actions.map((action, index) => {
188225
const {
189226
preDelay,
190227
postDelay,
191228
rearDelay,
192229
...newAction
193-
}: EditorAction &
194-
CamelCasedPropertiesDeep<CopilotOperationLoose['actions'][number]> = {
195-
...action,
196-
id: uniqueId(),
197-
}
230+
}: WithoutIdDeep<EditorAction> & (typeof camelCased)['actions'][number] =
231+
action
198232
// intermediatePostDelay 等于当前动作的 preDelay
199233
if (preDelay !== undefined) {
200234
newAction.intermediatePostDelay = preDelay
@@ -209,11 +243,11 @@ export function toEditorOperation(
209243
newAction.intermediatePreDelay = prevAction.postDelay
210244
}
211245
}
212-
return newAction satisfies EditorAction
246+
return newAction satisfies WithoutIdDeep<EditorAction>
213247
}),
214248
}
215249

216-
return converted
250+
return hydrateOperation(converted)
217251
}
218252

219253
/**
@@ -223,22 +257,17 @@ export function toMaaOperation(
223257
operation: EditorOperation,
224258
): CopilotOperationLoose {
225259
operation = JSON.parse(JSON.stringify(operation))
260+
const dehydrated = dehydrateOperation(operation)
226261
const converted = {
227-
...operation,
228-
opers: operation.opers.map(({ id, ...operator }) => operator),
229-
groups: operation.groups.map(({ id, opers, ...group }) => ({
230-
...group,
231-
opers: opers.map(({ id, ...operator }) => operator),
232-
})),
233-
actions: operation.actions.map((action, index, actions) => {
262+
...dehydrated,
263+
actions: dehydrated.actions.map((action, index, actions) => {
234264
type Action = PartialDeep<WithPartialCoordinates<CopilotDocV1.Action>>
235265
const {
236266
_id,
237-
id,
238267
intermediatePreDelay,
239268
intermediatePostDelay,
240269
...newAction
241-
}: EditorAction & Action = action
270+
}: WithoutIdDeep<EditorAction> & Action = action
242271
// preDelay 等于当前动作的 intermediatePostDelay
243272
if (intermediatePostDelay !== undefined) {
244273
newAction.preDelay = intermediatePostDelay

src/components/editor2/useAutoSave.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { i18n } from '../../i18n/i18n'
77
import { formatError } from '../../utils/error'
88
import { AppToaster } from '../Toaster'
99
import { EditorState, defaultEditorState, editorAtoms } from './editor-state'
10+
import { dehydrateOperation } from './reconciliation'
1011

1112
type Archive = Record[]
1213

@@ -34,23 +35,29 @@ export const editorArchiveAtom = atomWithStorage(
3435
[] as Archive,
3536
)
3637
export const editorSaveAtom = atom(noop, (get, set) => {
38+
const state = get(editorAtoms.editor)
39+
const dehydratedState = {
40+
...state,
41+
operation: dehydrateOperation(state.operation),
42+
}
43+
3744
// this will drop undefined properties but we don't care
38-
const currentStringified = JSON.stringify(get(editorAtoms.editor))
45+
const stringifiedState = JSON.stringify(dehydratedState)
3946

40-
if (currentStringified === defaultEditorStateStringified) {
47+
if (stringifiedState === defaultEditorStateStringified) {
4148
throw new NotChangedError()
4249
}
4350

4451
const records = get(editorArchiveAtom)
4552
const latestRecord = first(records)
4653

47-
if (latestRecord && JSON.stringify(latestRecord.v) === currentStringified) {
54+
if (latestRecord && JSON.stringify(latestRecord.v) === stringifiedState) {
4855
throw new NotChangedError()
4956
}
5057

51-
const current: EditorState = JSON.parse(currentStringified)
58+
const finalState: EditorState = JSON.parse(stringifiedState)
5259
const record: Record = {
53-
v: current,
60+
v: finalState,
5461
t: Date.now(),
5562
}
5663
const newArchive = [record, ...records].slice(0, AUTO_SAVE_LIMIT)

src/components/editor2/validation/schema.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,23 @@ const groupStrict = group.extend({
6767
const actionShape = {
6868
name: z.string().min(1).optional(),
6969
location: z
70-
.tuple([z.number().int().optional(), z.number().int().optional()])
70+
.tuple([
71+
z.number().int().or(z.undefined()),
72+
z.number().int().or(z.undefined()),
73+
])
74+
.optional(),
75+
76+
// We have to use `distance: z.string()` here and later validate it in `.check()`,
77+
// because if we use `distance: z.enum()` here, it becomes the second discriminator key,
78+
// which leads to very counterintuitive behavior when parsing. See: https://github.com/colinhacks/zod/issues/4280
79+
// We also need to cast its type to match the expected type in `actionWithDirection` below.
80+
direction: z.string().optional() as unknown as z.ZodOptional<
81+
typeof actionWithDirection.shape.direction
82+
>,
83+
84+
distance: z
85+
.tuple([z.number().or(z.undefined()), z.number().or(z.undefined())])
7186
.optional(),
72-
direction: z.string().optional(),
73-
distance: z.tuple([z.number().optional(), z.number().optional()]).optional(),
7487
skill_usage: operator.shape.skill_usage,
7588
skill_times: operator.shape.skill_times,
7689

@@ -85,9 +98,6 @@ const actionShape = {
8598
doc: z.string().optional(),
8699
doc_color: z.string().optional(),
87100
}
88-
// we have to put `distance: z.string()` in actionShape and validate it using another schema,
89-
// because if we put `distance: z.enum()` in actionShape, it becomes the second discriminator key,
90-
// which leads to very counterintuitive behavior when parsing. See: https://github.com/colinhacks/zod/issues/4280
91101
const actionWithDirection = z.object({
92102
direction: z.enum(CopilotDocV1.Direction),
93103
})

0 commit comments

Comments
 (0)