Skip to content

Commit 403e24d

Browse files
committed
feat: add beforeUnload and confirmNavigation if there are unsaved changes
1 parent a72333b commit 403e24d

8 files changed

Lines changed: 149 additions & 11 deletions

File tree

src/components/ProjectBrowser/ProjectEntry.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Tag } from "../../data/demos";
55
import { loadProject } from "../../features/Projects/application/loadProject";
66
import { useProject } from "../../features/Projects/stores/useProject";
77
import { cn } from "../../util/cn";
8+
import { confirmNavigate } from "../../util/confirmNavigate";
89

910
export type ProjectEntryProject = {
1011
key: string;
@@ -100,13 +101,19 @@ export const ProjectEntry: FC<ProjectEntryProps> = (
100101

101102
if (!dialog?.open) return;
102103

103-
if (isProject) {
104-
loadProject(project.key);
105-
} else {
106-
createNewProject("ex", {}, project.key);
104+
if (isCurrent) {
105+
return dialog?.close();
107106
}
108107

109-
dialog?.close();
108+
confirmNavigate(() => {
109+
if (isProject) {
110+
loadProject(project.key);
111+
} else {
112+
createNewProject("ex", {}, project.key);
113+
}
114+
115+
dialog?.close();
116+
});
110117
};
111118

112119
return (

src/components/Toolbar/ExampleList.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ChangeEvent, FC } from "react";
22
import { demos } from "../../data/demos";
33
import { loadProject } from "../../features/Projects/application/loadProject";
44
import { useProject } from "../../features/Projects/stores/useProject";
5+
import { confirmNavigate } from "../../util/confirmNavigate";
56
import { sortEntries } from "../ProjectBrowser/SortBy";
67

78
const ExampleList: FC = () => {
@@ -16,11 +17,13 @@ const ExampleList: FC = () => {
1617
"data-demo-id",
1718
);
1819

19-
if (demoId) {
20-
createNewProject("ex", {}, demoId);
21-
} else {
22-
loadProject(ev.target.value);
23-
}
20+
confirmNavigate(() => {
21+
if (demoId) {
22+
createNewProject("ex", {}, demoId);
23+
} else {
24+
loadProject(ev.target.value);
25+
}
26+
});
2427
};
2528

2629
const getSortedProjects = (mode: "pj" | "ex") => (

src/components/Toolbar/ProjectStatus.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export const ProjectStatus = () => {
156156
)}
157157

158158
<button
159+
id="project-save-button"
159160
className={cn(
160161
"btn btn-xs btn-ghost px-px rounded-sm items-center justify-center h-full",
161162
{

src/features/Editor/components/MonacoEditor.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Editor, type Monaco } from "@monaco-editor/react";
22
import type { editor } from "monaco-editor";
33
import { type FC, useEffect } from "react";
4+
import { useBeforeUnload } from "../../../hooks/useBeforeUnload";
45
import { useConfig } from "../../../hooks/useConfig.ts";
56
import { useEditor } from "../../../hooks/useEditor.ts";
67
import { useProject } from "../../Projects/stores/useProject.ts";
@@ -16,14 +17,24 @@ interface MonacoEditorProps {
1617
export const MonacoEditor: FC<MonacoEditorProps> = (props) => {
1718
const updateFile = useProject((s) => s.updateFile);
1819
const getFile = useProject((s) => s.getFile);
20+
const projectIsSaved = useProject((s) => s.projectIsSaved);
1921
const run = useEditor((s) => s.run);
2022
const update = useEditor((s) => s.update);
2123
const updateImageDecorations = useEditor((s) => s.updateImageDecorations);
2224
const setRuntime = useEditor((s) => s.setRuntime);
2325
const getRuntime = useEditor((s) => s.getRuntime);
26+
const updateEditorLastSavedValue = useEditor((s) =>
27+
s.updateEditorLastSavedValue
28+
);
29+
const updateHasUnsavedChanges = useEditor((s) => s.updateHasUnsavedChanges);
30+
const hasUnsavedChanges = useEditor((s) =>
31+
s.getRuntime().hasUnsavedChanges
32+
);
2433
const getConfig = useConfig((s) => s.getConfig);
2534
const setConfigKey = useConfig((s) => s.setConfigKey);
2635

36+
useBeforeUnload(hasUnsavedChanges);
37+
2738
const handleEditorBeforeMount = (monaco: Monaco) => {
2839
configMonaco(monaco);
2940
};
@@ -62,6 +73,13 @@ export const MonacoEditor: FC<MonacoEditorProps> = (props) => {
6273
updateImageDecorations();
6374
});
6475

76+
editor.onDidChangeModelContent(() => {
77+
const projectKey = useProject.getState().projectKey;
78+
const isSaved = projectKey && projectIsSaved(projectKey);
79+
if (isSaved) updateEditorLastSavedValue();
80+
updateHasUnsavedChanges();
81+
});
82+
6583
editor.onDidScrollChange(() => {
6684
updateImageDecorations();
6785
});

src/features/Projects/stores/slices/project.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,9 @@ export const createProjectSlice: StateCreator<
403403
useConfig.getState().setConfig({
404404
lastOpenedProject: id,
405405
});
406+
407+
useEditor.getState().updateEditorLastSavedValue();
408+
useEditor.getState().updateHasUnsavedChanges();
406409
},
407410

408411
generateId(prefix) {

src/hooks/useBeforeUnload.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useEffect } from "react";
2+
3+
export const useBeforeUnload = async (
4+
hasUnsavedChanges: boolean,
5+
focusQuerySelector: string = "#project-save-button",
6+
) => {
7+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
8+
if (!hasUnsavedChanges) return;
9+
e.preventDefault();
10+
11+
if (focusQuerySelector) {
12+
window.addEventListener("focus", () => {
13+
setTimeout(() =>
14+
document.querySelector<HTMLElement>(focusQuerySelector)
15+
?.focus()
16+
);
17+
}, { once: true });
18+
}
19+
};
20+
21+
useEffect(() => {
22+
if (hasUnsavedChanges) {
23+
window.addEventListener("beforeunload", handleBeforeUnload);
24+
}
25+
26+
return () => {
27+
window.removeEventListener("beforeunload", handleBeforeUnload);
28+
};
29+
}, [hasUnsavedChanges]);
30+
};

src/hooks/useEditor.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Monaco } from "@monaco-editor/react";
22
import confetti from "canvas-confetti";
3-
import type { editor } from "monaco-editor";
3+
import { editor } from "monaco-editor";
44
import { toast } from "react-toastify";
55
import { create } from "zustand";
66
import { kaplayVersions } from "../data/kaplayVersions.json";
@@ -27,6 +27,14 @@ interface EditorRuntime {
2727
* The current selection in the editor
2828
*/
2929
currentFile: string;
30+
/**
31+
* The last saved editor value
32+
*/
33+
editorLastSavedValue: string | null;
34+
/**
35+
* If file was modified and not saved
36+
*/
37+
hasUnsavedChanges: boolean;
3038
/**
3139
* Decorations for the gylph images
3240
*/
@@ -68,6 +76,8 @@ export interface EditorStore {
6876
updateModelMarkers: () => void;
6977
showNotification: (message: string) => void;
7078
setEditorValue: (value: string) => void;
79+
updateEditorLastSavedValue: (value?: string) => void;
80+
updateHasUnsavedChanges: () => void;
7181
updateAndRun: () => void;
7282
}
7383

@@ -76,6 +86,8 @@ export const useEditor = create<EditorStore>((set, get) => ({
7686
editor: null,
7787
monaco: null,
7888
currentFile: "main.js",
89+
editorLastSavedValue: null,
90+
hasUnsavedChanges: false,
7991
gylphDecorations: null,
8092
iframe: null,
8193
console: null,
@@ -143,6 +155,8 @@ export const useEditor = create<EditorStore>((set, get) => ({
143155
viewStates: viewStates,
144156
},
145157
}));
158+
159+
get().updateEditorLastSavedValue();
146160
},
147161
update: (customValue?: string) => {
148162
if (customValue) {
@@ -163,6 +177,8 @@ export const useEditor = create<EditorStore>((set, get) => ({
163177
currentFile.path.slice(0, 25) + "...",
164178
);
165179

180+
get().updateEditorLastSavedValue(currentFile.value);
181+
166182
get().setEditorValue(currentFile.value);
167183
get().updateImageDecorations();
168184
},
@@ -315,6 +331,27 @@ export const useEditor = create<EditorStore>((set, get) => ({
315331

316332
editor.setValue(value);
317333
},
334+
updateEditorLastSavedValue(value) {
335+
set((state) => ({
336+
runtime: {
337+
...state.runtime,
338+
editorLastSavedValue:
339+
(value ?? get().runtime.editor?.getValue()) ?? null,
340+
},
341+
}));
342+
},
343+
updateHasUnsavedChanges() {
344+
const editor = get().runtime.editor;
345+
if (!editor) return;
346+
347+
set((state) => ({
348+
runtime: {
349+
...state.runtime,
350+
hasUnsavedChanges: get().getRuntime().editorLastSavedValue
351+
!= editor.getValue(),
352+
},
353+
}));
354+
},
318355
updateAndRun() {
319356
get().getRuntime().editor?.setScrollTop(0);
320357
get().update();

src/util/confirmNavigate.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useProject } from "../features/Projects/stores/useProject";
2+
import { useEditor } from "../hooks/useEditor";
3+
import { confirm, type ConfirmContent, type ConfirmOptions } from "./confirm";
4+
5+
export type unsavedChangesConfirm = {
6+
title?: string;
7+
content?: ConfirmContent;
8+
options?: ConfirmOptions;
9+
};
10+
11+
export const confirmNavigate = async (
12+
to: () => void,
13+
{ title, content, options }: unsavedChangesConfirm = {},
14+
) => {
15+
if (!useEditor.getState().getRuntime().hasUnsavedChanges) return to();
16+
17+
if (
18+
await confirm(
19+
title || "You have unsaved changes!",
20+
content ?? (
21+
<p>
22+
You will loose your changes if you continue without
23+
saving.<br />
24+
<span className="text-sm">
25+
Your project will{" "}
26+
<strong className="text-white">auto-save</strong>{" "}
27+
after the first inital save.
28+
</span>
29+
</p>
30+
),
31+
options ?? {
32+
confirmText: "Save as Project and continue",
33+
dismissText: "Discard and continue",
34+
},
35+
)
36+
) useProject.getState().saveNewProject();
37+
38+
to?.();
39+
};

0 commit comments

Comments
 (0)