diff --git a/package.json b/package.json index 22ab06f8..672d9eb5 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "date-fns": "^4.1.0", "fflate": "^0.8.2", "file-saver": "^2.0.5", + "history-adapter": "^2.1.1", "idb": "^8.0.3", "lucide-react": "^1.0.1", "postprocessing": "^6.39.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b268b9a..1e8929be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: file-saver: specifier: ^2.0.5 version: 2.0.5 + history-adapter: + specifier: ^2.1.1 + version: 2.1.1(@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)) idb: specifier: ^8.0.3 version: 8.0.3 @@ -3359,6 +3362,15 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + history-adapter@2.1.1: + resolution: {integrity: sha512-a1JcX59psNFMWGeMW9qei6SImcgl+RTmGCNoX1n88gwdcFpBBkEzciHxiM/m4qeqFkZPl1fafXIu5FKK90Uv8w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@reduxjs/toolkit': ^1.9.7 || ^2 + peerDependenciesMeta: + '@reduxjs/toolkit': + optional: true + hls.js@1.6.16: resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} @@ -3395,6 +3407,9 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + immer@11.1.8: resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} @@ -9386,6 +9401,12 @@ snapshots: property-information: 7.2.0 space-separated-tokens: 2.0.2 + history-adapter@2.1.1(@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)): + dependencies: + immer: 10.2.0 + optionalDependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7) + hls.js@1.6.16: {} hono@4.12.24: {} @@ -9416,6 +9437,8 @@ snapshots: immediate@3.0.6: {} + immer@10.2.0: {} + immer@11.1.8: {} indent-string@2.1.0: diff --git a/src/setup.ts b/src/_setup.ts similarity index 70% rename from src/setup.ts rename to src/_setup.ts index 80c08254..101e56ba 100644 --- a/src/setup.ts +++ b/src/_setup.ts @@ -2,20 +2,16 @@ import { createToaster } from "@ark-ui/react/toast"; 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, setupLogger } from "bsmap"; -import { createStorage, type Driver, type StorageValue } from "unstorage"; +import { createBeatmap, loadDifficulty, loadInfo } from "bsmap"; +import { type CreateStorageOptions, 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 { createAppStore } from "./store/_setup"; import type { App } from "./types"; -import { createLazySingleton } from "./utils"; +import { createSingleton } from "./utils"; -if (import.meta.env.DEV) { - setupLogger(); -} - -const appFileDriver = createDriver({ +export const createAppFileStorageDriver = createDriver({ name: "beat-mapper-files", version: 4, async upgrade(idb, _current, next, tx) { @@ -75,21 +71,16 @@ const appFileDriver = createDriver { - return new AudioContext({ latencyHint: "playback" }); +export const AppAudioContext = createSingleton((options: Omit) => { + return new AudioContext({ ...options, latencyHint: "playback" }); }); -export const { get: getAppBeatmapFilestore, setup: setupAppBeatmapFilestore } = createLazySingleton((driver?: Driver) => { +export const AppFilestore = createSingleton((options: CreateStorageOptions) => { return new BeatmapFilestore({ - storage: createStorage({ - driver: driver ?? appFileDriver({ name: "entries" }), - }), + storage: createStorage(options), }); }); -export const { get: getAppToaster, setup: setupAppToaster } = createLazySingleton((toaster?: ReturnType): NonNullable | null => { - if (toaster !== undefined) return toaster ?? null; - return createToaster({ placement: "bottom-end", overlap: true, max: 8 }); -}); +export const AppToaster = createSingleton(createToaster); -export const { get: getAppStore, setup: setupAppStore } = createLazySingleton(createAppStore); +export const AppStore = createSingleton(createAppStore); diff --git a/src/components/app/forms/create-beatmap.tsx b/src/components/app/forms/create-beatmap.tsx index 83d97634..7f172a55 100644 --- a/src/components/app/forms/create-beatmap.tsx +++ b/src/components/app/forms/create-beatmap.tsx @@ -8,7 +8,7 @@ import type { PropsWithChildren, ReactNode } from "react"; import { object } from "valibot"; import { createBeatmapCharacteristicListCollection, createBeatmapDifficultyListCollection } from "$/components/app/constants"; -import { useSetupContext } from "$/components/context"; +import { useToaster } from "$/components/context"; import { useAppForm } from "$/components/ui/compositions"; import { type createAppBeatmap, resolveBeatmapId } from "$/helpers/song.helpers"; import { useAppSelector } from "$/store/hooks"; @@ -30,8 +30,7 @@ function CreateBeatmapForm({ dialog, onSubmit, children }: Assign selectAllBeatmaps(state, sid)); @@ -54,7 +53,7 @@ function CreateBeatmapForm({ dialog, onSubmit, children }: Assign void; } function ExportMapForm({ onSubmit, ...rest }: Assign, Props>) { - const { toaster } = useSetupContext(); + const toaster = useToaster(); const { collection: serialVersionCollection } = useListCollection({ initialItems: ["4", "3", "2", "1"], @@ -46,7 +46,7 @@ function ExportMapForm({ onSubmit, ...rest }: Assign void; } function ImportMapForm({ dialog, onAccept, ...rest }: Assign, Props>) { - const { toaster } = useSetupContext(); + const toaster = useToaster(); const handleFileChange = useCallback( (details: FileUploadFileChangeDetails) => { @@ -23,7 +23,7 @@ function ImportMapForm({ dialog, onAccept, ...rest }: Assign - dispatch(updateTickType({ value: TICK_MAP.indexOf(details.valueAsString) }))} /> - - - dispatch(updateProcessingDelay({ value: details.valueAsNumber }))} /> + dispatch(updateTickType(TICK_MAP.indexOf(details.valueAsString)))} /> diff --git a/src/components/app/forms/settings/controls.tsx b/src/components/app/forms/settings/controls.tsx index c1a11ba0..0f654d06 100644 --- a/src/components/app/forms/settings/controls.tsx +++ b/src/components/app/forms/settings/controls.tsx @@ -21,7 +21,7 @@ function AppControlsSettings() { - dispatch(updateObstaclePlacementMode({ value: x.value as ObstaclePlacementMode }))} /> + dispatch(updateObstaclePlacementMode(x.value as ObstaclePlacementMode))} /> diff --git a/src/components/app/forms/settings/graphics.tsx b/src/components/app/forms/settings/graphics.tsx index 2c2534d3..3a077933 100644 --- a/src/components/app/forms/settings/graphics.tsx +++ b/src/components/app/forms/settings/graphics.tsx @@ -13,10 +13,10 @@ function AppGraphicsSettings() { - dispatch(updateRenderScale({ value: details.value[0] }))} /> + dispatch(updateRenderScale(details.value[0]))} /> - dispatch(updateBloomEnabled({ checked: details.checked }))} /> + dispatch(updateBloomEnabled(details.checked))} /> diff --git a/src/components/app/forms/settings/user.tsx b/src/components/app/forms/settings/user.tsx index 8c34a739..97546a4b 100644 --- a/src/components/app/forms/settings/user.tsx +++ b/src/components/app/forms/settings/user.tsx @@ -12,7 +12,7 @@ function AppUserSettings() { - dispatch(updateUsername({ value: details.valueAsString }))} /> + dispatch(updateUsername(details.valueAsString))} /> diff --git a/src/components/app/forms/update-beatmap.tsx b/src/components/app/forms/update-beatmap.tsx index acce4b1f..eb80540f 100644 --- a/src/components/app/forms/update-beatmap.tsx +++ b/src/components/app/forms/update-beatmap.tsx @@ -9,7 +9,7 @@ import { array, endsWith, type GenericSchema, gtValue, null_, number, object, pi import { ENVIRONMENT_OVERRIDE_COLLECTION } from "$/components/app/constants"; import { CreateBeatmapForm } from "$/components/app/forms"; -import { useSetupContext } from "$/components/context"; +import { useToaster } from "$/components/context"; import { Interleave } from "$/components/ui/atoms"; import { AlertDialogProvider, Button, Collapsible, Dialog, Heading, RouterLink, Stat, useAppForm } from "$/components/ui/compositions"; import { addColorScheme, copyBeatmap, removeBeatmap, updateBeatmap } from "$/store/actions"; @@ -40,10 +40,10 @@ function UpdateBeatmapForm({ bid }: Props) { const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); - const { toaster } = useSetupContext(); + const navigate = useNavigate(); + const toaster = useToaster(); const dispatch = useAppDispatch(); - const navigate = useNavigate(); const bpm = useAppSelector((state) => selectBpm(state, sid)); const beatmaps = useAppSelector((state) => selectBeatmaps(state, sid)); const lightshowIds = useAppSelector((state) => selectLightshowIds(state, sid)); @@ -86,7 +86,7 @@ function UpdateBeatmapForm({ bid }: Props) { formApi.reset(value); } catch (error) { - toaster?.error({ description: `Could not update beatmap: ${error instanceof Error ? error.message : "See console for more information."}` }); + toaster.error({ description: `Could not update beatmap: ${error instanceof Error ? error.message : "See console for more information."}` }); return console.error(error); } }, @@ -109,7 +109,7 @@ function UpdateBeatmapForm({ bid }: Props) { // Don't let the user delete the last difficulty! const remainingDifficultyIds = Object.keys(mutableDifficultiesCopy); if (remainingDifficultyIds.length === 0) { - return toaster?.error({ + return toaster.error({ id: "last-difficulty", description: "Sorry, you cannot delete the only remaining difficulty! Please create another difficulty first.", }); diff --git a/src/components/app/forms/update-song.tsx b/src/components/app/forms/update-song.tsx index fd4b6a31..6a8a3cfb 100644 --- a/src/components/app/forms/update-song.tsx +++ b/src/components/app/forms/update-song.tsx @@ -5,11 +5,11 @@ import { endsWith, type GenericSchema, gtValue, minValue, number, object, pipe, import { COVER_ART_FILE_ACCEPT_TYPE, ENVIRONMENT_COLLECTION, SONG_FILE_ACCEPT_TYPE } from "$/components/app/constants"; import { useLocalFileMutation, useLocalFileQuery } from "$/components/app/hooks/local-file.hooks"; -import { useSetupContext } from "$/components/context"; +import { useToaster } from "$/components/context"; import { Audio, Field, FileUpload, useAppForm } from "$/components/ui/compositions"; import { remuxImageToSquare } from "$/helpers/file.helpers"; import { BeatmapFilestore } from "$/services/file.service"; -import { updateSong } from "$/store/actions"; +import { loadSongFile, updateSong } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectSongById, selectSongVolume } from "$/store/selectors"; @@ -39,7 +39,7 @@ const SCHEMA = object({ function UpdateSongForm() { const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); - const { filestore, toaster } = useSetupContext(); + const toaster = useToaster(); const dispatch = useAppDispatch(); const song = useAppSelector((state) => selectSongById(state, sid)); @@ -56,12 +56,14 @@ function UpdateSongForm() { const { mutate: handleAcceptSongFile } = useLocalFileMutation(BeatmapFilestore.resolveFilename(sid, "song", {}), { onSuccess: () => { - toaster?.success({ id: "song-file-accepted", description: "Successfully updated song file!" }); + toaster.success({ id: "song-file-accepted", description: "Successfully updated song file!" }); + // reload the song file to reconcile the redux store + dispatch(loadSongFile({ songId: sid })); }, }); const { mutate: handleAcceptCoverArtFile } = useLocalFileMutation(BeatmapFilestore.resolveFilename(sid, "cover", {}), { onSuccess: () => { - toaster?.success({ id: "cover-art-file-accepted", description: "Successfully updated cover art file!" }); + toaster.success({ id: "cover-art-file-accepted", description: "Successfully updated cover art file!" }); }, }); @@ -85,24 +87,10 @@ function UpdateSongForm() { }, onSubmit: async ({ value, formApi }) => { try { - const newSongObject = { ...song, ...value }; - - if (acceptedCoverArtFile) { - const { filename: coverArtFilename } = await filestore.saveCoverArtFile(sid, acceptedCoverArtFile[0]); - newSongObject.coverArtFilename = coverArtFilename; - } - - if (acceptedSongFile) { - const { filename: songFilename } = await filestore.saveSongFile(sid, acceptedSongFile[0]); - newSongObject.songFilename = songFilename; - } - - // Update our redux state - dispatch(updateSong({ songId: sid, songFile: acceptedSongFile?.[0], changes: newSongObject })); - + dispatch(updateSong({ songId: sid, changes: value })); formApi.reset(value); } catch (error) { - toaster?.error({ description: `Could not update song: ${error instanceof Error ? error.message : "See console for more information."}` }); + toaster.error({ description: `Could not update song: ${error instanceof Error ? error.message : "See console for more information."}` }); return console.error(error); } }, diff --git a/src/components/app/hooks/local-file.hooks.ts b/src/components/app/hooks/local-file.hooks.ts index 731e3dcb..96688d0e 100644 --- a/src/components/app/hooks/local-file.hooks.ts +++ b/src/components/app/hooks/local-file.hooks.ts @@ -1,12 +1,12 @@ import { type UseQueryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useSetupContext } from "$/components/context"; +import { useBeatmapFilestore } from "$/components/context"; interface UseLocalFileQueryOptions extends Omit, "queryFn"> { transformFile?: (file: File) => Promise | T; } export function useLocalFileQuery(filename: string, { ...rest }: UseLocalFileQueryOptions) { - const { filestore } = useSetupContext(); + const filestore = useBeatmapFilestore(); return useQuery({ ...rest, @@ -21,9 +21,8 @@ export function useLocalFileQuery(filename: string, { ...rest }: UseLo } export function useLocalFileMutation(filename: string, options: { onSuccess: () => void }) { - const { filestore } = useSetupContext(); - const client = useQueryClient(); + const filestore = useBeatmapFilestore(); return useMutation({ mutationFn: async (file: File) => { diff --git a/src/components/app/layouts/event-grid/machine.ts b/src/components/app/layouts/event-grid/machine.ts index 82cedc23..5c6c9cdb 100644 --- a/src/components/app/layouts/event-grid/machine.ts +++ b/src/components/app/layouts/event-grid/machine.ts @@ -1,3 +1,4 @@ +import type { EntityId } from "@reduxjs/toolkit"; import { shuffle } from "@std/random/shuffle"; import { createMachine, type MachineSchema, type Params, type Service } from "@zag-js/core"; import { normalizeProps } from "@zag-js/react"; @@ -39,6 +40,7 @@ export interface EventGridSchema extends MachineSchema { refs: { mouseDownAt: { x: number; y: number; button: number } | null; hoveredTrackId: number | null; + lastPlacedId: string | null; norm: number; }; computed: { @@ -84,6 +86,7 @@ export const machine = createMachine({ return { mouseDownAt: null, hoveredTrackId: null, + lastPlacedId: null, norm: 0, }; }, @@ -195,13 +198,14 @@ export const machine = createMachine({ }); export interface IEventPlacementActions { - onCreate: (time: number, norm: number) => App.IWrapEditorObject; - onPlace: (data: App.IWrapEditorObject, isBulk?: boolean) => void; - onDelete: (data: App.IWrapEditorObject, isBulk?: boolean) => void; - onSelect: (data: App.IWrapEditorObject) => void; - onDeselect: (data: App.IWrapEditorObject) => void; - onPick?: (data: App.IWrapEditorObject) => void; - onWheel?: (data: App.IWrapEditorObject, delta: number) => void; + selectId: (data: T) => EntityId; + onCreate: (time: number, norm: number) => T; + onPlace: (data: T, id: EntityId) => void; + onDelete: (data: T, id: EntityId) => void; + onSelect: (data: T, id: EntityId) => void; + onDeselect: (data: T, id: EntityId) => void; + onPick?: (data: T, id: EntityId) => void; + onWheel?: (data: T, id: EntityId, delta: number) => void; } export function connect({ scope, send, prop, context, refs, computed }: Service, normalize = normalizeProps) { @@ -300,17 +304,24 @@ export function connect({ scope, send, prop, context, refs, computed }: Service< if (isPlaceMode && event.button === 0) { const data = actions.onCreate(context.get("pointer"), refs.get("norm")); - return actions.onPlace?.(data, false); + const id = actions.selectId(data); + refs.set("lastPlacedId", id.toString()); + return actions.onPlace?.(data, id); } }, onPointerUp: () => { refs.set("norm", 0); refs.set("hoveredTrackId", null); + refs.set("lastPlacedId", null); }, onPointerMove: () => { if (isPlaceMode && activeButton === 0 && isHoveringTrack(trackId)) { const data = actions.onCreate(context.get("pointer"), refs.get("norm")); - return actions.onPlace?.(data, true); + const id = actions.selectId(data); + if (id !== refs.get("lastPlacedId")) { + refs.set("lastPlacedId", id.toString()); + return actions.onPlace?.(data, id); + } } }, onPointerOver: () => { @@ -331,28 +342,34 @@ export function connect({ scope, send, prop, context, refs, computed }: Service< style: { ...style, transform: `translateX(${centeredOffset}px)` }, onContextMenu: (e) => e.preventDefault(), onPointerDown: (ev) => { + const id = actions.selectId(data); + switch (ev.button) { case 0: { - return data.selected ? actions.onDeselect?.(data) : actions.onSelect?.(data); + return data.selected ? actions.onDeselect?.(data, id) : actions.onSelect?.(data, id); } case 1: { ev.preventDefault(); - return actions.onPick?.(data); + return actions.onPick?.(data, id); } case 2: { - return actions.onDelete?.(data, false); + return actions.onDelete?.(data, id); } } }, onPointerOver: () => { + const id = actions.selectId(data); + if (activeButton === 2) { - actions.onDelete?.(data, true); + actions.onDelete?.(data, id); } }, onWheel: (event: React.WheelEvent) => { + const id = actions.selectId(data); + if (event.altKey) { const delta = event.deltaY > 0 ? -1 : 1; - actions.onWheel?.(data, delta); + actions.onWheel?.(data, id, delta); } }, }); diff --git a/src/components/app/layouts/visualizer/markers.tsx b/src/components/app/layouts/visualizer/markers.tsx index 5304960b..8568ebd0 100644 --- a/src/components/app/layouts/visualizer/markers.tsx +++ b/src/components/app/layouts/visualizer/markers.tsx @@ -19,9 +19,8 @@ function AudioVisualizerMarkers({ markers, duration, offset, timeProcessor, onMa const reversed = markers.sort((a, b) => b.time - a.time); return reversed.map((marker) => { - const realTimeInSeconds = timeProcessor.toRealTime(marker.time); - const realTimeMs = realTimeInSeconds * 1000 + offset; - const offsetPercentage = (realTimeMs / duration) * 100; + const realTimeInSeconds = timeProcessor.toRealTime(marker.time) + offset; + const offsetPercentage = (realTimeInSeconds / duration) * 100; return { ...marker, offset: offsetPercentage }; }); }, [timeProcessor, markers, duration, offset]); diff --git a/src/components/app/templates/download/index.tsx b/src/components/app/templates/download/index.tsx index c8f7747f..b4a2b994 100644 --- a/src/components/app/templates/download/index.tsx +++ b/src/components/app/templates/download/index.tsx @@ -21,7 +21,7 @@ function Download() { Download Map Unfortunately, the demo map is not available for download.}> - dispatch(downloadMapFiles({ songId: sid, ...options }))} /> + dispatch(downloadMapFiles({ songId: sid, options }))} /> ); diff --git a/src/components/app/templates/editor/action-panel-groups/default.tsx b/src/components/app/templates/editor/action-panel-groups/default.tsx index e8b7289f..f57eebd3 100644 --- a/src/components/app/templates/editor/action-panel-groups/default.tsx +++ b/src/components/app/templates/editor/action-panel-groups/default.tsx @@ -1,4 +1,4 @@ -import { useParams, useRouteContext } from "@tanstack/react-router"; +import { useParams } from "@tanstack/react-router"; import type { MouseEventHandler } from "react"; import { createJumpToBeatPrompt, createQuickSelectPrompt } from "$/components/app/constants"; @@ -14,7 +14,6 @@ interface Props { } function DefaultActionPanelGroup({ handleGridConfigClick }: Props) { const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); - const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const canUndo = useAppSelector(selectObjectsCanUndo); @@ -34,36 +33,36 @@ function DefaultActionPanelGroup({ handleGridConfigClick }: Props) { if (typeof endBeat !== "number") { endBeat = Number.POSITIVE_INFINITY; } - dispatch(selectAllEntitiesInRange({ songId: sid, view: view, startBeat, endBeat })); - dispatch(jumpToBeat({ songId: sid, value: startBeat, pauseTrack: true })); + dispatch(selectAllEntitiesInRange({ startBeat, endBeat })); + dispatch(jumpToBeat({ value: startBeat })); }, }), ); const { trigger: triggerJumpToBeat } = usePrompt( createJumpToBeatPrompt({ render: ({ form }) => {(ctx) => }, - onSubmit: ({ value: { beatNum } }) => dispatch(jumpToBeat({ songId: sid, pauseTrack: true, value: beatNum })), + onSubmit: ({ value: { beatNum } }) => dispatch(jumpToBeat({ value: beatNum })), }), ); return ( - - - - - diff --git a/src/components/app/templates/editor/action-panel-groups/direction.tsx b/src/components/app/templates/editor/action-panel-groups/direction.tsx index 2775cf04..c22d1faa 100644 --- a/src/components/app/templates/editor/action-panel-groups/direction.tsx +++ b/src/components/app/templates/editor/action-panel-groups/direction.tsx @@ -20,31 +20,31 @@ function NoteDirectionActionPanelGroup() { return ( - - - - - - - - - diff --git a/src/components/app/templates/editor/action-panel-groups/grid-presets.tsx b/src/components/app/templates/editor/action-panel-groups/grid-presets.tsx index 65549731..c46e8e69 100644 --- a/src/components/app/templates/editor/action-panel-groups/grid-presets.tsx +++ b/src/components/app/templates/editor/action-panel-groups/grid-presets.tsx @@ -27,7 +27,7 @@ function GridPresetsActionPanelGroup() { "Load Grid Preset"}> - diff --git a/src/components/app/templates/editor/action-panel-groups/grid.tsx b/src/components/app/templates/editor/action-panel-groups/grid.tsx index 6d253ed1..dda134f3 100644 --- a/src/components/app/templates/editor/action-panel-groups/grid.tsx +++ b/src/components/app/templates/editor/action-panel-groups/grid.tsx @@ -25,7 +25,7 @@ function GridActionPanelGroup({ finishTweakingGrid }: Props) { validate: object({ slot: pipe(string(), nonEmpty()) }), render: ({ form }) => {(ctx) => }, onSubmit: ({ value }) => { - return dispatch(saveGridPreset({ songId: sid, presetSlot: value.slot })); + return dispatch(saveGridPreset({ slot: value.slot })); }, }); diff --git a/src/components/app/templates/editor/action-panel-groups/selection.tsx b/src/components/app/templates/editor/action-panel-groups/selection.tsx index 3b2eef1f..e35022f1 100644 --- a/src/components/app/templates/editor/action-panel-groups/selection.tsx +++ b/src/components/app/templates/editor/action-panel-groups/selection.tsx @@ -1,13 +1,13 @@ -import { useParams, useRouteContext } from "@tanstack/react-router"; +import { useRouteContext } from "@tanstack/react-router"; import { ArrowDownToLineIcon, ArrowUpToLineIcon, FlipHorizontal2Icon, FlipVertical2Icon } from "lucide-react"; import { type MouseEventHandler, useMemo } from "react"; import { ActionPanelGroup } from "$/components/app/layouts"; import { Show } from "$/components/ui/atoms"; import { Button, Tooltip } from "$/components/ui/compositions"; -import { deselectAllEntities, deselectAllEntitiesOfType, mirrorSelection, nudgeSelection } from "$/store/actions"; +import { deselectAllEntities, deselectAllObjectsOfType, mirrorSelection, nudgeSelection } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectGridSize, selectSelectedBeatmapEntities } from "$/store/selectors"; +import { selectSelectedBeatmapEntities } from "$/store/selectors"; import { ObjectType } from "$/types"; import { StrikethroughOnHover, Text } from "$:styled-system/jsx"; @@ -30,44 +30,42 @@ function SelectionCount({ num, label, onClick }: CountProps) { } function SelectionActionPanelGroup() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const selectedEntities = useAppSelector((state) => selectSelectedBeatmapEntities(state, view)); - const grid = useAppSelector((state) => selectGridSize(state, sid)); return ( - {(items) => dispatch(deselectAllEntitiesOfType({ itemType: ObjectType.NOTE }))} />} - {(items) => dispatch(deselectAllEntitiesOfType({ itemType: ObjectType.BOMB }))} />} - {(items) => dispatch(deselectAllEntitiesOfType({ itemType: ObjectType.OBSTACLE }))} />} + {(items) => dispatch(deselectAllObjectsOfType({ itemType: ObjectType.NOTE }))} />} + {(items) => dispatch(deselectAllObjectsOfType({ itemType: ObjectType.BOMB }))} />} + {(items) => dispatch(deselectAllObjectsOfType({ itemType: ObjectType.OBSTACLE }))} />} - "Mirror selection horizontally"}> - "Mirror selection vertically"}> - "Nudge selection forwards"}> - "Nudge selection backwards"}> - diff --git a/src/components/app/templates/editor/action-panel-groups/tool.tsx b/src/components/app/templates/editor/action-panel-groups/tool.tsx index 8ccd1bd3..fd914415 100644 --- a/src/components/app/templates/editor/action-panel-groups/tool.tsx +++ b/src/components/app/templates/editor/action-panel-groups/tool.tsx @@ -20,22 +20,22 @@ function NoteToolActionPanelGroup() { "Left Color Note"}> - "Right Color Note"}> - "Bomb Note"}> - "Obstacle"}> - diff --git a/src/components/app/templates/editor/navigation-controls.tsx b/src/components/app/templates/editor/navigation-controls.tsx index 8f0bacb1..8602aa48 100644 --- a/src/components/app/templates/editor/navigation-controls.tsx +++ b/src/components/app/templates/editor/navigation-controls.tsx @@ -6,7 +6,7 @@ import { NavigationPanel } from "$/components/app/layouts"; import { Button, Select, Stat } from "$/components/ui/compositions"; import { SNAPPING_INCREMENTS } from "$/constants"; import { formatCursorPosition, formatCursorPositionInBeats } from "$/helpers/audio.helpers"; -import { jumpToEnd, jumpToStart, seekBackwards, seekForwards, togglePlayback, updateSnap } from "$/store/actions"; +import { jumpBackwards, jumpForwards, jumpToEnd, jumpToStart, togglePlayback, updateSnap } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectCursorPosition, selectCursorPositionInBeats, selectLoading, selectPlaying, selectSnap } from "$/store/selectors"; import { getMetaKeyLabel, roundToNearest } from "$/utils"; @@ -45,22 +45,22 @@ function EditorNavigationControls() { return ( - dispatch(updateSnap(Number.parseFloat(ev.value[0])))} /> - - - - - diff --git a/src/components/app/templates/editor/status-bar.tsx b/src/components/app/templates/editor/status-bar.tsx index 3619e14e..96a61902 100644 --- a/src/components/app/templates/editor/status-bar.tsx +++ b/src/components/app/templates/editor/status-bar.tsx @@ -3,9 +3,9 @@ import { BellIcon, BellOffIcon, BoxIcon, CuboidIcon, EyeClosedIcon, EyeIcon, Fas import { StatusBar } from "$/components/app/layouts"; import { Show } from "$/components/ui/atoms"; -import { updateBeatDepth, updateEventsEditorPreview, updateEventsEditorTrackHeight, updateEventsEditorTrackOpacity, updatePlaybackRate, updateSongVolume, updateTickVolume } from "$/store/actions"; +import { updateEventsEditorPreview, updateEventsEditorTrackHeight, updateEventsEditorTrackOpacity, updatePlaybackRate, updateSongVolume, updateTickVolume, updateTrackScale } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectBeatDepth, selectEventsEditorPreview, selectEventsEditorTrackHeight, selectEventsEditorTrackOpacity, selectedTotalBombNotes, selectLoading, selectNoteDensity, selectPlaybackRate, selectSongVolume, selectTickVolume, selectTotalColorNotes, selectTotalObstacles } from "$/store/selectors"; +import { selectBeatDepth, selectEventsEditorPreview, selectEventsEditorTrackHeight, selectEventsEditorTrackOpacity, selectLoading, selectNoteDensity, selectPlaybackRate, selectSongVolume, selectTickVolume, selectTotalBombNotes, selectTotalColorNotes, selectTotalObstacles } from "$/store/selectors"; import { View } from "$/types"; import { pluralize } from "$/utils"; import { styled } from "$:styled-system/jsx"; @@ -20,7 +20,7 @@ function EditorStatusBar() { const songVolume = useAppSelector(selectSongVolume); const tickVolume = useAppSelector(selectTickVolume); const numOfBlocks = useAppSelector(selectTotalColorNotes); - const numOfMines = useAppSelector(selectedTotalBombNotes); + const numOfMines = useAppSelector(selectTotalBombNotes); const numOfObstacles = useAppSelector(selectTotalObstacles); const showLightingPreview = useAppSelector(selectEventsEditorPreview); const rowHeight = useAppSelector(selectEventsEditorTrackHeight); @@ -45,21 +45,21 @@ function EditorStatusBar() { {noteDensity.toFixed(2)} - dispatch(updateBeatDepth({ value: details.value[0] }))} /> - dispatch(updateTickVolume({ value: details.value[0] }))} /> + dispatch(updateTrackScale(details.value[0]))} /> + dispatch(updateTickVolume(details.value[0]))} /> - dispatch(updateEventsEditorPreview())} /> - dispatch(updateEventsEditorTrackHeight({ newHeight: details.value[0] }))} /> - dispatch(updateEventsEditorTrackOpacity({ newOpacity: details.value[0] }))} /> + dispatch(updateEventsEditorPreview(details.checked))} /> + dispatch(updateEventsEditorTrackHeight(details.value[0]))} /> + dispatch(updateEventsEditorTrackOpacity(details.value[0]))} /> - dispatch(updateTickVolume({ value: details.value[0] }))} /> + dispatch(updateTickVolume(details.value[0]))} /> - dispatch(updatePlaybackRate({ value: details.value[0] }))} /> - dispatch(updateSongVolume({ value: details.value[0] }))} /> + dispatch(updatePlaybackRate(details.value[0]))} /> + dispatch(updateSongVolume(details.value[0]))} /> ); diff --git a/src/components/app/templates/editor/visualizer.tsx b/src/components/app/templates/editor/visualizer.tsx index 01075cfa..eb24ebaa 100644 --- a/src/components/app/templates/editor/visualizer.tsx +++ b/src/components/app/templates/editor/visualizer.tsx @@ -27,9 +27,9 @@ function EditorAudioVisualizer() { const handleVisualizerClick = useCallback( (_: MouseEvent, offset: number) => { - dispatch(jumpToTime({ songId: sid, value: offset })); + dispatch(jumpToTime({ value: offset })); }, - [dispatch, sid], + [dispatch], ); const handleMarkerClick = useCallback( @@ -40,16 +40,16 @@ function EditorAudioVisualizer() { return dispatch(removeBookmark({ beatNum: time })); } default: { - return dispatch(jumpToBeat({ songId: sid, value: time })); + return dispatch(jumpToBeat({ value: time })); } } }, - [dispatch, sid], + [dispatch], ); return ( - + {(ref) => } {duration !== null && ( diff --git a/src/components/app/templates/events/basic-track.tsx b/src/components/app/templates/events/basic-track.tsx index 3f1ebb41..cfa2d311 100644 --- a/src/components/app/templates/events/basic-track.tsx +++ b/src/components/app/templates/events/basic-track.tsx @@ -7,21 +7,21 @@ import { EventGrid } from "$/components/app/layouts"; import { For } from "$/components/ui/atoms"; import { resolveColorForItem } from "$/helpers/colors.helpers"; import { deserializeBasicEventValue, isBasicLightEvent, isBasicValueEvent, resolveBasicEventColor, resolveBasicEventEffect, resolveEventId, serializeBasicEventValue } from "$/helpers/events.helpers"; -import { addBasicEvent, bulkAddBasicEvent, bulkRemoveEvent, deselectEvent, removeEvent, selectEvent, updateBasicEvent } from "$/store/actions"; +import { addBasicEvent, deselectBasicEvent, removeBasicEvent, selectBasicEvent, updateBasicEvent } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectAllBasicEventsForTrack, selectAllBoostEvents, + selectColorBoostAtBeat, selectColorScheme, selectCurrentLightStateForTrack, selectEditorOffsetInBeats, selectEnvironment, - selectEventEditorStartAndEndBeat, selectEventsEditorColor, selectEventsEditorMirrorLock, + selectEventsEditorStartAndEndBeat, selectEventsEditorTool, selectEventTracksForEnvironment, - selectToggleAtBeat, } from "$/store/selectors"; import { type App, BasicEventEffect, EventColor } from "$/types"; import { clamp, cycle, isColorDark, normalize, roundToNearest } from "$/utils"; @@ -63,7 +63,7 @@ const BasicEvent = memo(function BasicEvent({ data, actions }: { data: App.IBasi const api = EventGrid.useContext(); - const isEventBoosted = useAppSelector((state) => selectToggleAtBeat(state, { trackId: 5, beforeBeat: data.time + 0.001 })); + const isEventBoosted = useAppSelector((state) => selectColorBoostAtBeat(state, { beforeBeat: data.time + 0.001 })); const resolveEventStyle = useCallback( (data: IWrapBasicEvent) => { @@ -91,7 +91,7 @@ function BasicEventTrack({ trackId, ...rest }: Assign selectEventEditorStartAndEndBeat(state, sid)); + const { startBeat, endBeat } = useAppSelector((state) => selectEventsEditorStartAndEndBeat(state, sid)); const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); const environment = useAppSelector((state) => selectEnvironment(state, sid, bid)); const basicEvents = useAppSelector((state) => selectAllBasicEventsForTrack(state, trackId)); @@ -147,12 +147,13 @@ function BasicEventTrack({ trackId, ...rest }: Assign>(() => { return { + selectId: resolveEventId, onCreate: resolveEventData, - onPlace: (data, isBulk) => dispatch((isBulk ? bulkAddBasicEvent : addBasicEvent)({ query: data, data: data, environment, areLasersLocked })), - onDelete: (data, isBulk) => dispatch((isBulk ? bulkRemoveEvent : removeEvent)({ query: data, environment, areLasersLocked })), - onSelect: (data) => dispatch(selectEvent({ query: data, environment, areLasersLocked })), - onDeselect: (data) => dispatch(deselectEvent({ query: data, environment, areLasersLocked })), - onPick: (data) => { + onPlace: (data, _) => dispatch(addBasicEvent({ data: data, environment, areLasersLocked })), + onDelete: (_, id) => dispatch(removeBasicEvent({ id, environment, areLasersLocked })), + onSelect: (_, id) => dispatch(selectBasicEvent({ id, environment, areLasersLocked })), + onDeselect: (_, id) => dispatch(deselectBasicEvent({ id, environment, areLasersLocked })), + onPick: (data, id) => { const MIRRORABLE_COLORS = Object.values(EventColor).slice(0, -1); switch (tracks[trackId].type) { @@ -160,14 +161,14 @@ function BasicEventTrack({ trackId, ...rest }: Assign { + onWheel: (data, id, delta) => { const step = (x: number, step: number) => { return x + step / delta; }; @@ -175,27 +176,27 @@ function BasicEventTrack({ trackId, ...rest }: Assign selectEventEditorStartAndEndBeat(state, sid)); + const { startBeat, endBeat } = useAppSelector((state) => selectEventsEditorStartAndEndBeat(state, sid)); const environment = useAppSelector((state) => selectEnvironment(state, sid, bid)); const boostEvents = useAppSelector((state) => selectAllBoostEvents(state)); @@ -57,12 +57,13 @@ function BoostEventTrack({ trackId, ...rest }: Assign>(() => { return { + selectId: resolveEventId, onCreate: resolveEventData, - onPlace: (data, isBulk) => dispatch((isBulk ? bulkAddBoostEvent : addBoostEvent)({ query: data, data: data, environment, areLasersLocked })), - onDelete: (data, isBulk) => dispatch((isBulk ? bulkRemoveEvent : removeEvent)({ query: data, environment, areLasersLocked })), - onSelect: (data) => dispatch(selectEvent({ query: data, environment, areLasersLocked })), - onDeselect: (data) => dispatch(deselectEvent({ query: data, environment, areLasersLocked })), - onWheel: (data, delta) => dispatch(updateBoostEvent({ query: data, environment, areLasersLocked, changes: { toggle: delta > 0 } })), + onPlace: (data) => dispatch(addBoostEvent({ data: data, environment, areLasersLocked })), + onDelete: (_, id) => dispatch(removeBoostEvent({ id, environment, areLasersLocked })), + onSelect: (_, id) => dispatch(selectBoostEvent({ id, environment, areLasersLocked })), + onDeselect: (_, id) => dispatch(deselectBoostEvent({ id, environment, areLasersLocked })), + onWheel: (_, id, delta) => dispatch(updateBoostEvent({ id, environment, areLasersLocked, changes: { toggle: delta > 0 } })), }; }, [dispatch, resolveEventData, environment, areLasersLocked]); diff --git a/src/components/app/templates/events/controls.tsx b/src/components/app/templates/events/controls.tsx index 6d41b0c3..65b2350a 100644 --- a/src/components/app/templates/events/controls.tsx +++ b/src/components/app/templates/events/controls.tsx @@ -7,7 +7,7 @@ import { EventEffectIcon } from "$/components/icons"; import { Button, Field, Toggle, ToggleGroup, Tooltip } from "$/components/ui/compositions"; import { ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN } from "$/constants"; import type { ColorResolverOptions } from "$/helpers/colors.helpers"; -import { decrementEventsEditorZoom, incrementEventsEditorZoom, updateEventsEditorColor, updateEventsEditorEditMode, updateEventsEditorMirrorLock, updateEventsEditorTool, updateEventsEditorWindowLock } from "$/store/actions"; +import { decrementEventsEditorZoomLevel, incrementEventsEditorZoomLevel, updateEventsEditorColor, updateEventsEditorEditMode, updateEventsEditorMirrorLock, updateEventsEditorTool, updateEventsEditorWindowLock } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectColorScheme, selectEventsEditorColor, selectEventsEditorEditMode, selectEventsEditorMirrorLock, selectEventsEditorTool, selectEventsEditorWindowLock, selectEventsEditorZoomLevel } from "$/store/selectors"; import { EventColor, EventEditMode, EventTool } from "$/types"; @@ -62,23 +62,23 @@ function EventGridControls({ ...rest }: ComponentProps) { - details.value.length > 0 && dispatch(updateEventsEditorEditMode({ editMode: details.value[0] as EventEditMode }))} /> + details.value.length > 0 && dispatch(updateEventsEditorEditMode(details.value[0] as EventEditMode))} /> - details.value.length > 0 && dispatch(updateEventsEditorColor({ color: details.value[0] as EventColor }))} /> + details.value.length > 0 && dispatch(updateEventsEditorColor(details.value[0] as EventColor))} /> - details.value.length > 0 && dispatch(updateEventsEditorTool({ tool: details.value[0] as EventTool }))} /> + details.value.length > 0 && dispatch(updateEventsEditorTool(details.value[0] as EventTool))} /> "Loop playback within the current event window"}> - dispatch(updateEventsEditorWindowLock())}> + dispatch(updateEventsEditorWindowLock(pressed))}> "Clone event placements for symmetrical tracks"}> - dispatch(updateEventsEditorMirrorLock())}> + dispatch(updateEventsEditorMirrorLock(pressed))}> @@ -88,10 +88,10 @@ function EventGridControls({ ...rest }: ComponentProps) { - - diff --git a/src/components/app/templates/events/grid.tsx b/src/components/app/templates/events/grid.tsx index 07bdf95f..79ef5541 100644 --- a/src/components/app/templates/events/grid.tsx +++ b/src/components/app/templates/events/grid.tsx @@ -18,10 +18,10 @@ import { selectDurationInBeats, selectEditorOffsetInBeats, selectEnvironment, - selectEventEditorStartAndEndBeat, selectEventsEditorCursor, selectEventsEditorEditMode, selectEventsEditorMirrorLock, + selectEventsEditorStartAndEndBeat, selectEventsEditorTrackHeight, selectEventTracksForEnvironment, selectLoading, @@ -43,7 +43,7 @@ function EventGridEditor({ ...rest }: Omit const snapTo = useAppSelector(selectSnap); const rowHeight = useAppSelector(selectEventsEditorTrackHeight); const areLasersLocked = useAppSelector(selectEventsEditorMirrorLock); - const { startBeat, numOfBeatsToShow } = useAppSelector((state) => selectEventEditorStartAndEndBeat(state, sid)); + const { startBeat, endBeat, numOfBeatsToShow } = useAppSelector((state) => selectEventsEditorStartAndEndBeat(state, sid)); const tracks = useAppSelector((state) => selectEventTracksForEnvironment(state, sid, bid)); const environment = useAppSelector((state) => selectEnvironment(state, sid, bid)); const cursorPositionInBeats = useAppSelector((state) => selectCursorPositionInBeats(state, sid)); @@ -78,14 +78,14 @@ function EventGridEditor({ ...rest }: Omit return dispatch(updateEventsEditorCursor({ selectedBeat })); }, onTrackHeightChange: ({ height: newHeight }) => { - return dispatch(updateEventsEditorTrackHeight({ newHeight })); + return dispatch(updateEventsEditorTrackHeight(newHeight)); }, onSelectionCommit: ({ selectionBoxInBeats }) => { const filteredTracks = allFilteredTracks.reduce((acc: ITrackDefinitions<{ id: number }>, track) => { acc[track.id] = track; return acc; }, {}); - return dispatch(drawEventSelectionBox({ songId: sid, tracks: filteredTracks, selectionBoxInBeats: selectionBoxInBeats })); + return dispatch(drawEventSelectionBox({ window: { startBeat, endBeat }, tracks: filteredTracks, selectionBoxInBeats: selectionBoxInBeats })); }, }); @@ -177,7 +177,7 @@ function EventGridEditor({ ...rest }: Omit Track Visibility - dispatch(jumpToBeat({ songId: sid, value: beat }))} /> + dispatch(jumpToBeat({ value: beat }))} /> ev.stopPropagation()}> diff --git a/src/components/app/templates/home/first-time.tsx b/src/components/app/templates/home/first-time.tsx index f74e460f..082b075c 100644 --- a/src/components/app/templates/home/first-time.tsx +++ b/src/components/app/templates/home/first-time.tsx @@ -1,10 +1,11 @@ +import { useNavigate } from "@tanstack/react-router"; import { CirclePlusIcon, DownloadIcon, PackageOpenIcon } from "lucide-react"; import { useCallback, useState } from "react"; -import { heroVideo } from "$/assets"; +import { demoFileUrl, heroVideo } from "$/assets"; import { CreateMapForm, ImportMapForm } from "$/components/app/forms"; import { Button, Dialog, Heading } from "$/components/ui/compositions"; -import { addSongFromFile, loadDemoMap } from "$/store/actions"; +import { addSongFromFile } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectSongIds } from "$/store/selectors"; import { styled, VStack, Wrap } from "$:styled-system/jsx"; @@ -14,12 +15,17 @@ function FirstTimeHome() { const dispatch = useAppDispatch(); const songIds = useAppSelector(selectSongIds); + const navigate = useNavigate(); + const [isLoadingDemo, setIsLoadingDemo] = useState(false); - const handleDemoClick = useCallback(() => { + const handleDemoClick = useCallback(async () => { setIsLoadingDemo(true); - dispatch(loadDemoMap()); - }, [dispatch]); + const file = await fetch(demoFileUrl).then((response) => response.blob()); + const { songId, beatmapId } = await dispatch(addSongFromFile({ file, options: { readonly: true } })).unwrap(); + navigate({ to: "/edit/$sid/$bid/notes", params: { sid: songId.toString(), bid: beatmapId.toString() } }); + setIsLoadingDemo(false); + }, [dispatch, navigate]); return ( @@ -43,7 +49,6 @@ function FirstTimeHome() { - {/** biome-ignore lint/suspicious/useIterableCallbackReturn: doesn't matter */} files.forEach((file) => void dispatch(addSongFromFile({ file, options: { currentSongIds: songIds } })))} />}>