diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..ec4bb386
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: false
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b6506d4f..0bf63697 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,7 +14,7 @@ Before you jump the gun, we ask that you first and foremost check the following
If you issue has already been listed, you're welcome to add a comment if you have any additional context to contribute!
-When creating a new issue, please use the corresponding templates to better organize the context surrounding your issue and make it as easy as possible for maintainers to address your issue in a timely manner.
+When creating a new issue, **please use the corresponding templates** to better organize the context surrounding your issue and make it as easy as possible for maintainers to address your issue in a timely manner.
## For Developers
diff --git a/package.json b/package.json
index e0116618..d9c7374d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "beatmapper",
- "version": "0.4.2",
+ "version": "0.4.3",
"type": "module",
"private": true,
"packageManager": "yarn@4.9.2",
diff --git a/src/components/app/forms/create-map.tsx b/src/components/app/forms/create-map.tsx
index e09f434c..bbc00e21 100644
--- a/src/components/app/forms/create-map.tsx
+++ b/src/components/app/forms/create-map.tsx
@@ -6,7 +6,7 @@ import { gtValue, minLength, number, object, pipe, string, transform } from "val
import { APP_TOASTER, CHARACTERISTIC_COLLECTION, COVER_ART_FILE_ACCEPT_TYPE, DIFFICULTY_COLLECTION, SONG_FILE_ACCEPT_TYPE } from "$/components/app/constants";
import { Field, FileUpload, useAppForm } from "$/components/ui/compositions";
-import { resolveBeatmapId, resolveSongId } from "$/helpers/song.helpers";
+import { createSongId, resolveBeatmapId } from "$/helpers/song.helpers";
import { addSong } from "$/store/actions";
import { useAppDispatch, useAppSelector } from "$/store/hooks";
import { selectSongIds, selectUsername } from "$/store/selectors";
@@ -62,7 +62,7 @@ function CreateMapForm({ dialog }: Props) {
});
}
- const songId = resolveSongId({ name: value.name });
+ const songId = createSongId(value);
const beatmapId = resolveBeatmapId({ characteristic: value.characteristic, difficulty: value.difficulty });
// Song IDs must be unique, and song IDs are generated from the name.
diff --git a/src/components/app/forms/settings/index.tsx b/src/components/app/forms/settings/index.tsx
index 8f9a08c6..bad248a6 100644
--- a/src/components/app/forms/settings/index.tsx
+++ b/src/components/app/forms/settings/index.tsx
@@ -23,7 +23,7 @@ const collection = createListCollection({
function AppSettings() {
return (
-
+
);
}
diff --git a/src/components/app/layouts/error-boundary.tsx b/src/components/app/layouts/error-boundary.tsx
index 77cc7a1d..396143ed 100644
--- a/src/components/app/layouts/error-boundary.tsx
+++ b/src/components/app/layouts/error-boundary.tsx
@@ -42,7 +42,7 @@ function ErrorBoundary({ error, interactive = true, reset }: Props) {
- If you're still encountering issues, please fill out a bug report on the repository.
+ If you're still encountering issues, please fill out a bug report on the repository.
diff --git a/src/components/app/layouts/status-bar/range.tsx b/src/components/app/layouts/status-bar/range.tsx
index 91b2caf6..2dc5117c 100644
--- a/src/components/app/layouts/status-bar/range.tsx
+++ b/src/components/app/layouts/status-bar/range.tsx
@@ -15,7 +15,7 @@ function StatusBarRangeControl({ label, minIcon, maxIcon, min, max, step, value,
label}>
onValueChange?.({ value: [min ?? 0] })} disabled={disabled} />
-
+
onValueChange?.({ value: [max ?? 100] })} disabled={disabled} />
diff --git a/src/components/app/layouts/status-bar/toggle.tsx b/src/components/app/layouts/status-bar/toggle.tsx
index 4f989e30..055478c1 100644
--- a/src/components/app/layouts/status-bar/toggle.tsx
+++ b/src/components/app/layouts/status-bar/toggle.tsx
@@ -15,7 +15,7 @@ function StatusBarToggleControl({ label, onIcon, offIcon, checked, onCheckedChan
label}>
onCheckedChange?.({ checked: false })} disabled={disabled} />
-
+
onCheckedChange?.({ checked: true })} disabled={disabled} />
diff --git a/src/components/app/templates/action-panel-groups/clipboard.tsx b/src/components/app/templates/action-panel-groups/clipboard.tsx
index 7b091ed0..5ef68b10 100644
--- a/src/components/app/templates/action-panel-groups/clipboard.tsx
+++ b/src/components/app/templates/action-panel-groups/clipboard.tsx
@@ -22,15 +22,15 @@ function ClipboardActionPanelActionGroup({ sid }: Props) {
-
- dispatch(pasteSelection({ songId: sid, view: view ?? View.BEATMAP }))}>
+ dispatch(pasteSelection({ songId: sid, view: view ?? View.BEATMAP }))}>
Paste Selection
diff --git a/src/components/app/templates/action-panel-groups/default.tsx b/src/components/app/templates/action-panel-groups/default.tsx
index 018cc09b..a006b34e 100644
--- a/src/components/app/templates/action-panel-groups/default.tsx
+++ b/src/components/app/templates/action-panel-groups/default.tsx
@@ -24,18 +24,18 @@ function DefaultActionPanelGroup({ sid, handleGridConfigClick }: Props) {
"Select everything over a time period"}>
- openPrompt("QUICK_SELECT")}>
+ openPrompt("QUICK_SELECT")}>
Quick-select
"Jump to a specific beat number"}>
- openPrompt("JUMP_TO_BEAT")}>
+ openPrompt("JUMP_TO_BEAT")}>
Jump to Beat
{mappingExtensionsEnabled && (
"Change the number of columns/rows"}>
-
+
Customize Grid
diff --git a/src/components/app/templates/action-panel-groups/direction.tsx b/src/components/app/templates/action-panel-groups/direction.tsx
index 83df4a01..950767a8 100644
--- a/src/components/app/templates/action-panel-groups/direction.tsx
+++ b/src/components/app/templates/action-panel-groups/direction.tsx
@@ -20,31 +20,31 @@ function NoteDirectionActionPanelGroup() {
return (
- dispatch(updateNotesEditorDirection({ direction: NoteDirection.UP_LEFT }))}>
+ dispatch(updateNotesEditorDirection({ direction: NoteDirection.UP_LEFT }))}>
- dispatch(updateNotesEditorDirection({ direction: NoteDirection.UP }))}>
+ dispatch(updateNotesEditorDirection({ direction: NoteDirection.UP }))}>
- dispatch(updateNotesEditorDirection({ direction: NoteDirection.UP_RIGHT }))}>
+ dispatch(updateNotesEditorDirection({ direction: NoteDirection.UP_RIGHT }))}>
- dispatch(updateNotesEditorDirection({ direction: NoteDirection.LEFT }))}>
+ dispatch(updateNotesEditorDirection({ direction: NoteDirection.LEFT }))}>
- dispatch(updateNotesEditorDirection({ direction: NoteDirection.ANY }))}>
+ dispatch(updateNotesEditorDirection({ direction: NoteDirection.ANY }))}>
- dispatch(updateNotesEditorDirection({ direction: NoteDirection.RIGHT }))}>
+ dispatch(updateNotesEditorDirection({ direction: NoteDirection.RIGHT }))}>
- dispatch(updateNotesEditorDirection({ direction: NoteDirection.DOWN_LEFT }))}>
+ dispatch(updateNotesEditorDirection({ direction: NoteDirection.DOWN_LEFT }))}>
- dispatch(updateNotesEditorDirection({ direction: NoteDirection.DOWN }))}>
+ dispatch(updateNotesEditorDirection({ direction: NoteDirection.DOWN }))}>
- dispatch(updateNotesEditorDirection({ direction: NoteDirection.DOWN_RIGHT }))}>
+ dispatch(updateNotesEditorDirection({ direction: NoteDirection.DOWN_RIGHT }))}>
diff --git a/src/components/app/templates/action-panel-groups/history.tsx b/src/components/app/templates/action-panel-groups/history.tsx
index 6bb42d38..58beba9e 100644
--- a/src/components/app/templates/action-panel-groups/history.tsx
+++ b/src/components/app/templates/action-panel-groups/history.tsx
@@ -15,10 +15,10 @@ function HistoryActionPanelActionGroup({ sid }: Props) {
return (
- dispatch(undoObjects({ songId: sid }))}>
+ dispatch(undoObjects({ songId: sid }))}>
Undo
- dispatch(redoObjects({ songId: sid }))}>
+ dispatch(redoObjects({ songId: sid }))}>
Redo
diff --git a/src/components/app/templates/action-panel-groups/obstacles.tsx b/src/components/app/templates/action-panel-groups/obstacles.tsx
index 196387df..a29a1749 100644
--- a/src/components/app/templates/action-panel-groups/obstacles.tsx
+++ b/src/components/app/templates/action-panel-groups/obstacles.tsx
@@ -12,7 +12,7 @@ function ObstaclesActionPanelGroup({ sid }: Props) {
return (
- openPrompt("UPDATE_OBSTACLE_DURATION")}>
+ openPrompt("UPDATE_OBSTACLE_DURATION")}>
Change duration
diff --git a/src/components/app/templates/action-panel-groups/tool.tsx b/src/components/app/templates/action-panel-groups/tool.tsx
index 091b0c8b..db39901e 100644
--- a/src/components/app/templates/action-panel-groups/tool.tsx
+++ b/src/components/app/templates/action-panel-groups/tool.tsx
@@ -20,22 +20,22 @@ function NoteToolActionPanelGroup({ sid, bid }: Props) {
"Left Color Note"}>
- dispatch(updateNotesEditorTool({ tool: ObjectTool.LEFT_NOTE }))}>
+ dispatch(updateNotesEditorTool({ tool: ObjectTool.LEFT_NOTE }))}>
"Right Color Note"}>
- dispatch(updateNotesEditorTool({ tool: ObjectTool.RIGHT_NOTE }))}>
+ dispatch(updateNotesEditorTool({ tool: ObjectTool.RIGHT_NOTE }))}>
"Bomb Note"}>
- dispatch(updateNotesEditorTool({ tool: ObjectTool.BOMB_NOTE }))}>
+ dispatch(updateNotesEditorTool({ tool: ObjectTool.BOMB_NOTE }))}>
"Obstacle"}>
- dispatch(updateNotesEditorTool({ tool: ObjectTool.OBSTACLE }))}>
+ dispatch(updateNotesEditorTool({ tool: ObjectTool.OBSTACLE }))}>
diff --git a/src/components/app/templates/editor/action-panel/grid.tsx b/src/components/app/templates/editor/action-panel/grid.tsx
index 87e66962..76489e2d 100644
--- a/src/components/app/templates/editor/action-panel/grid.tsx
+++ b/src/components/app/templates/editor/action-panel/grid.tsx
@@ -30,16 +30,16 @@ function GridActionPanel({ sid, finishTweakingGrid }: Props) {
{!isObjectEmpty(gridPresets) && (
-
"Load Grid Preset"}>
- dispatch(loadGridPreset({ songId: sid, grid: gridPresets[slot] }))}>
+ dispatch(loadGridPreset({ songId: sid, grid: gridPresets[slot] }))}>
"Delete Grid Preset"}>
- dispatch(removeGridPreset({ songId: sid, presetSlot: slot }))}>
+ dispatch(removeGridPreset({ songId: sid, presetSlot: slot }))}>
@@ -63,13 +63,13 @@ function GridActionPanel({ sid, finishTweakingGrid }: Props) {
- openPrompt("SAVE_GRID_PRESET")}>
+ openPrompt("SAVE_GRID_PRESET")}>
Save as Preset
- sid && dispatch(updateGridSize({ songId: sid, changes: DEFAULT_GRID }))}>
+ sid && dispatch(updateGridSize({ songId: sid, changes: DEFAULT_GRID }))}>
Reset Grid
-
+
Finish Customizing
diff --git a/src/components/app/templates/editor/action-panel/selection.tsx b/src/components/app/templates/editor/action-panel/selection.tsx
index 13477468..cd11cf4b 100644
--- a/src/components/app/templates/editor/action-panel/selection.tsx
+++ b/src/components/app/templates/editor/action-panel/selection.tsx
@@ -64,30 +64,30 @@ function SelectionActionPanel({ sid, numOfSelectedBlocks, numOfSelectedMines, nu
"Mirror selection horizontally"}>
- dispatch(mirrorSelection({ axis: "horizontal", grid }))}>
+ dispatch(mirrorSelection({ axis: "horizontal", grid }))}>
"Mirror selection vertically"}>
- dispatch(mirrorSelection({ axis: "vertical", grid }))}>
+ dispatch(mirrorSelection({ axis: "vertical", grid }))}>
"Nudge selection forwards"}>
- dispatch(nudgeSelection({ direction: "forwards", view: View.BEATMAP }))}>
+ dispatch(nudgeSelection({ direction: "forwards", view: View.BEATMAP }))}>
"Nudge selection backwards"}>
- dispatch(nudgeSelection({ direction: "backwards", view: View.BEATMAP }))}>
+ dispatch(nudgeSelection({ direction: "backwards", view: View.BEATMAP }))}>
- dispatch(deselectAllEntities({ view: View.BEATMAP }))}>
+ dispatch(deselectAllEntities({ view: View.BEATMAP }))}>
Clear selection
diff --git a/src/components/app/templates/editor/navigation-panel/playback.tsx b/src/components/app/templates/editor/navigation-panel/playback.tsx
index bc2c8d1b..2c898694 100644
--- a/src/components/app/templates/editor/navigation-panel/playback.tsx
+++ b/src/components/app/templates/editor/navigation-panel/playback.tsx
@@ -26,24 +26,24 @@ function EditorNavigationControls({ sid }: Props) {
return (
- dispatch(updateSnap({ value: Number.parseFloat(ev.value[0]) }))}>
+ dispatch(updateSnap({ value: Number.parseFloat(ev.value[0]) }))}>
Snap To
- dispatch(jumpToStart({ songId: sid }))}>
+ dispatch(jumpToStart({ songId: sid }))}>
- dispatch(seekBackwards({ songId: sid, view }))}>
+ dispatch(seekBackwards({ songId: sid, view }))}>
- dispatch(playButtonAction({ songId: sid }))}>
+ dispatch(playButtonAction({ songId: sid }))}>
{isPlaying ? : }
- dispatch(seekForwards({ songId: sid, view }))}>
+ dispatch(seekForwards({ songId: sid, view }))}>
- dispatch(jumpToEnd({ songId: sid }))}>
+ dispatch(jumpToEnd({ songId: sid }))}>
diff --git a/src/components/app/templates/editor/song-info.tsx b/src/components/app/templates/editor/song-info.tsx
index 466986b8..c2ac52fb 100644
--- a/src/components/app/templates/editor/song-info.tsx
+++ b/src/components/app/templates/editor/song-info.tsx
@@ -2,7 +2,7 @@ import type { SelectValueChangeDetails } from "@ark-ui/react/select";
import { useNavigate } from "@tanstack/react-router";
import type { CharacteristicName, DifficultyName } from "bsmap/types";
import { PlusIcon } from "lucide-react";
-import { Fragment, memo, useCallback, useMemo } from "react";
+import { memo, useCallback, useMemo } from "react";
import { CoverArtFilePreview } from "$/components/app/compositions";
import { createBeatmapListCollection } from "$/components/app/constants";
@@ -52,41 +52,37 @@ function EditorSongInfo({ sid, bid, showDifficultySelector }: Props) {
);
return (
-
-
-
-
-
-
- {metadata.title}
-
-
- {metadata.artist}
-
-
- {showDifficultySelector && bid && (
-
-
-
-
-
-
- )}
+
+
+
+
+
+ {metadata.title}
+
+
+ {metadata.artist}
+
-
-
+ {showDifficultySelector && bid && (
+
+
+
+
+ )}
+
+
);
}
diff --git a/src/components/app/templates/events/controls.tsx b/src/components/app/templates/events/controls.tsx
index 538d2527..389af027 100644
--- a/src/components/app/templates/events/controls.tsx
+++ b/src/components/app/templates/events/controls.tsx
@@ -59,23 +59,23 @@ function EventGridControls({ sid, bid, ...rest }: Props) {
- details.value.length > 0 && dispatch(updateEventsEditorEditMode({ editMode: details.value[0] as EventEditMode }))} />
+ details.value.length > 0 && dispatch(updateEventsEditorEditMode({ editMode: details.value[0] as EventEditMode }))} />
- details.value.length > 0 && dispatch(updateEventsEditorColor({ color: details.value[0] as EventColor }))} />
+ details.value.length > 0 && dispatch(updateEventsEditorColor({ color: details.value[0] as EventColor }))} />
- details.value.length > 0 && dispatch(updateEventsEditorTool({ tool: details.value[0] as EventTool }))} />
+ details.value.length > 0 && dispatch(updateEventsEditorTool({ tool: details.value[0] as EventTool }))} />
"Loop playback within the current event window (L)"}>
- dispatch(updateEventsEditorWindowLock())}>
+ dispatch(updateEventsEditorWindowLock())}>
"Pair side lasers for symmetrical left/right events"}>
- dispatch(updateEventsEditorMirrorLock())}>
+ dispatch(updateEventsEditorMirrorLock())}>
@@ -85,10 +85,10 @@ function EventGridControls({ sid, bid, ...rest }: Props) {
- dispatch(decrementEventsEditorZoom())} disabled={zoomLevel === ZOOM_LEVEL_MIN}>
+ dispatch(decrementEventsEditorZoom())} disabled={zoomLevel === ZOOM_LEVEL_MIN}>
- dispatch(incrementEventsEditorZoom())} disabled={zoomLevel === ZOOM_LEVEL_MAX}>
+ dispatch(incrementEventsEditorZoom())} disabled={zoomLevel === ZOOM_LEVEL_MAX}>
diff --git a/src/components/app/templates/shortcuts/default.tsx b/src/components/app/templates/shortcuts/default.tsx
index 52e85ab0..a2b90856 100644
--- a/src/components/app/templates/shortcuts/default.tsx
+++ b/src/components/app/templates/shortcuts/default.tsx
@@ -76,6 +76,10 @@ function DefaultEditorShortcuts({ sid }: Props) {
{ wait: wait },
);
+ const handleRefresh = useCallback(() => {
+ dispatch(saveBeatmapContents({ songId: sid }));
+ }, [dispatch, sid]);
+
const handleKeyDown = useCallback(
(ev: KeyboardEvent) => {
if (isLoading) return;
@@ -235,6 +239,7 @@ function DefaultEditorShortcuts({ sid }: Props) {
const handleWheel = useCallback(
(ev: WheelEvent) => {
+ ev.preventDefault();
if (isLoading) return;
if (!view) return;
if (activePrompt) return;
@@ -248,7 +253,9 @@ function DefaultEditorShortcuts({ sid }: Props) {
useGlobalEventListener("keydown", handleKeyDown);
useGlobalEventListener("keyup", handleKeyUp);
- useGlobalEventListener("wheel", handleWheel, { options: { passive: true } });
+ useGlobalEventListener("wheel", handleWheel, { options: { passive: false } });
+
+ useGlobalEventListener("beforeunload", handleRefresh);
return null;
}
diff --git a/src/components/scene/templates/visualization/index.tsx b/src/components/scene/templates/visualization/index.tsx
index 2235f9a2..f49ba8da 100644
--- a/src/components/scene/templates/visualization/index.tsx
+++ b/src/components/scene/templates/visualization/index.tsx
@@ -87,6 +87,8 @@ function MapVisualization({ sid, bid, beatDepth, surfaceDepth, interactive }: Pr
(event: ThreeEvent) => {
if (selectionMode) return;
if (isDispatchingEvent.current) return;
+ // ignore left click, since we don't want passthrough to take priority over placements
+ if (event.button === 0) return;
const intersects = raycaster.intersectObjects(scene.children, true);
diff --git a/src/components/ui/compositions/button.tsx b/src/components/ui/compositions/button.tsx
index 9b04a529..d8e88e6b 100644
--- a/src/components/ui/compositions/button.tsx
+++ b/src/components/ui/compositions/button.tsx
@@ -1,6 +1,6 @@
import { ark } from "@ark-ui/react/factory";
import { Presence } from "@ark-ui/react/presence";
-import { type ComponentProps, type MouseEventHandler, useCallback, useMemo } from "react";
+import { type ComponentProps, type KeyboardEvent, type MouseEvent, useCallback, useMemo } from "react";
import { Button as Styled } from "$/components/ui/styled/button";
import type { VirtualColorPalette } from "$/styles/types";
@@ -13,13 +13,12 @@ export interface ButtonProps extends ComponentProps {
loading?: boolean;
unfocusOnClick?: boolean;
}
-export function Button({ colorPalette: color, loading, onClick, unfocusOnClick, asChild, children, ...rest }: ButtonProps) {
- const handleClick = useCallback>(
- (event) => {
+export function Button({ colorPalette: color, loading, unfocusOnClick, asChild, children, ...rest }: ButtonProps) {
+ const handleUnfocus = useCallback(
+ (event: MouseEvent | KeyboardEvent) => {
if (unfocusOnClick) event.currentTarget.blur();
- if (onClick) onClick(event);
},
- [onClick, unfocusOnClick],
+ [unfocusOnClick],
);
const colorPalette = useMemo(() => {
@@ -29,7 +28,7 @@ export function Button({ colorPalette: color, loading, onClick, unfocusOnClick,
}, [color, rest.variant]);
return (
-
+
{children}
diff --git a/src/components/ui/compositions/checkbox.tsx b/src/components/ui/compositions/checkbox.tsx
index 9c5a5b5a..dbb616d6 100644
--- a/src/components/ui/compositions/checkbox.tsx
+++ b/src/components/ui/compositions/checkbox.tsx
@@ -1,15 +1,23 @@
import { type LucideProps, MinusIcon, XIcon } from "lucide-react";
-import { type ComponentProps, type ComponentType, forwardRef } from "react";
+import { type ComponentProps, type ComponentType, forwardRef, type KeyboardEvent, type MouseEvent, useCallback } from "react";
import * as Builder from "$/components/ui/styled/checkbox";
export interface CheckboxProps extends ComponentProps {
icon?: ComponentType;
+ unfocusOnClick?: boolean;
}
-export const Checkbox = forwardRef(({ icon: Icon = XIcon, children, ...rest }, ref) => {
+export const Checkbox = forwardRef(({ icon: Icon = XIcon, children, unfocusOnClick, ...rest }, ref) => {
+ const handleUnfocus = useCallback(
+ (event: MouseEvent | KeyboardEvent) => {
+ if (unfocusOnClick) event.currentTarget.blur();
+ },
+ [unfocusOnClick],
+ );
+
return (
-
+
diff --git a/src/components/ui/compositions/dialog.tsx b/src/components/ui/compositions/dialog.tsx
index 90afc608..02a9e8cc 100644
--- a/src/components/ui/compositions/dialog.tsx
+++ b/src/components/ui/compositions/dialog.tsx
@@ -39,7 +39,7 @@ function Contents({ title, description, render, children }: DialogProps & PropsW
export function Dialog({ children, ...rest }: Assign, DialogProps>) {
return (
-
+
{children && (
{children}
diff --git a/src/components/ui/compositions/select.tsx b/src/components/ui/compositions/select.tsx
index 1503d3f5..a721083c 100644
--- a/src/components/ui/compositions/select.tsx
+++ b/src/components/ui/compositions/select.tsx
@@ -3,7 +3,7 @@ import type { CollectionItem, ListCollection } from "@ark-ui/react/collection";
import { Portal } from "@ark-ui/react/portal";
import type { SelectRootProps } from "@ark-ui/react/select";
import { ChevronDownIcon } from "lucide-react";
-import type { PropsWithChildren } from "react";
+import { type KeyboardEvent, type MouseEvent, type PropsWithChildren, useCallback, useRef } from "react";
import { ListCollectionFor } from "$/components/ui/atoms";
import * as Builder from "$/components/ui/styled/select";
@@ -14,12 +14,22 @@ export interface SelectProps extends Assign;
size?: "sm" | "md";
placeholder?: string;
+ unfocusOnClick?: boolean;
}
-export function Select({ collection, placeholder, children, ...rest }: SelectProps) {
+export function Select({ collection, placeholder, children, unfocusOnClick, ...rest }: SelectProps) {
+ const ref = useRef(null);
+
+ const handleUnfocus = useCallback(
+ (event: MouseEvent | KeyboardEvent) => {
+ if (unfocusOnClick) event.currentTarget.blur();
+ },
+ [unfocusOnClick],
+ );
+
return (
- } {...rest}>
+ } {...rest} onKeyDown={(e) => e.stopPropagation()}>
-
+
{children && {children}}
diff --git a/src/components/ui/compositions/slider.tsx b/src/components/ui/compositions/slider.tsx
index bf8b42a5..4b2ef29c 100644
--- a/src/components/ui/compositions/slider.tsx
+++ b/src/components/ui/compositions/slider.tsx
@@ -1,19 +1,27 @@
-import type { ComponentProps } from "react";
+import { type ComponentProps, type KeyboardEvent, type MouseEvent, useCallback } from "react";
import { For } from "$/components/ui/atoms";
import * as Builder from "$/components/ui/styled/slider";
export interface SliderProps extends ComponentProps {
marks?: Array;
+ unfocusOnClick?: boolean;
}
-export function Slider({ children, marks, ...rest }: SliderProps) {
+export function Slider({ children, marks, unfocusOnClick, ...rest }: SliderProps) {
+ const handleUnfocus = useCallback(
+ (event: MouseEvent | KeyboardEvent) => {
+ if (unfocusOnClick) event.currentTarget.blur();
+ },
+ [unfocusOnClick],
+ );
+
return (
-
+
diff --git a/src/components/ui/compositions/switch.tsx b/src/components/ui/compositions/switch.tsx
index 09a80c5d..e42fc581 100644
--- a/src/components/ui/compositions/switch.tsx
+++ b/src/components/ui/compositions/switch.tsx
@@ -1,12 +1,21 @@
-import type { ComponentProps } from "react";
+import { type ComponentProps, type KeyboardEvent, type MouseEvent, useCallback } from "react";
import * as Builder from "$/components/ui/styled/switch";
-export interface SwitchProps extends ComponentProps {}
-export function Switch({ children, ...rest }: SwitchProps) {
+export interface SwitchProps extends ComponentProps {
+ unfocusOnClick?: boolean;
+}
+export function Switch({ children, unfocusOnClick, ...rest }: SwitchProps) {
+ const handleUnfocus = useCallback(
+ (event: MouseEvent | KeyboardEvent) => {
+ if (unfocusOnClick) event.currentTarget.blur();
+ },
+ [unfocusOnClick],
+ );
+
return (
-
+
{children && {children}}
diff --git a/src/components/ui/compositions/tabs.tsx b/src/components/ui/compositions/tabs.tsx
index 5fc85d9f..d9c82e06 100644
--- a/src/components/ui/compositions/tabs.tsx
+++ b/src/components/ui/compositions/tabs.tsx
@@ -1,6 +1,6 @@
import type { CollectionItem, ListCollection } from "@ark-ui/react/collection";
import type { UseTabsContext } from "@ark-ui/react/tabs";
-import type { ComponentProps, ReactNode } from "react";
+import { type ComponentProps, type KeyboardEvent, type MouseEvent, type ReactNode, useCallback } from "react";
import { ListCollectionFor } from "$/components/ui/atoms";
import * as Builder from "$/components/ui/styled/tabs";
@@ -15,8 +15,16 @@ export interface TabsItem extends CollectionItem {
export interface TabsProps extends ComponentProps {
collection: ListCollection;
colorPalette?: VirtualColorPalette;
+ unfocusOnClick?: boolean;
}
-export function Tabs({ collection, colorPalette = "pink", ...rest }: TabsProps) {
+export function Tabs({ collection, colorPalette = "pink", unfocusOnClick, ...rest }: TabsProps) {
+ const handleUnfocus = useCallback(
+ (event: MouseEvent | KeyboardEvent) => {
+ if (unfocusOnClick) event.currentTarget.blur();
+ },
+ [unfocusOnClick],
+ );
+
return (
@@ -27,7 +35,7 @@ export function Tabs({ collection, colorPalette = "pink", ..
const label = collection.stringifyItem(item);
const disabled = collection.getItemDisabled(item);
return (
-
+
{label}
);
diff --git a/src/components/ui/compositions/toggle-group.tsx b/src/components/ui/compositions/toggle-group.tsx
index 824f24e6..8a372e1c 100644
--- a/src/components/ui/compositions/toggle-group.tsx
+++ b/src/components/ui/compositions/toggle-group.tsx
@@ -1,5 +1,5 @@
import type { CollectionItem, ListCollection } from "@ark-ui/react/collection";
-import type { ComponentProps } from "react";
+import { type ComponentProps, type MouseEventHandler, useCallback } from "react";
import { ListCollectionFor } from "$/components/ui/atoms";
import * as Builder from "$/components/ui/styled/toggle-group";
@@ -8,10 +8,18 @@ export interface ToggleItem extends CollectionItem {}
export interface ToggleGroupProps extends ComponentProps {
collection: ListCollection;
+ unfocusOnClick?: boolean;
}
-export function ToggleGroup({ collection, ...rest }: ToggleGroupProps) {
+export function ToggleGroup({ collection, unfocusOnClick, ...rest }: ToggleGroupProps) {
+ const handleClickCapture = useCallback>(
+ (event) => {
+ if (unfocusOnClick) event.currentTarget.blur();
+ },
+ [unfocusOnClick],
+ );
+
return (
-
+
{(item) => {
const value = collection.getItemValue(item);
@@ -19,7 +27,7 @@ export function ToggleGroup({ collection, ...rest }: Toggl
const label = collection.stringifyItem(item);
const disabled = collection.getItemDisabled(item);
return (
-
+
{label}
);
diff --git a/src/components/ui/compositions/toggle.tsx b/src/components/ui/compositions/toggle.tsx
index 930221b9..24600bc5 100644
--- a/src/components/ui/compositions/toggle.tsx
+++ b/src/components/ui/compositions/toggle.tsx
@@ -1,9 +1,22 @@
-import type { ComponentProps } from "react";
+import { type ComponentProps, type KeyboardEvent, type MouseEvent, useCallback } from "react";
import * as Builder from "$/components/ui/styled/toggle";
-interface Props extends ComponentProps {}
+interface Props extends ComponentProps {
+ unfocusOnClick?: boolean;
+}
+
+export function Toggle({ children, unfocusOnClick, ...rest }: Props) {
+ const handleUnfocus = useCallback(
+ (event: MouseEvent | KeyboardEvent) => {
+ if (unfocusOnClick) event.currentTarget.blur();
+ },
+ [unfocusOnClick],
+ );
-export function Toggle({ children, ...rest }: Props) {
- return {children};
+ return (
+
+ {children}
+
+ );
}
diff --git a/src/helpers/item.helpers.ts b/src/helpers/item.helpers.ts
index 31f65ff6..bcceb6b1 100644
--- a/src/helpers/item.helpers.ts
+++ b/src/helpers/item.helpers.ts
@@ -1,4 +1,5 @@
-import { mirrorNoteColor, mirrorNoteDirectionHorizontally, mirrorNoteDirectionVertically, type NoteColor } from "bsmap";
+import { mirrorNoteColor, mirrorNoteDirectionHorizontally, mirrorNoteDirectionVertically } from "bsmap";
+import type { wrapper } from "bsmap/types";
import { DEFAULT_NUM_COLS, DEFAULT_NUM_ROWS } from "$/constants";
import type { IGrid } from "$/types";
@@ -17,22 +18,35 @@ export function nudgeItem(item: T, direction: "forwa
} as Partial;
}
-function mirrorCoordinate(coordinate: number, count: number, offset?: number) {
- const fromExtended = (x: number) => (x >= 1000 || x <= -1000 ? x / 1000 + (x > 0 ? -1 : 1) : x);
- const value = fromExtended(coordinate);
+function isExtendedCoordinate(x: number) {
+ return x >= 1000 || x <= -1000;
+}
+function deserializeCoordinate(x: number) {
+ return isExtendedCoordinate(x) ? x / 1000 + (x > 0 ? -1 : 1) : x;
+}
+function serializeCoordinate(x: number, extensions?: boolean) {
+ if (extensions) return (x >= 0 ? x + 1 : x - 1) * 1000;
+ return x;
+}
+
+export function mirrorCoordinate(coordinate: number, count: number, offset?: number) {
+ const value = deserializeCoordinate(coordinate);
const axis = (count - 1) / 2;
- const mirrored = axis - value + axis + (offset ? 1 - fromExtended(offset ?? 0) : 0);
- return (mirrored >= 0 ? mirrored + 1 : mirrored - 1) * 1000;
+ const mirrored = axis - value + axis + (offset ? 1 - deserializeCoordinate(offset ?? 0) : 0);
+ return serializeCoordinate(mirrored, isExtendedCoordinate(coordinate));
+}
+
+export function mirrorGridObjectProperties(item: T, axis: "horizontal" | "vertical", grid?: IGrid, offset?: number): Partial {
+ return {
+ posX: axis === "horizontal" ? mirrorCoordinate(item.posX, DEFAULT_NUM_COLS, offset) : item.posX,
+ posY: axis === "vertical" ? mirrorCoordinate(item.posY, grid?.numRows ?? DEFAULT_NUM_ROWS, offset) : item.posY,
+ } as Partial;
}
-export function mirrorItem(item: T, axis: "horizontal" | "vertical", grid?: IGrid, offset?: number) {
+export function mirrorBaseNoteProperties(item: T, axis: "horizontal" | "vertical"): Partial {
const resolveDirection = axis === "horizontal" ? mirrorNoteDirectionHorizontally : mirrorNoteDirectionVertically;
- const color = item.color !== undefined ? item.color : undefined;
- const direction = item.direction !== undefined ? item.direction : undefined;
return {
- posX: axis === "horizontal" && item.posX !== undefined ? mirrorCoordinate(item.posX, DEFAULT_NUM_COLS, offset) : item.posX,
- posY: axis === "vertical" && item.posY !== undefined ? mirrorCoordinate(item.posY, grid?.numRows ?? DEFAULT_NUM_ROWS, offset) : item.posY,
- color: axis === "horizontal" && color !== undefined ? mirrorNoteColor(color) : item.color,
- direction: direction !== undefined ? resolveDirection(direction) : undefined,
+ color: axis === "horizontal" ? mirrorNoteColor(item.color) : item.color,
+ direction: resolveDirection(item.direction),
} as Partial;
}
diff --git a/src/helpers/song.helpers.ts b/src/helpers/song.helpers.ts
index f72a1e99..0e9c8308 100644
--- a/src/helpers/song.helpers.ts
+++ b/src/helpers/song.helpers.ts
@@ -1,4 +1,4 @@
-import { slugify } from "@std/text/unstable-slugify";
+import { toPascalCase } from "@std/text/to-pascal-case";
import { CharacteristicName, DifficultyName } from "bsmap/types";
import { DEFAULT_GRID } from "$/constants";
@@ -7,8 +7,11 @@ import { deepAssign } from "$/utils";
import { deriveColorSchemeFromEnvironment } from "./colors.helpers";
import { patchEnvironmentName } from "./packaging.helpers";
-export function resolveSongId(x: Pick): string {
- return slugify(x.name);
+export function createSongId(x: Pick): string {
+ return toPascalCase(x.name);
+}
+export function resolveSongId(x: Pick): string {
+ return x.id.toString();
}
export function resolveBeatmapId(x: Pick): string {
if (x.characteristic !== "Standard") return `${x.difficulty}${x.characteristic}`;
diff --git a/src/routes/editor/layout.tsx b/src/routes/editor/layout.tsx
index e6706eda..fdf21245 100644
--- a/src/routes/editor/layout.tsx
+++ b/src/routes/editor/layout.tsx
@@ -10,7 +10,7 @@ import { MDXContent } from "$/components/ui/atoms";
import { List, Text } from "$/components/ui/compositions";
import { store } from "$/setup";
import { dismissPrompt, leaveEditor, startLoadingMap } from "$/store/actions";
-import { selectAnnouncements } from "$/store/selectors";
+import { selectAllEntities, selectAnnouncements } from "$/store/selectors";
import { prompts } from "$:content";
import { css } from "$:styled-system/css";
import { styled } from "$:styled-system/jsx";
@@ -57,7 +57,9 @@ export const Route = createFileRoute("/_/edit/$sid/$bid/_")({
}
},
onLeave: async ({ params }) => {
- await Promise.resolve(store.dispatch(leaveEditor({ songId: params.sid, beatmapId: params.bid })));
+ const state = store.getState();
+ const entities = selectAllEntities(state);
+ await Promise.resolve(store.dispatch(leaveEditor({ songId: params.sid, beatmapId: params.bid, entities })));
},
});
diff --git a/src/services/packaging.service.ts b/src/services/packaging.service.ts
index c5ff53f9..5ceb11fb 100644
--- a/src/services/packaging.service.ts
+++ b/src/services/packaging.service.ts
@@ -10,7 +10,7 @@ import { convertMillisecondsToBeats, deriveAudioDataFromFile } from "$/helpers/a
import { serializeCustomBookmark } from "$/helpers/bookmarks.helpers";
import { deserializeInfoContents } from "$/helpers/packaging.helpers";
import type { ImplicitVersion } from "$/helpers/serialization.helpers";
-import { getSelectedBeatmap, resolveBeatmapIdFromFilename, resolveLightshowIdFromFilename, resolveSongId } from "$/helpers/song.helpers";
+import { createSongId, getSelectedBeatmap, resolveBeatmapIdFromFilename, resolveLightshowIdFromFilename } from "$/helpers/song.helpers";
import { filestore } from "$/setup";
import type { App, IEntityMap, SongId } from "$/types";
import { deepAssign, yieldValue } from "$/utils";
@@ -168,7 +168,7 @@ export async function processImportedMap(zipFile: Uint8Array, options: { current
// parse the wrapper into the editor form
const song = deserializeInfoContents(info, { readonly: options.readonly });
- const songId = resolveSongId(song);
+ const songId = createSongId(song);
// save the info data (Not 100% sure that this is necessary, but better to have and not need)
await filestore.saveInfoContents(songId, info);
@@ -257,6 +257,7 @@ export async function processImportedMap(zipFile: Uint8Array, options: { current
return {
...song,
+ id: songId,
selectedDifficulty: getSelectedBeatmap(song),
createdAt: Date.now(),
};
diff --git a/src/setup.ts b/src/setup.ts
index 7acc29b3..975c8d87 100644
--- a/src/setup.ts
+++ b/src/setup.ts
@@ -1,17 +1,19 @@
import { typeByExtension } from "@std/media-types/type-by-extension";
import { extname } from "@std/path/extname";
+import { toPascalCase } from "@std/text/to-pascal-case";
import { createBeatmap, loadDifficulty, loadInfo } from "bsmap";
import { createStorage, type StorageValue } from "unstorage";
import { BeatmapFilestore } from "./services/file.service";
import { createDriver, type LegacyStorageSchema } from "./services/storage.service";
import { createAppStore } from "./store/setup";
+import type { App } from "./types";
import { createAutosaveWorker } from "./workers";
export const driver = createDriver({
name: "beat-mapper-files",
- version: 3,
- async upgrade(idb, current, next, tx) {
+ version: 4,
+ async upgrade(idb, _current, next, tx) {
// this is a remnant of localforage, and is no longer necessary since blobs are universally supported
await idb.removeStore("local-forage-detect-blob-support", tx);
@@ -53,6 +55,18 @@ export const driver = createDriver= 4) {
+ const keys = await idb.keys("entries", tx);
+ await Promise.all([
+ keys.forEach(async (key) => {
+ const sid = key.split(".")[0];
+ if (sid === toPascalCase(sid)) return;
+ const current = (await idb.get("entries", key, tx)) as App.ISong;
+ await idb.set("entries", key.replace(sid, toPascalCase(sid)), current, tx);
+ await idb.delete("entries", key, tx);
+ }),
+ ]);
+ }
},
});
diff --git a/src/store/actions.ts b/src/store/actions.ts
index e917def9..ac902b50 100644
--- a/src/store/actions.ts
+++ b/src/store/actions.ts
@@ -74,7 +74,7 @@ export const downloadMapFiles = createAction("downloadMap", (args: { songId: Son
return { payload: { ...args } };
});
-export const leaveEditor = createAction("leaveEditor", (args: { songId: SongId; beatmapId: BeatmapId }) => {
+export const leaveEditor = createAction("leaveEditor", (args: { songId: SongId; beatmapId: BeatmapId; entities: Partial }) => {
return { payload: { ...args } };
});
diff --git a/src/store/features/entities/beatmap/bombs.slice.ts b/src/store/features/entities/beatmap/bombs.slice.ts
index bec384d2..ae3082f8 100644
--- a/src/store/features/entities/beatmap/bombs.slice.ts
+++ b/src/store/features/entities/beatmap/bombs.slice.ts
@@ -1,7 +1,7 @@
import { createEntityAdapter, createSlice, type EntityId, isAnyOf } from "@reduxjs/toolkit";
import { createBombNote, sortObjectFn } from "bsmap";
-import { mirrorItem, nudgeItem, resolveTimeForItem } from "$/helpers/item.helpers";
+import { mirrorGridObjectProperties, nudgeItem, resolveTimeForItem } from "$/helpers/item.helpers";
import { resolveNoteId } from "$/helpers/notes.helpers";
import {
addSong,
@@ -123,9 +123,15 @@ const slice = createSlice({
builder.addCase(mirrorSelection, (state, action) => {
const { axis, grid } = action.payload;
const entities = selectAllSelected(state);
- return adapter.updateMany(
+ adapter.removeMany(
+ state,
+ entities.map((x) => adapter.selectId(x)),
+ );
+ return adapter.addMany(
state,
- entities.map((x) => ({ id: adapter.selectId(x), changes: mirrorItem(x, axis, grid) })),
+ entities.map((x) => {
+ return { ...x, ...mirrorGridObjectProperties(x, axis, grid, 0) };
+ }),
);
});
builder.addCase(nudgeSelection.fulfilled, (state, action) => {
diff --git a/src/store/features/entities/beatmap/notes.slice.ts b/src/store/features/entities/beatmap/notes.slice.ts
index cb9fd784..d9fca4b1 100644
--- a/src/store/features/entities/beatmap/notes.slice.ts
+++ b/src/store/features/entities/beatmap/notes.slice.ts
@@ -1,7 +1,7 @@
import { createEntityAdapter, createSlice, type EntityId, isAnyOf } from "@reduxjs/toolkit";
import { createColorNote, mirrorNoteColor, sortObjectFn } from "bsmap";
-import { mirrorItem, nudgeItem, resolveTimeForItem } from "$/helpers/item.helpers";
+import { mirrorBaseNoteProperties, mirrorGridObjectProperties, nudgeItem, resolveTimeForItem } from "$/helpers/item.helpers";
import { resolveNoteId } from "$/helpers/notes.helpers";
import {
addSong,
@@ -131,9 +131,15 @@ const slice = createSlice({
builder.addCase(mirrorSelection, (state, action) => {
const { axis, grid } = action.payload;
const entities = selectAllSelected(state);
- return adapter.updateMany(
+ adapter.removeMany(
+ state,
+ entities.map((x) => adapter.selectId(x)),
+ );
+ return adapter.addMany(
state,
- entities.map((x) => ({ id: adapter.selectId(x), changes: mirrorItem(x, axis, grid) })),
+ entities.map((x) => {
+ return { ...x, ...mirrorGridObjectProperties(x, axis, grid, 0), ...mirrorBaseNoteProperties(x, axis) };
+ }),
);
});
builder.addCase(nudgeSelection.fulfilled, (state, action) => {
diff --git a/src/store/features/entities/beatmap/obstacles.slice.ts b/src/store/features/entities/beatmap/obstacles.slice.ts
index b9b7ee30..c136240c 100644
--- a/src/store/features/entities/beatmap/obstacles.slice.ts
+++ b/src/store/features/entities/beatmap/obstacles.slice.ts
@@ -1,7 +1,7 @@
import { type AsyncThunkPayloadCreator, createEntityAdapter, type EntityId, isAnyOf, type Update } from "@reduxjs/toolkit";
import { createObstacle, sortObjectFn } from "bsmap";
-import { mirrorItem, nudgeItem, resolveTimeForItem } from "$/helpers/item.helpers";
+import { mirrorGridObjectProperties, nudgeItem, resolveTimeForItem } from "$/helpers/item.helpers";
import { resolveObstacleId } from "$/helpers/obstacles.helpers";
import { addObstacle, addSong, cutSelection, deselectAllEntities, deselectAllEntitiesOfType, leaveEditor, loadBeatmapEntities, mirrorSelection, nudgeSelection, pasteSelection, removeAllSelectedObjects, selectAllEntities, selectAllEntitiesInRange, startLoadingMap } from "$/store/actions";
import { createSelectedEntitiesSelector, createSlice } from "$/store/helpers";
@@ -138,9 +138,15 @@ const slice = createSlice({
const { axis, grid } = action.payload;
if (axis === "vertical") return state;
const entities = selectAllSelected(state);
- return adapter.updateMany(
+ adapter.removeMany(
+ state,
+ entities.map((x) => adapter.selectId(x)),
+ );
+ return adapter.addMany(
state,
- entities.map((x) => ({ id: adapter.selectId(x), changes: mirrorItem(x, "horizontal", grid, x.width) })),
+ entities.map((x) => {
+ return { ...x, ...mirrorGridObjectProperties(x, axis, grid, x.width) };
+ }),
);
});
builder.addCase(nudgeSelection.fulfilled, (state, action) => {
diff --git a/src/store/features/songs.slice.ts b/src/store/features/songs.slice.ts
index aa273923..f4374a3d 100644
--- a/src/store/features/songs.slice.ts
+++ b/src/store/features/songs.slice.ts
@@ -22,8 +22,7 @@ const fetchContentsFromFile: AsyncThunkPayloadCreator<{ songId: SongId; songData
const { readonly } = args.options;
const archive = await args.file.arrayBuffer();
const songData = await processImportedMap(new Uint8Array(archive), args.options);
- const songId = resolveSongId({ name: songData.name });
- return api.fulfillWithValue({ songId, songData: { ...songData, demo: readonly } });
+ return api.fulfillWithValue({ songId: songData.id, songData: { ...songData, demo: readonly } });
} catch (e) {
return api.rejectWithValue(e);
}
@@ -95,7 +94,7 @@ const slice = createSlice({
return {
payload: {
songId: songId,
- songData: { ...rest },
+ songData: { ...rest, id: songId.toString() },
beatmapId: resolveBeatmapId({ characteristic: selectedCharacteristic, difficulty: selectedDifficulty }),
beatmapData: { characteristic: selectedCharacteristic, difficulty: selectedDifficulty, mappers, lighters: mappers },
songFile,
diff --git a/src/store/middleware/backup.middleware.ts b/src/store/middleware/backup.middleware.ts
index 56d58c7b..1d153a62 100644
--- a/src/store/middleware/backup.middleware.ts
+++ b/src/store/middleware/backup.middleware.ts
@@ -1,19 +1,21 @@
import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/toolkit";
+import type { BeatmapFilestore } from "$/services/file.service";
import { downloadMapFiles, leaveEditor, saveBeatmapContents, updateBeatmap, updateSong } from "$/store/actions";
import type { RootState } from "$/store/setup";
-import type { BeatmapId, SongId } from "$/types";
+import type { App, BeatmapId, SongId } from "$/types";
import type { createAutosaveWorker } from "$/workers";
import { selectActiveBeatmapId } from "../selectors";
interface Options {
+ filestore: BeatmapFilestore;
worker: ReturnType;
}
export default function createBackupMiddleware({ worker }: Options) {
const instance = createListenerMiddleware();
instance.startListening({
- matcher: isAnyOf(saveBeatmapContents, downloadMapFiles, leaveEditor),
+ matcher: isAnyOf(saveBeatmapContents, downloadMapFiles),
effect: async (action: PayloadAction<{ songId: SongId }>, api) => {
const { songId } = action.payload;
const state = api.getState();
@@ -21,6 +23,14 @@ export default function createBackupMiddleware({ worker }: Options) {
await worker.save(state, songId, beatmapId);
},
});
+ instance.startListening({
+ matcher: isAnyOf(leaveEditor),
+ effect: async (action: PayloadAction<{ songId: SongId; beatmapId: BeatmapId; entities: Partial }>, api) => {
+ const { songId, beatmapId, entities } = action.payload;
+ const state = api.getState();
+ await worker.save(state, songId, beatmapId, entities);
+ },
+ });
instance.startListening({
matcher: isAnyOf(updateSong),
effect: async (action: PayloadAction<{ songId: SongId }>, api) => {
diff --git a/src/store/middleware/demo.middleware.ts b/src/store/middleware/demo.middleware.ts
index 0ddcf405..9df57588 100644
--- a/src/store/middleware/demo.middleware.ts
+++ b/src/store/middleware/demo.middleware.ts
@@ -1,7 +1,7 @@
import { createListenerMiddleware } from "@reduxjs/toolkit";
import { demoFileUrl } from "$/assets";
-import { getSelectedBeatmap, resolveSongId } from "$/helpers/song.helpers";
+import { getSelectedBeatmap } from "$/helpers/song.helpers";
import { router } from "$/index";
import { addSongFromFile, loadDemoMap } from "$/store/actions";
import type { RootState } from "$/store/setup";
@@ -16,10 +16,9 @@ export default function createDemoMiddleware() {
actionCreator: loadDemoMap,
effect: async (_, api) => {
const blob = await fetch(demoFileUrl).then((response) => response.blob());
- const { songData } = await api.dispatch(addSongFromFile({ file: blob, options: { readonly: true } })).unwrap();
- const sid = resolveSongId({ name: songData.name });
+ const { songId: sid, songData } = await api.dispatch(addSongFromFile({ file: blob, options: { readonly: true } })).unwrap();
const bid = getSelectedBeatmap(songData);
- router.navigate({ to: "/edit/$sid/$bid/notes", params: { sid, bid: bid.toString() } });
+ router.navigate({ to: "/edit/$sid/$bid/notes", params: { sid: sid.toString(), bid: bid.toString() } });
},
});
diff --git a/src/store/middleware/index.ts b/src/store/middleware/index.ts
index 9aceba71..6e8e85b2 100644
--- a/src/store/middleware/index.ts
+++ b/src/store/middleware/index.ts
@@ -26,7 +26,7 @@ export function createAllSharedMiddleware({ filestore, autosaveWorker }: Options
const audioMiddleware = createAudioMiddleware({ filestore });
const fileMiddleware = createFileMiddleware({ filestore });
const downloadMiddleware = createPackagingMiddleware({ filestore });
- const backupMiddleware = createBackupMiddleware({ worker: autosaveWorker });
+ const backupMiddleware = createBackupMiddleware({ filestore, worker: autosaveWorker });
const demoMiddleware = createDemoMiddleware();
const historyMiddleware = createHistoryMiddleware();
diff --git a/src/store/setup.ts b/src/store/setup.ts
index 58cdd87c..cbb8ad88 100644
--- a/src/store/setup.ts
+++ b/src/store/setup.ts
@@ -2,6 +2,7 @@
import { configureStore, type DevToolsEnhancerOptions } from "@reduxjs/toolkit";
import { omit } from "@std/collections/omit";
+import { toPascalCase } from "@std/text/to-pascal-case";
import type { NoteDirection } from "bsmap";
import { initStateWithPrevTab } from "redux-state-sync";
import { createStorage } from "unstorage";
@@ -83,8 +84,8 @@ export type SessionStorageObservers = {
const driver = createDriver } }>({
name: "beat-mapper-state",
- version: 3,
- async upgrade(idb, current, next, tx) {
+ version: 4,
+ async upgrade(idb, _current, next, tx) {
// this is a remnant of localforage, and is no longer necessary since blobs are universally supported
await idb.removeStore("local-forage-detect-blob-support", tx);
@@ -139,6 +140,17 @@ const driver = createDriver= 4) {
+ const keys = await idb.keys("songs", tx);
+ await Promise.all([
+ keys.forEach(async (sid) => {
+ if (sid === toPascalCase(sid)) return;
+ const current = (await idb.get("songs", sid, tx)) as App.ISong;
+ await idb.set("songs", toPascalCase(sid), { ...current, id: toPascalCase(sid) }, tx);
+ await idb.delete("songs", sid);
+ }),
+ ]);
+ }
},
});
diff --git a/src/types/beatmap/app/info.ts b/src/types/beatmap/app/info.ts
index fdff2d89..bb4e9398 100644
--- a/src/types/beatmap/app/info.ts
+++ b/src/types/beatmap/app/info.ts
@@ -31,6 +31,7 @@ export interface IBeatmap {
}
export interface ISong {
+ id: EntityId;
name: string;
subName: string;
artistName: string;
diff --git a/src/workers/autosave.worker.ts b/src/workers/autosave.worker.ts
index 20cbdc1e..af2ca9ba 100644
--- a/src/workers/autosave.worker.ts
+++ b/src/workers/autosave.worker.ts
@@ -4,7 +4,7 @@ import type { BeatmapFilestore } from "$/services/file.service";
import { selectBeatmapSerializationOptionsFromState, selectInfoSerializationOptionsFromState } from "$/store/middleware/file.middleware";
import { selectAllEntities, selectBeatmapIdsWithLightshowId, selectLightshowIdForBeatmap, selectSongById } from "$/store/selectors";
import type { RootState } from "$/store/setup";
-import type { BeatmapId, SongId } from "$/types";
+import type { App, BeatmapId, SongId } from "$/types";
// A mechanism already exists to back up the Redux state to our persistence layer, so that the state can be rehydrated on return visits.
// The only Redux state persisted is the `songs` reducer; for stuff like what the notes are for the current song, we'll read that from the files saved to disk,
@@ -15,7 +15,7 @@ import type { BeatmapId, SongId } from "$/types";
// it's because the user can have dozens or hundreds of songs, and each song can have thousands of notes. It's too much to keep in RAM.
// So I store non-loaded songs to disk, stored in indexeddb. It uses the same mechanism as Redux Storage, but it's treated separately.)
-export async function save(state: RootState, filestore: BeatmapFilestore, songId: SongId, beatmapId: BeatmapId | null) {
+export async function save(state: RootState, filestore: BeatmapFilestore, songId: SongId, beatmapId: BeatmapId | null, entities?: Partial) {
// If we have an actively-loaded song, we want to first persist that song so that we download the very latest stuff.
const song = selectSongById(state, songId);
const infoContents = serializeInfoContents(song, selectInfoSerializationOptionsFromState(state, songId));
@@ -23,8 +23,8 @@ export async function save(state: RootState, filestore: BeatmapFilestore, songId
// Note that we can also download files from the homescreen, so there will be no selected difficulty in this case.
if (beatmapId) {
- const entities = selectAllEntities(state);
- const { difficulty, lightshow, customData } = serializeBeatmapContents(entities, selectBeatmapSerializationOptionsFromState(state, songId));
+ const activeEntities = entities ?? selectAllEntities(state);
+ const { difficulty, lightshow, customData } = serializeBeatmapContents(activeEntities, selectBeatmapSerializationOptionsFromState(state, songId));
const { contents } = await filestore.updateBeatmapContents(songId, beatmapId, { difficulty, lightshow, customData });
// we want to copy lightshow data across beatmaps that share the same lightshow id
@@ -47,6 +47,6 @@ interface Options {
}
export function createAutosaveWorker({ filestore }: Options) {
return {
- save: async (state: RootState, songId: SongId, beatmapId: BeatmapId | null) => await save(state, filestore, songId, beatmapId),
+ save: async (state: RootState, songId: SongId, beatmapId: BeatmapId | null, entities?: Partial) => await save(state, filestore, songId, beatmapId, entities),
};
}