diff --git a/docs/content/examples/map-editor.tsx b/docs/content/examples/map-editor.tsx index 7eb890b9..2fe6f8c5 100644 --- a/docs/content/examples/map-editor.tsx +++ b/docs/content/examples/map-editor.tsx @@ -80,6 +80,8 @@ export default function MapEditorExample() { const handleMapUpdate = useCallback( (variables: MapCreateVariables | MapUpdateVariables) => { + const slug = toSlug(variables.mapName); + setMapObject({ campaigns: { edges: [], @@ -90,9 +92,9 @@ export default function MapEditorExample() { username: viewer.username, }, effects: JSON.stringify(encodeEffects(variables.effects)), - id: 'id' in variables ? variables.id : '', + id: 'id' in variables ? variables.id : slug, name: variables.mapName, - slug: toSlug(variables.mapName), + slug, state: JSON.stringify(variables.map.toJSON()), tags: variables.tags, }); diff --git a/hera/editor/MapEditor.tsx b/hera/editor/MapEditor.tsx index 70447499..9289e15a 100644 --- a/hera/editor/MapEditor.tsx +++ b/hera/editor/MapEditor.tsx @@ -26,12 +26,14 @@ import validateMap from '@deities/athena/lib/validateMap.tsx'; import withModifiers from '@deities/athena/lib/withModifiers.tsx'; import { Biome, Biomes } from '@deities/athena/map/Biome.tsx'; import { DoubleSize, TileSize } from '@deities/athena/map/Configuration.tsx'; +import { PlainMap } from '@deities/athena/map/PlainMap.tsx'; import { Bot, HumanPlayer, PlayerID } from '@deities/athena/map/Player.tsx'; import { toTeamArray } from '@deities/athena/map/Team.tsx'; import MapData, { SizeVector } from '@deities/athena/MapData.tsx'; import getFirstOrThrow from '@deities/hephaestus/getFirstOrThrow.tsx'; import isPresent from '@deities/hephaestus/isPresent.tsx'; import random from '@deities/hephaestus/random.tsx'; +import toSlug from '@deities/hephaestus/toSlug.tsx'; import { ClientGame } from '@deities/hermes/game/toClientGame.tsx'; import { isIOS } from '@deities/ui/Browser.tsx'; import isControlElement from '@deities/ui/controls/isControlElement.tsx'; @@ -89,7 +91,10 @@ import useZoom from './hooks/useZoom.tsx'; import BiomeIcon from './lib/BiomeIcon.tsx'; import canFillTile from './lib/canFillTile.tsx'; import getMapValidationErrorText from './lib/getMapValidationErrorText.tsx'; -import updateUndoStack from './lib/updateUndoStack.tsx'; +import updateUndoStack, { + getUndoStackIndexKeyFor, + getUndoStackKeyFor, +} from './lib/updateUndoStack.tsx'; import ZoomButton from './lib/ZoomButton.tsx'; import MapEditorControlPanel from './panels/MapEditorControlPanel.tsx'; import ResizeHandle from './ResizeHandle.tsx'; @@ -103,15 +108,39 @@ import { PreviousMapEditorState, SaveMapFunction, SetMapFunction, + UndoStack, } from './Types.tsx'; const generateName = nameGenerator(); +export function decodeUndoStack( + encodedUndoStack: string | null, +): UndoStack | null { + if (!encodedUndoStack) { + return null; + } + + try { + return JSON.parse(encodedUndoStack).map( + ([key, value]: [string, PlainMap]) => [key, MapData.fromObject(value)], + ); + } catch { + return null; + } +} + +const getUndoStack = (id?: string) => + decodeUndoStack(Storage.getItem(getUndoStackKeyFor(id))); + +const getUndoStackIndex = (id?: string) => { + const index = Storage.getItem(getUndoStackIndexKeyFor(id)); + return index ? Number.parseInt(index, 10) : undefined; +}; + const startAction = { type: 'Start', } as const; -const MAP_KEY = 'map-editor-previous-map'; const EFFECTS_KEY = 'map-editor-previous-effects'; const getDefaultScenario = (effects: Effects) => @@ -125,6 +154,8 @@ const getEditorBaseState = ( mapObject: Pick | null = null, mode: EditorMode = 'design', scenario?: Scenario, + undoStack?: UndoStack | null, + undoStackIndex?: number | null, ): EditorState => { const startScenario = new Set([{ actions: [] }]); let effects: Effects = mapObject?.effects @@ -133,6 +164,7 @@ const getEditorBaseState = ( if (!effects.has('Start')) { effects = new Map([...effects, ['Start', startScenario]]); } + return { effects, isDrawing: false, @@ -143,8 +175,8 @@ const getEditorBaseState = ( selected: { tile: Plain.id, }, - undoStack: [['initial', map]], - undoStackIndex: null, + undoStack: undoStack ?? [['initial', map]], + undoStackIndex: undoStackIndex ?? null, }; }; @@ -169,6 +201,23 @@ export type BaseMapEditorProps = Readonly<{ setHasChanges: (hasChanges: boolean) => void; }>; +const getInitialMapFromUndoStack = (id?: string) => { + const undoStack = getUndoStack(id); + + if (!undoStack) { + return null; + } + + const undoStackIndex = getUndoStackIndex(id) ?? undoStack.length - 1; + const map = undoStack.at(undoStackIndex)?.[1]; + + if (!map) { + return null; + } + + return { map, undoStack, undoStackIndex }; +}; + export default function MapEditor({ animationSpeed, campaignLock, @@ -212,7 +261,8 @@ export default function MapEditor({ (size = new SizeVector(random(10, 15), random(10, 15))): MapData => { return withHumanPlayer( mapObject - ? MapData.fromJSON(mapObject.state)! + ? getInitialMapFromUndoStack(mapObject?.id)?.map ?? + MapData.fromJSON(mapObject.state)! : withModifiers( generateSea( generateBuildings( @@ -241,7 +291,14 @@ export default function MapEditor({ usePlayMusic(map.config.biome); const [editor, _setEditorState] = useState(() => - getEditorBaseState(map, mapObject, mode, scenario), + getEditorBaseState( + map, + mapObject, + mode, + scenario, + getUndoStack(mapObject?.id), + getUndoStackIndex(mapObject?.id), + ), ); const [previousEffects, setPreviousEffects] = useState(editor.effects); @@ -319,9 +376,18 @@ export default function MapEditor({ const [previousState, setPreviousState] = useState(() => { try { + const effects = Storage.getItem(EFFECTS_KEY) || ''; + const stateFromStorage = getInitialMapFromUndoStack(mapObject?.id); + + if (!stateFromStorage) { + return null; + } + return { - effects: Storage.getItem(EFFECTS_KEY) || '', - map: MapData.fromJSON(Storage.getItem(MAP_KEY) || ''), + effects, + map: stateFromStorage.map, + undoStack: stateFromStorage.undoStack, + undoStackIndex: stateFromStorage.undoStackIndex, }; // eslint-disable-next-line no-empty } catch {} @@ -329,17 +395,21 @@ export default function MapEditor({ }); const setMap: SetMapFunction = useCallback( - (type, map) => { + (type, map, undoStack) => { _setMap(map); if (type !== 'cleanup') { - updateUndoStack({ setEditorState }, editor, [ - type === 'resize' - ? `resize-${map.size.height}-${map.size.width}` - : type === 'biome' - ? `biome-${map.config.biome}` - : 'checkpoint', - map, - ]); + updateUndoStack( + { setEditorState }, + undoStack ? { ...editor, undoStack } : editor, + [ + type === 'resize' + ? `resize-${map.size.height}-${map.size.width}` + : type === 'biome' + ? `biome-${map.config.biome}` + : 'checkpoint', + map, + ], + ); } setInternalMapID(internalMapID + 1); }, @@ -347,12 +417,14 @@ export default function MapEditor({ ); const updatePreviousMap = useCallback( - (map?: MapData, editorEffects?: Effects) => { + (map?: MapData, editorEffects?: Effects, editorUndoStack?: UndoStack) => { const effects = JSON.stringify( editorEffects ? encodeEffects(editorEffects) : '', ); - Storage.setItem(MAP_KEY, JSON.stringify(map)); - setPreviousState(map ? { effects, map } : null); + + setPreviousState( + map ? { effects, map, undoStack: editorUndoStack } : null, + ); }, [], ); @@ -457,6 +529,12 @@ export default function MapEditor({ const isNew = type === 'New' || !mapObject?.id; setSaveState(null); + + const stack = editor.undoStack.map(([key, value]) => [ + key, + value.toJSON(), + ]); + if (isNew) { createMap( { @@ -469,6 +547,18 @@ export default function MapEditor({ }, setSaveState, ); + Storage.removeItem(getUndoStackKeyFor('')); + Storage.removeItem(getUndoStackIndexKeyFor('')); + Storage.setItem( + getUndoStackKeyFor(toSlug(mapName)), + JSON.stringify(stack), + ); + if (editor.undoStackIndex !== null) { + Storage.setItem( + getUndoStackIndexKeyFor(toSlug(mapName)), + `${editor?.undoStackIndex}`, + ); + } } else { updateMap( { @@ -481,14 +571,26 @@ export default function MapEditor({ type, setSaveState, ); + Storage.setItem( + getUndoStackKeyFor(mapObject?.id), + JSON.stringify(stack), + ); + if (editor.undoStackIndex !== null) { + Storage.setItem( + getUndoStackIndexKeyFor(mapObject?.id), + `${editor?.undoStackIndex}`, + ); + } } }, [ createMap, editor.effects, + editor.undoStackIndex, mapName, mapObject?.id, setMap, + editor.undoStack, tags, updateMap, withHumanPlayer, @@ -574,6 +676,12 @@ export default function MapEditor({ undoStack.length, ), ); + if (Storage.getItem(getUndoStackKeyFor(mapObject?.id)) !== null) { + Storage.setItem( + getUndoStackIndexKeyFor(mapObject?.id), + `${index}`, + ); + } if (index > -1 && index < undoStack.length) { const [, newMap] = undoStack.at(index) || []; if (newMap) { @@ -659,7 +767,15 @@ export default function MapEditor({ document.removeEventListener('keydown', keydownListener); document.removeEventListener('keyup', keyupListener); }; - }, [editor, saveMap, setEditorState, setMap, tilted, toggleDeleteEntity]); + }, [ + editor, + saveMap, + setEditorState, + setMap, + tilted, + toggleDeleteEntity, + mapObject?.id, + ]); useInput( 'select', @@ -684,6 +800,8 @@ export default function MapEditor({ setMap('reset', newMap); _setEditorState(getEditorBaseState(newMap, mapObject, mode, scenario)); updatePreviousMap(); + Storage.removeItem(getUndoStackKeyFor('')); + Storage.removeItem(getUndoStackIndexKeyFor('')); }, [getInitialMap, mapObject, mode, scenario, setMap, updatePreviousMap]); const fillMap = useCallback(() => { @@ -712,7 +830,7 @@ export default function MapEditor({ const restorePreviousState = useCallback(() => { if (previousState) { - setMap('reset', previousState.map); + setMap('reset', previousState.map, previousState.undoStack); setPreviousState(null); _setEditorState( getEditorBaseState( @@ -722,6 +840,8 @@ export default function MapEditor({ }, editor.mode, editor.scenario, + previousState.undoStack, + previousState.undoStackIndex, ), ); } @@ -754,6 +874,16 @@ export default function MapEditor({ } }, [saveState]); + useEffect(() => { + if (editor.undoStack) { + const stack = editor.undoStack.map(([key, value]) => [ + key, + value.toJSON(), + ]); + Storage.setItem(getUndoStackKeyFor(mapObject?.id), JSON.stringify(stack)); + } + }, [editor.undoStack, mapObject?.id]); + // Disabling the context menu is handled globally in production, // so this is only needed for the Map Editor in development. if (process.env.NODE_ENV === 'development') { diff --git a/hera/editor/Types.tsx b/hera/editor/Types.tsx index 161b3f55..17503e60 100644 --- a/hera/editor/Types.tsx +++ b/hera/editor/Types.tsx @@ -65,6 +65,7 @@ export type SetEditorStateFunction = (editor: Partial) => void; export type SetMapFunction = ( type: 'biome' | 'cleanup' | 'heal' | 'reset' | 'resize' | 'teams', map: MapData, + undoStack?: UndoStack, ) => void; type MapSaveType = 'New' | 'Update' | 'Disk' | 'Export'; @@ -110,6 +111,8 @@ export type MapObject = Readonly<{ export type PreviousMapEditorState = Readonly<{ effects: string; map: MapData; + undoStack?: UndoStack; + undoStackIndex?: number; }>; export type MapEditorSaveMessageId = diff --git a/hera/editor/lib/updateUndoStack.tsx b/hera/editor/lib/updateUndoStack.tsx index a0905aab..5e22eaa5 100644 --- a/hera/editor/lib/updateUndoStack.tsx +++ b/hera/editor/lib/updateUndoStack.tsx @@ -1,5 +1,11 @@ import { EditorState, SetEditorStateFunction, UndoEntry } from '../Types.tsx'; +export const getUndoStackKeyFor = (id: string | undefined) => + `map-editor-undo-stack-${id ? `${id}` : 'fallback'}`; + +export const getUndoStackIndexKeyFor = (id: string | undefined) => + `map-editor-undo-stack-index-${id ? `${id}` : 'fallback'}`; + export default function updateUndoStack( { setEditorState }: { setEditorState: SetEditorStateFunction }, { undoStack, undoStackIndex }: EditorState, diff --git a/ui/Storage.tsx b/ui/Storage.tsx index b72d6f69..8bfdb611 100644 --- a/ui/Storage.tsx +++ b/ui/Storage.tsx @@ -14,6 +14,10 @@ export default { return localStorage.getItem(`${namespace}${key}`); }, + removeItem(key: string) { + return localStorage.removeItem(`${namespace}${key}`); + }, + setItem(key: string, value: string) { if (value === null) { localStorage.removeItem(`${namespace}${key}`);