Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add undo-stack on a per map basis #22

Closed
wants to merge 6 commits into from

Conversation

tjamesmac
Copy link

@tjamesmac tjamesmac commented May 17, 2024

Description

Hoping to resolve #3
Adds the ability to:

  • save the undo stack on reload for unsaved maps
  • store undo stacks for saved maps

Todo

  • undo stack on reload one off (need to push)
  • move effects into undostack

Would appreciate any feedback and whether this has hit the mark! :)

@cpojer
Copy link
Contributor

cpojer commented May 17, 2024

Here are the replies to your questions from the issue:

The MapObject.id is returning undefined and never returns an actual id. Is this expected? If it is, do you have any suggestions for generating a decent fallback?

If you rebase, now it will be an empty string when you "save" it.

Instead of restoring the previous map with the Restore button, i'm restoring if a previousState is present. When clicking New map I then delete the related localstorage items so a refresh doesn't continually bring back the last map. Does this sound like the right path for the U? Happy to change if I misinterpreted the requirement.

I think that makes the most sense, yes. If you don't hit restore before clicking "new map", we should wipe the undo state.

Also not sure if i'm missing something obvious but the confirmation dialogs for the new/reset map don't seem to work. I've just started calling new map directly

Yes that's fine. The docs page doesn't have those confirmation dialogs but they are in the game.

Lastly, are there tests for the map editor?

There are no tests 🪦 If you write a function (not React) that you want to test, feel free to add a test. Otherwise we are living in a brave world here.

Comment on lines 4 to 9
export const UNDO_STACK_KEY = (id: string | undefined) =>
`map-editor-undo-stack-${id ? `${id}` : 'fallback'}`;

export const UNDO_STACK_INDEX_KEY = (id: string | undefined) =>
`map-editor-undo-stack-index-${id ? `${id}` : 'fallback'}`;

Copy link
Contributor

@cpojer cpojer May 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these are functions they should be getUndoStackKey and getUndoStackIndexKey.

) => void;

