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) { - - - 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"}> - "Jump to a specific beat number"}> - {mappingExtensionsEnabled && ( "Change the number of columns/rows"}> - 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 ( - - - - - - - - - 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 ( - - 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 ( - 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"}> - "Right Color Note"}> - "Bomb Note"}> - "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) && ( - setSlot(x.value[0])} /> "Load Grid Preset"}> - "Delete Grid Preset"}> - @@ -63,13 +63,13 @@ function GridActionPanel({ sid, finishTweakingGrid }: Props) { - - - 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"}> - "Mirror selection vertically"}> - "Nudge selection forwards"}> - "Nudge selection backwards"}> - - 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]) }))}> Snap To - - - - - 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 && ( - - - + ( + + {() => "Create beatmap"} + + )} + > + + + + )} + + ); } 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) { - - 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), }; }