type MapSaveType = 'New' | 'Update' | 'Disk' | 'Export';
export type MapCreateVariables = Readonly<{
effects: Effects;
id: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove this.

When you rebase, you'll be able to find this: https://github.com/nkzw-tech/athena-crisis/blob/main/docs/content/examples/map-editor.tsx#L93

Let's change that line to set the id to the same as slug, that means you'll always have a unique id to work with.

Comment on lines 115 to 129
export function decodeUndoStack(
encodedUndoStack: string | null,
): UndoStack | undefined {
if (!encodedUndoStack) {
return undefined;
}

try {
return JSON.parse(encodedUndoStack).map(
([key, value]: [string, PlainMap]) => [key, MapData.fromObject(value)],
);
} catch {
return undefined;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why return undefined instead of null?

}

const undoStackIndex = getUndoStackIndex(mapObject?.id);
const map = undoStack[undoStackIndex ?? -1][1];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if this ends up being -1? It will crash, right?

@cpojer
Copy link
Contributor

cpojer commented May 19, 2024

Hey @tjamesmac, thank you so much for your contributions! I left a few comments, would you mind taking a look?

if the map isn't saved, the undo stack is extended across reloads. Meaning, you can go back to the last refreshed map with ctrl-z.

I think this is ok actually, that's the whole point of replacing the "restore" feature with this one, as long as the id matches.

tjamc added 3 commits May 19, 2024 19:22
- change stack keys
- remove id from MapCreateVariables type
- use null instead of undefined
- use array.length - 1 to calc last index
@tjamesmac
Copy link
Author

Hey @cpojer, thanks for the quick review and sorry for the slowish response. Busy weekend!

I've made your suggestions and added a few more fixes for some edge cases. Would appreciate another review!

Also thanks for open sourcing Athena crisis :)

@@ -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 ??
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this restores the state from the correct position in the undostack when loading the map from the url

Comment on lines 872 to 881
useEffect(() => {
if (editor.undoStack) {
const stack = editor.undoStack.map(([key, value]) => [
key,
value.toJSON(),
]);
Storage.setItem(getUndoStackKey(mapObject?.id), JSON.stringify(stack));
}
}, [editor.undoStack, mapObject?.id]);

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved the stack saving to localstorage from updateUndoStack to here because it was out of sync by one

@@ -80,6 +80,8 @@ export default function MapEditorExample() {

const handleMapUpdate = useCallback(
(variables: MapCreateVariables | MapUpdateVariables) => {
const nameToSlug = toSlug(variables.mapName);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const nameToSlug = toSlug(variables.mapName);
const slug = toSlug(variables.mapName);

@@ -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 : nameToSlug,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: 'id' in variables ? variables.id : nameToSlug,
id: 'id' in variables ? variables.id : slug,

name: variables.mapName,
slug: toSlug(variables.mapName),
slug: nameToSlug,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
slug: nameToSlug,
slug,

Comment on lines 3 to 8
export const getUndoStackKey = (id: string | undefined) =>
`map-editor-undo-stack-${id ? `${id}` : 'fallback'}`;

export const getUndoStackIndexKey = (id: string | undefined) =>
`map-editor-undo-stack-index-${id ? `${id}` : 'fallback'}`;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a suggestion, curious what you think: From reading the code it's not obvious why the id is passed. What do you think about calling these getUndoStackKeyFor(id) and getUndoStackIndexKeyFor(id)?

Sorry, didn't come to me earlier since they were uppercased and that confused me 😅

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hah, not a problem at all. Definitely think adding the For makes the intent a little clearer

@@ -319,40 +376,56 @@ export default function MapEditor({
const [previousState, setPreviousState] =
useState<PreviousMapEditorState | null>(() => {
try {
const effects = Storage.getItem(EFFECTS_KEY) || '';

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove this one empty line. There are too many empty lines here :D

@cpojer
Copy link
Contributor

cpojer commented May 20, 2024

This is looking really solid! I think there are only two more considerations before it's ready:

  • When I edit a map, then reload and hit cmd+z, it restores the map with one state less than expected. Ideally it restores it at the same state as before.
  • Now that we are restoring maps with this approach, I think we need to also move effects into the undo stack. Effects are character messages etc. that you can define in the effects panel. Ideally they get versioned with the current state of the map as well. This should allow cleaning up a bit of the code that handles storage as we won't have to make use of a separate storage item for effects.

Just note, we should probably not immediately update undo every time an effect changes since a user can type into the textarea to update the text a character says and it would probably be slow and not fun to add each character to the undo stack.

@cpojer cpojer force-pushed the main branch 3 times, most recently from e7d0373 to e7114fa Compare May 21, 2024 22:39
@tjamesmac
Copy link
Author

Hey thanks, got a bit busier than I expected. Will look at shifting the effects into the stack when I get chance

- nameToSlug -> slug
- getUndoStackKey -> getUndoStackKeyFor
- getUndoStackKeyIndex -> getUndoStackKeyIndexFor
- remove empty line
@tjamesmac
Copy link
Author

Hey @cpojer , I have a couple of questions!

When I edit a map, then reload and hit cmd+z, it restores the map with one state less than expected. Ideally it restores it at the same state as before.

How do you feel about the previous state (if present) just being restored straight off a reload instead of hitting restore to get back to the previous state? If that's okay I think I can check when getting the initial map state whether I should restore from:

  • undostack
  • mapobject
  • generate a new map

Ideally they get versioned with the current state of the map as well.

Does this mean move the effects into MapData and store that as an entry in the undostack? Instead of adding an Effect as a separate value in a undoEntry?

@cpojer
Copy link
Contributor

cpojer commented May 23, 2024

How do you feel about the previous state (if present) just being restored straight off a reload instead of hitting restore to get back to the previous state?

I prefer not to do this. In the game, opening the map editor should start you off with a fresh map. I mostly just want undo to work properly in that empty state so that we can make the "Restore Map" button more prominent in the editor in a follow-up. That way we give control to the user.

Does this mean move the effects into MapData and store that as an entry in the undostack? Instead of adding an Effect as a separate value in a undoEntry?

I think we have to change undoEntry to take [MapData, Effects] instead of just MapData. Effects cannot live on MapData directly.

@cpojer
Copy link
Contributor

cpojer commented Aug 27, 2024

Closing due to inactivity. Happy to reopen if it's being picked up again.

@cpojer cpojer closed this Aug 27, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Feature] Map Editor Undo should be stored in localStorage
3 participants