From a73a11e354d6395a41429cd44bbf13e31377be5c Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 14 Jul 2026 18:26:41 -0500 Subject: [PATCH 01/27] refactor storage middleware to strategy-based enhancers --- src/store/actions.ts | 18 +- src/store/enhancers/storage.enhancer.ts | 152 +++++++++ src/store/features/editor/beatmap.slice.ts | 19 +- src/store/features/editor/lightshow.slice.ts | 27 +- src/store/features/navigation.slice.ts | 11 +- src/store/features/songs.slice.ts | 9 +- src/store/features/user.slice.ts | 20 +- src/store/middleware/audio.middleware.ts | 12 +- src/store/middleware/index.ts | 6 +- src/store/middleware/storage.middleware.ts | 257 --------------- src/store/setup.ts | 310 ++++++++++++------- 11 files changed, 394 insertions(+), 447 deletions(-) create mode 100644 src/store/enhancers/storage.enhancer.ts delete mode 100644 src/store/middleware/storage.middleware.ts diff --git a/src/store/actions.ts b/src/store/actions.ts index cc84bb03..0f828ac0 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -5,9 +5,8 @@ import { HIGHEST_PRECISION } from "$/constants"; import type { resolveEventId } from "$/helpers/events.helpers"; import type { resolveNoteId } from "$/helpers/notes.helpers"; import type { ExportMapArchiveOptions } from "$/services/packaging.service"; -import { type App, type BeatmapId, type IGrid, type IGridPresets, type ISelectionBoxInBeats, type Member, type ObjectSelectionMode, type ObjectTool, type ObjectType, type SongId, View } from "$/types"; +import { type App, type BeatmapId, type IGrid, type ISelectionBoxInBeats, type ObjectSelectionMode, type ObjectTool, type ObjectType, type SongId, View } from "$/types"; import { roundToNearest } from "$/utils"; -import { createEntityStorageActions, createStorageActions } from "./middleware/storage.middleware"; import { selectAllBasicEvents, selectAllBombNotes, @@ -25,7 +24,7 @@ import { selectPlaying, selectSnap, } from "./selectors"; -import type { RootState, SessionStorageObservers, UserStorageObservers } from "./setup"; +import type { RootState } from "./setup"; // biome-ignore-start assist/source/organizeImports: circular dependencies @@ -48,16 +47,11 @@ import visualizer from "./features/visualizer.slice"; export const { init } = global.actions; -export const { load: loadUser, save: saveUser, hydrate: hydrateUser } = createStorageActions("user"); -export const { load: loadSession, save: saveSession, hydrate: hydrateSession } = createStorageActions("session"); -export const { load: loadSongs, save: saveSongs, hydrate: hydrateSongs } = createEntityStorageActions("songs"); -export const { load: loadGridPresets, save: saveGridPresets, hydrate: hydrateGridPresets } = createEntityStorageActions>("grids"); - -export const rehydrate = createAction("@@STORAGE/rehydrate", (args: { songId: SongId; beatmapId: BeatmapId }) => { +export const rehydrate = createAction("rehydrate", (args: { songId: SongId; beatmapId: BeatmapId }) => { return { payload: { ...args } }; }); -export const { dismissPrompt, updateUsername, updateProcessingDelay, updateRenderScale, updateBloomEnabled, updateObstaclePlacementMode, updatePacerWait } = user.actions; +export const { updateNew, updateAnnouncements, dismissPrompt, updateUsername, updateProcessingDelay, updateRenderScale, updateBloomEnabled, updateObstaclePlacementMode, updatePacerWait } = user.actions; export const startLoadingMap = createAction("startLoadingMap", (args: { songId: SongId; beatmapId: BeatmapId }) => { return { payload: { ...args } }; @@ -84,6 +78,7 @@ export const leaveEditor = createAction("leaveEditor", (args: { songId: SongId; }); export const { + hydrate: hydrateSongs, addOne: addSong, addOneFromFile: addSongFromFile, updateOne: updateSong, @@ -133,7 +128,7 @@ export const { export const { reloadVisualizer, updateZoom: zoomVisualizer } = visualizer.actions; -export const { updateTool: updateNotesEditorTool, updateDirection: updateNotesEditorDirection, upsertGridPreset: saveGridPreset, removeGridPreset } = beatmap.actions; +export const { updateTool: updateNotesEditorTool, updateDirection: updateNotesEditorDirection, updateDefaultObstacleDuration: updateNotesEditorDefaultObstacleDuration, hydrateGridPresets, upsertGridPreset: saveGridPreset, removeGridPreset } = beatmap.actions; export const loadGridPreset = createAction("loadGridPreset", (args: { songId: SongId; grid: IGrid }) => { return { payload: { ...args } }; @@ -146,6 +141,7 @@ export const { updateCursor: updateEventsEditorCursor, updateTrackHeight: updateEventsEditorTrackHeight, updateTrackOpacity: updateEventsEditorTrackOpacity, + updateZoomLevel: updateEventsEditorZoomLevel, incrementZoom: incrementEventsEditorZoom, decrementZoom: decrementEventsEditorZoom, updatePreview: updateEventsEditorPreview, diff --git a/src/store/enhancers/storage.enhancer.ts b/src/store/enhancers/storage.enhancer.ts new file mode 100644 index 00000000..41c48579 --- /dev/null +++ b/src/store/enhancers/storage.enhancer.ts @@ -0,0 +1,152 @@ +import type { Store, StoreEnhancer, UnknownAction } from "@reduxjs/toolkit"; +import { distinct } from "@std/collections/distinct"; +import { createStorage, type Driver, type StorageValue } from "unstorage"; + +import type { MaybeDefined } from "$/types/vendor"; +import { rehydrate } from "../actions"; + +export interface StorageStrategy { + getKeys: (prevState: TState, nextState: TState) => string[]; + getValue: (state: TState, key: string) => TValue; + onHydrate: (store: Store, data: Record) => void; + asRaw?: boolean; +} + +export function createStorageEnhancer(driver: Driver, { getKeys, getValue, onHydrate, asRaw }: StorageStrategy): StoreEnhancer<{ hydrate: () => Promise }> { + const storage = createStorage({ driver }); + + function read(key: string): Promise { + return (asRaw ? storage.getItemRaw(key) : storage.getItem(key)) as Promise; + } + function write(key: string, value: T) { + if (value === null || value === undefined) { + storage.removeItem(key); + } else { + asRaw ? storage.setItemRaw(key, value as MaybeDefined) : storage.setItem(key, value); + } + } + + return (next) => (reducer, preloadedState) => { + const store = next(reducer, preloadedState); + + let isHydrating = false; + let lastState = store.getState() as TState; + + async function hydrate() { + if (isHydrating) return; + isHydrating = true; + + try { + const initialState = store.getState() as TState; + + const currentKeys = getKeys(initialState, initialState); + const storageKeys = currentKeys.length ? currentKeys : await storage.getKeys(); + + const dataToHydrate: Record = {}; + // for keys that have updated + const tasks = storageKeys.map(async (key) => { + const initialValue = getValue(initialState, key); + const storageValue = await read(key); + // for the initial hydration step, dispatch the observer's slice updater action + if (storageValue !== null && storageValue !== undefined) { + // if the stored value does not match the initial value, queue it for hydration + if (JSON.stringify(storageValue) !== JSON.stringify(initialValue)) { + dataToHydrate[key] = storageValue; + } + } else { + // seed storage using the initial state from the reducer + write(key, initialValue); + } + }); + // safely intercept and chain the upstream enhancer's hydrate logic if it exists + if (store && "hydrate" in store && typeof store.hydrate === "function") { + tasks.push(store.hydrate()); + } + // resolve any downstream hydration tasks + await Promise.all(tasks); + // trigger hydration step for entries that have observed changes between states + if (Object.keys(dataToHydrate).length > 0) { + onHydrate(store, dataToHydrate); + } + // when hydration is complete, set a fresh value to use for future comparison + lastState = store.getState() as TState; + } finally { + isHydrating = false; + } + } + + return { + ...store, + hydrate: hydrate, + dispatch: (action) => { + if (rehydrate.match(action)) { + // run the hydration step if we manually rehydrate the app + hydrate(); + return store.dispatch(action); + } + + const result = store.dispatch(action); + // don't start observing new changes until the state is initialized + if (isHydrating) return result; + + const nextState = store.getState() as TState; + // if the state hasn't changed, we don't need to go any further + if (nextState === lastState) return result; + + // aggregate keys across both states to determine what was changed + for (const key of getKeys(lastState, nextState)) { + const prevValue = getValue(lastState, key); + const nextValue = getValue(nextState, key); + // perform the update only when the observed value has actually changed + if (prevValue !== nextValue) { + write(key, nextValue); + } + } + + lastState = nextState; + return result; + }, + }; + }; +} + +interface IKeyValueStorageObserver { + selectValue: (s: TState) => TValue; + hydrateValue: (v: TValue) => UnknownAction; +} +export function createKeyValueStorageStrategy(observers: { [key in keyof TShape]: IKeyValueStorageObserver }): StorageStrategy { + const keys = Object.keys(observers); + + return { + asRaw: false, + getKeys: () => keys, + getValue: (state, key) => { + return observers[key].selectValue(state); + }, + onHydrate: (store, data) => { + for (const [key, value] of Object.entries(data)) { + store.dispatch(observers[key].hydrateValue(value as TShape[string])); + } + }, + }; +} + +interface IEntityStorageObserver { + selectIds: (s: TState) => string[]; + selectById: (s: TState, id: string) => TValue; + hydrateEntities: (data: Record) => UnknownAction; +} +export function createEntityStorageStrategy(observer: IEntityStorageObserver): StorageStrategy { + return { + asRaw: true, + getKeys: (prevState, nextState) => { + return prevState === nextState ? [] : distinct([...observer.selectIds(prevState), ...observer.selectIds(nextState)]); + }, + getValue: (state, key) => { + return observer.selectById(state, key); + }, + onHydrate: (store, data) => { + store.dispatch(observer.hydrateEntities(data as Record)); + }, + }; +} diff --git a/src/store/features/editor/beatmap.slice.ts b/src/store/features/editor/beatmap.slice.ts index 8a849cab..4e9ff22e 100644 --- a/src/store/features/editor/beatmap.slice.ts +++ b/src/store/features/editor/beatmap.slice.ts @@ -1,7 +1,7 @@ import { type AsyncThunkPayloadCreator, isAnyOf } from "@reduxjs/toolkit"; import type { NoteDirection } from "bsmap"; -import { cycleToNextTool, cycleToPrevTool, finishManagingNoteSelection, hydrateGridPresets, hydrateSession, startManagingNoteSelection, updateAllSelectedObstacles, updateObstacle } from "$/store/actions"; +import { cycleToNextTool, cycleToPrevTool, finishManagingNoteSelection, startManagingNoteSelection, updateAllSelectedObstacles, updateObstacle } from "$/store/actions"; import { createSlice } from "$/store/helpers"; import { selectGridSize } from "$/store/selectors"; import type { RootState } from "$/store/setup"; @@ -45,6 +45,13 @@ const slice = createSlice({ const { direction } = action.payload; return { ...state, selectedDirection: direction }; }), + updateDefaultObstacleDuration: api.reducer<{ value: number }>((state, action) => { + const { value } = action.payload; + return { ...state, defaultObstacleDuration: value }; + }), + hydrateGridPresets: api.reducer>((state, action) => { + return { ...state, gridPresets: action.payload }; + }), upsertGridPreset: api.asyncThunk(fetchGridSize, { fulfilled: (state, action) => { const { presetSlot: key, grid: value } = action.payload; @@ -58,16 +65,6 @@ const slice = createSlice({ }; }, extraReducers: (builder) => { - builder.addCase(hydrateSession, (state, action) => { - const { "notes.tool": selectedTool, "notes.direction": selectedDirection, "notes.duration": defaultObstacleDuration } = action.payload; - if (selectedTool !== undefined) state.selectedTool = Object.values(ObjectTool)[selectedTool]; - if (selectedDirection !== undefined) state.selectedDirection = selectedDirection; - if (defaultObstacleDuration !== undefined) state.defaultObstacleDuration = defaultObstacleDuration; - }); - builder.addCase(hydrateGridPresets, (state, action) => { - const gridPresets = action.payload; - return { ...state, gridPresets: { ...state.gridPresets, ...gridPresets } }; - }); builder.addCase(startManagingNoteSelection, (state, action) => { const { selectionMode } = action.payload; return { ...state, selectionMode: selectionMode }; diff --git a/src/store/features/editor/lightshow.slice.ts b/src/store/features/editor/lightshow.slice.ts index a1a1fa99..48986662 100644 --- a/src/store/features/editor/lightshow.slice.ts +++ b/src/store/features/editor/lightshow.slice.ts @@ -1,7 +1,7 @@ import { createSlice, isAnyOf, type ReducerCreators } from "@reduxjs/toolkit"; import { BEATS_PER_ZOOM_LEVEL, ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN } from "$/constants"; -import { cycleToNextTool, cycleToPrevTool, hydrateSession } from "$/store/actions"; +import { cycleToNextTool, cycleToPrevTool } from "$/store/actions"; import { EventColor, EventEditMode, EventTool, View } from "$/types"; const EVENT_TOOLS = Object.values(EventTool); @@ -84,33 +84,14 @@ const slice = createSlice({ if (checked) return { ...state, areLasersLocked: checked }; return { ...state, areLasersLocked: !state.areLasersLocked }; }), + updateZoomLevel: api.reducer<{ value: number }>((state, action) => { + return { ...state, zoomLevel: action.payload.value }; + }), incrementZoom: updateZoomLevel(api, (current) => Math.min(ZOOM_LEVEL_MAX, current + 1)), decrementZoom: updateZoomLevel(api, (current) => Math.max(ZOOM_LEVEL_MIN, current - 1)), }; }, extraReducers: (builder) => { - builder.addCase(hydrateSession, (state, action) => { - const { - "events.mode": selectedEditMode, - "events.tool": selectedTool, - "events.color": selectedColor, - "events.zoom": zoomLevel, - "events.preview": showLightingPreview, - "events.opacity": backgroundOpacity, - "events.height": rowHeight, - "events.loop": isLockedToCurrentWindow, - "events.mirror": areLasersLocked, - } = action.payload; - if (selectedEditMode !== undefined) state.selectedEditMode = Object.values(EventEditMode)[selectedEditMode]; - if (selectedTool !== undefined) state.selectedTool = Object.values(EventTool)[selectedTool]; - if (selectedColor !== undefined) state.selectedColor = Object.values(EventColor)[selectedColor]; - if (zoomLevel !== undefined) state.zoomLevel = zoomLevel; - if (showLightingPreview !== undefined) state.showLightingPreview = showLightingPreview; - if (backgroundOpacity !== undefined) state.backgroundOpacity = backgroundOpacity; - if (rowHeight !== undefined) state.rowHeight = rowHeight; - if (isLockedToCurrentWindow !== undefined) state.isLockedToCurrentWindow = isLockedToCurrentWindow; - if (areLasersLocked !== undefined) state.areLasersLocked = areLasersLocked; - }); builder.addMatcher(isAnyOf(cycleToNextTool, cycleToPrevTool), (state, action) => { const { view } = action.payload; if (view !== View.LIGHTSHOW) return state; diff --git a/src/store/features/navigation.slice.ts b/src/store/features/navigation.slice.ts index 245410c8..e74d6349 100644 --- a/src/store/features/navigation.slice.ts +++ b/src/store/features/navigation.slice.ts @@ -1,7 +1,7 @@ import { isAnyOf, type ReducerCreators } from "@reduxjs/toolkit"; import { SNAPPING_INCREMENTS } from "$/constants"; -import { hydrateSession, leaveEditor, reloadVisualizer, scrollThroughSong, selectAllEntitiesInRange, updateSong } from "$/store/actions"; +import { leaveEditor, reloadVisualizer, scrollThroughSong, selectAllEntitiesInRange, updateSong } from "$/store/actions"; import { createSlice } from "$/store/helpers"; import type { SongId } from "$/types"; import { clamp } from "$/utils"; @@ -129,15 +129,6 @@ const slice = createSlice({ }; }, extraReducers: (builder) => { - builder.addCase(hydrateSession, (state, action) => { - const { "track.snap": snapTo, "track.spacing": beatDepth, "playback.rate": playbackRate, "playback.volume": songVolume, "tick.volume": tickVolume, "tick.type": tickType } = action.payload; - if (snapTo !== undefined) state.snapTo = snapTo; - if (beatDepth !== undefined) state.beatDepth = beatDepth; - if (playbackRate !== undefined) state.playbackRate = playbackRate; - if (songVolume !== undefined) state.songVolume = songVolume; - if (tickVolume !== undefined) state.tickVolume = tickVolume; - if (tickType !== undefined) state.tickType = tickType; - }); builder.addCase(reloadVisualizer, (state, action) => { const { duration } = action.payload; return { ...state, duration: duration * 1000 }; diff --git a/src/store/features/songs.slice.ts b/src/store/features/songs.slice.ts index 38c95247..b75ea927 100644 --- a/src/store/features/songs.slice.ts +++ b/src/store/features/songs.slice.ts @@ -6,7 +6,7 @@ import { eventTypeRename } from "bsmap/extensions/renamer"; import { createAppBeatmap, createAppSong, getColorScheme, getEnvironment, resolveSongId } from "$/helpers/song.helpers"; import { importMapArchiveToFilestore } from "$/services/packaging.service"; import { getAppToaster } from "$/setup"; -import { finishLoadingMap, hydrateSongs, loadGridPreset, startLoadingMap } from "$/store/actions"; +import { finishLoadingMap, loadGridPreset, startLoadingMap } from "$/store/actions"; import { createSlice } from "$/store/helpers"; import type { App, BeatmapId, IColorScheme, IGrid, SongId } from "$/types"; import { deepAssign } from "$/utils"; @@ -118,6 +118,9 @@ const slice = createSlice({ }; return { + hydrate: api.reducer>((state, action) => { + return adapter.upsertMany(state, action.payload); + }), addOne: api.reducer<{ songId: SongId; beatmapId: BeatmapId; songFile: File; coverArtFile: File; songData: Parameters[0]; beatmapData: Parameters[0] }>((state, action) => { const { songData, beatmapId, beatmapData } = action.payload; return adapter.addOne(state, createAppSong({ ...songData, difficultiesById: { [beatmapId]: createAppBeatmap({ ...beatmapData, environmentName: beatmapData.environmentName ?? songData.environment ?? EnvironmentName[0] }) } })); @@ -243,10 +246,6 @@ const slice = createSlice({ }; }, extraReducers: (builder) => { - builder.addCase(hydrateSongs, (state, action) => { - const byId = action.payload; - return adapter.upsertMany(state, Object.values(byId)); - }); builder.addCase(startLoadingMap, (state, action) => { const { songId, beatmapId } = action.payload; return adapter.updateOne(state, { id: songId, changes: { selectedDifficulty: beatmapId } }); diff --git a/src/store/features/user.slice.ts b/src/store/features/user.slice.ts index f0f929c8..79d1a106 100644 --- a/src/store/features/user.slice.ts +++ b/src/store/features/user.slice.ts @@ -1,6 +1,6 @@ import { createSlice, isAnyOf } from "@reduxjs/toolkit"; -import { addSong, addSongFromFile, finishLoadingMap, hydrateUser, updateSong } from "$/store/actions"; +import { addSong, addSongFromFile, finishLoadingMap, updateSong } from "$/store/actions"; import { ObstaclePlacementMode } from "$/types"; const initialState = { @@ -29,6 +29,14 @@ const slice = createSlice({ }, reducers: (api) => { return { + updateNew: api.reducer<{ value: boolean }>((state, action) => { + const { value } = action.payload; + return { ...state, isNewUser: value }; + }), + updateAnnouncements: api.reducer<{ value: string[] }>((state, action) => { + const { value } = action.payload; + return { ...state, seenPrompts: value }; + }), dismissPrompt: api.reducer<{ id: string }>((state, action) => { const { id } = action.payload; return { ...state, seenPrompts: [...state.seenPrompts, id] }; @@ -61,16 +69,6 @@ const slice = createSlice({ }; }, extraReducers: (builder) => { - builder.addCase(hydrateUser, (state, action) => { - const { "user.new": isNewUser, "user.announcements": seenPrompts, "user.username": stickyMapAuthorName, "audio.offset": processingDelay, "graphics.scale": renderScale, "graphics.bloom": isBlooming, "controls.obstacles": obstaclePlacementMode } = action.payload; - if (isNewUser !== undefined) state.isNewUser = isNewUser; - if (seenPrompts !== undefined) state.seenPrompts = seenPrompts; - if (stickyMapAuthorName !== undefined) state.stickyMapAuthorName = stickyMapAuthorName; - if (processingDelay !== undefined) state.processingDelay = processingDelay; - if (renderScale !== undefined) state.renderScale = renderScale; - if (isBlooming !== undefined) state.isBloomEnabled = isBlooming; - if (obstaclePlacementMode !== undefined) state.obstaclePlacementMode = Object.values(ObstaclePlacementMode)[obstaclePlacementMode]; - }); builder.addCase(addSongFromFile.fulfilled, (state) => { return { ...state, isNewUser: false }; }); diff --git a/src/store/middleware/audio.middleware.ts b/src/store/middleware/audio.middleware.ts index a0cf1d16..ed537599 100644 --- a/src/store/middleware/audio.middleware.ts +++ b/src/store/middleware/audio.middleware.ts @@ -5,7 +5,7 @@ import { convertFileToArrayBuffer } from "$/helpers/file.helpers"; import { getRouter } from "$/router"; import type { AudioSample } from "$/services/audio.service"; import { getAppBeatmapFilestore } from "$/setup"; -import { decrementPlaybackRate, finishLoadingMap, hydrateSession, incrementPlaybackRate, pausePlayback, startPlayback, stopPlayback, tick, updateCursorPosition, updatePlaybackRate, updateSong, updateSongVolume, updateTickType, updateTickVolume } from "$/store/actions"; +import { decrementPlaybackRate, finishLoadingMap, incrementPlaybackRate, pausePlayback, startPlayback, stopPlayback, tick, updateCursorPosition, updatePlaybackRate, updateSong, updateSongVolume, updateTickType, updateTickVolume } from "$/store/actions"; import { selectAllColorNotes, selectAudioProcessingDelayInBeats, selectCursorPosition, selectPlaybackRate, selectTickVolume } from "$/store/selectors"; import type { RootState } from "$/store/setup"; import { type SongId, View } from "$/types"; @@ -27,16 +27,6 @@ export default function createAudioMiddleware({ songSample, tickSample }: { song let tickSchedule: number[] = []; - instance.startListening({ - actionCreator: hydrateSession, - effect: async (action) => { - const { "playback.rate": playbackRate, "playback.volume": songVolume, "tick.volume": tickVolume, "tick.type": tickType } = action.payload; - if (playbackRate !== undefined) songSample.changePlaybackRate(playbackRate); - if (songVolume !== undefined) songSample.changeVolume(songVolume); - if (tickVolume !== undefined) tickSample.changeVolume(tickVolume); - if (tickType !== undefined) tickSample.load(NOTE_TICK_TYPES[tickType]); - }, - }); instance.startListening({ actionCreator: updateCursorPosition, effect: async (action) => { diff --git a/src/store/middleware/index.ts b/src/store/middleware/index.ts index ba8a9ee9..7539d77e 100644 --- a/src/store/middleware/index.ts +++ b/src/store/middleware/index.ts @@ -2,6 +2,7 @@ import type { Middleware } from "@reduxjs/toolkit"; import { createStateSyncMiddleware } from "redux-state-sync"; import { AudioSample } from "$/services/audio.service"; +import { rehydrate } from "../actions"; import createAudioMiddleware from "./audio.middleware"; import createBackupMiddleware from "./backup.middleware"; import createDemoMiddleware from "./demo.middleware"; @@ -10,13 +11,10 @@ import createHistoryMiddleware from "./history.middleware"; import createPackagingMiddleware from "./packaging.middleware"; import createPlaybackMiddleware from "./playback.middleware"; -export { createStorageMiddleware, type StorageObserver } from "./storage.middleware"; - export function createAllSharedMiddleware() { const stateSyncMiddleware = createStateSyncMiddleware({ predicate: (action) => { - if (action.type.startsWith("@@STORAGE")) return true; - return false; + return rehydrate.match(action); }, }); diff --git a/src/store/middleware/storage.middleware.ts b/src/store/middleware/storage.middleware.ts deleted file mode 100644 index 53e958ed..00000000 --- a/src/store/middleware/storage.middleware.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { createAction, isAction, type Middleware } from "@reduxjs/toolkit"; -import { distinct } from "@std/collections/distinct"; -import type { Storage, StorageValue } from "unstorage"; - -import { rehydrate } from "$/store/actions"; -import type { MaybeDefined } from "$/types/vendor"; - -// the common architecture other packages use for persisting redux state is to clone the entire state object into storage and then hydrate the redux store all at once. -// this is a really bad means of persistence for many reasons: -// - any retroactive updates made to the redux state can break functionality, especially if there are no migrations in place to convert the stored state object -// - any updates to the persisted state have to be made all at once on every dispatch, even if the persisted state isn't actually changed between updates - -// this new middleware (based off `redux-persistent`) implements a more flexible and performant architecture for reading state changes: -// you define observers that map a selector to a key-value storage entry, and update the entry when any action updates that observed state -// - this gives us full control over what we want to persist, how we want to store it, and how those values are hydrated back into the app state -// - migrations are much easier to manage, since we can make direct updates to the selectors as needed without breaking references in storage. -// - state updates are easier to verify, since the save action is only dispatched when the persisted state *actually* changes instead of every dispatched action - -// the only downside: we need to define a dedicated case reducer to hydrate values for each slice. -// bit annoying, but this ensures we have more control for how we want to plug state back into the app, which imo is a fair tradeoff. - -// TODO: this could probably be rewritten in the future with better abstractions for common logic and rely more heavily on thunk and listener middleware capabilities. -// unfortunately i can't be bothered with it right now :) - -export interface StorageObserver { - /** The state to listen for. */ - selector: (state: State) => T; - /** Allows you to control whether this observer is active. */ - condition?: boolean; - /** Will serialize the raw values directly to/from storage, rather than interpolating them as strings. Only useful if the driver supports non-serial values in storage,such as arrays, blobs, etc. */ - asRaw?: boolean; -} - -export type StorageObserverMap = Record>; - -export function createStorageActions = StorageObserverMap>(namespace: string) { - return { - /** Runs when all hydrations are complete. */ - load: createAction(`@@STORAGE/LOAD/${namespace}`), - /** Runs when an observed state is updated. */ - save: createAction(`@@STORAGE/SAVE/${namespace}`, (payload: { key: keyof T; value: ReturnType | null }) => { - return { payload: payload }; - }), - /** Applies a persisted value into the state. Returns the entry from the storage. */ - hydrate: createAction(`@@STORAGE/HYDRATE/${namespace}`, (payload: Partial<{ [Key in keyof T]: ReturnType }>) => { - return { payload: payload }; - }), - }; -} - -export interface StorageMiddlewareOptions = StorageObserverMap> { - /** The scope of the middleware. Useful when you have multiple storage middleware defined. */ - namespace: string; - /** The preferred storage engine for the middleware. */ - storage: Storage; - /** A map of storage observers. Each key in the map will be the key used in the storage engine. */ - observers: T; -} -export function createStorageMiddleware = StorageObserverMap>({ namespace, observers, storage }: StorageMiddlewareOptions): Middleware { - const { load, save, hydrate } = createStorageActions(namespace); - let processing = false; - return ({ dispatch, getState }) => { - return (next) => async (action) => { - const state = getState(); - // we don't want to be observing changes until the state is initialized - if (processing) { - return next(action); - } - - if (rehydrate.match(action)) { - dispatch(load()); - } - - if (load.match(action)) { - processing = true; - return Promise.all( - Object.keys(observers).map(async (key) => { - const { condition: enabled, selector, asRaw } = observers[key]; - // if the observer is not enabled, abort early - if (enabled === false) return; - const value = (asRaw ? await storage.getItemRaw(key) : await storage.getItem(key)) ?? selector(state); - // if the entry doesn't exist, autogenerate it from the initialized state - if (!(await storage.hasItem(key))) { - if (asRaw) { - await storage.setItemRaw(key, value); - } else { - await storage.setItem(key, value); - } - } - // if the value in storage matches the selected state, we can skip hydration since the value will remain the same anyways - if (value === selector(state)) return; - // dispatch a hydrate action with the injected value - return dispatch(hydrate({ [key]: value })); - }), - ).then((dispatched) => { - processing = false; - // don't dispatch the load action if nothing was actually loaded - if (!dispatched) return; - // once all processing is complete, dispatch the load action to trigger side-effects - return next(action); - }); - } - - // we don't want to cascade storage updates in other instances of this middleware - // this only really applies when we have two observers watching the same state - if (isAction(action) && action.type.startsWith("@@STORAGE")) { - return next(action); - } - - next(action); - const nextState = getState(); - // if the two states aren't equal, time to figure out what changed - if (nextState !== state) { - return Promise.resolve( - Object.keys(observers).reduce( - (acc: { key: string | undefined; value: StorageValue | undefined }, key) => { - const { condition: enabled, selector, asRaw } = observers[key]; - // if the observer is not enabled, abort early - if (enabled === false) return acc; - const value = selector(nextState); - // only save if the observed state changes - if (value === selector(state)) return acc; - // if the value is undefined, clear the value - if (value === undefined || value === "") { - storage.removeItem(key); - return { key, value: null }; - } - if (asRaw) { - storage.setItemRaw(key, value); - } else { - storage.setItem(key, value); - } - return { key, value }; - }, - { key: undefined, value: undefined }, - ), - ).then(({ key, value }) => { - // if there was a change, dispatch a save action with the updated field - if (key) dispatch(save({ key, value: value ?? null })); - }); - } - }; - }; -} - -// for entities, we don't know what the keys are ahead of time since they are dynamically referenced in state, so we need to make some adjustments to the logic: - -interface EntityStorageObserver extends Omit, "selector"> { - /** The key selector (selectIds). Used to generate the keys on initialization and observe for new entries to add on hydration. */ - keys: (state: State) => string[]; - /** The entity selector (selectById). */ - selector: (state: State, key: string) => T; -} - -export function createEntityStorageActions(namespace: string) { - return { - /** Runs when all hydrations are complete. */ - load: createAction(`@@STORAGE/LOAD/${namespace}`), - /** Runs when an observed state is updated. */ - save: createAction(`@@STORAGE/SAVE/${namespace}`, (payload: { key: string; value: T | null }) => { - return { payload: payload }; - }), - /** Applies a persisted value into the state. Returns the entry from the storage. */ - hydrate: createAction(`@@STORAGE/HYDRATE/${namespace}`, (payload: Record) => { - return { payload: payload }; - }), - }; -} - -interface EntityStorageMiddlewareOptions extends Omit, "observers"> { - /** The entity observer. Uses selectors to dynamically define key-value pairs based on an entity state. Pairs nicely with RTK's entity adapters */ - observer: EntityStorageObserver; -} -export function createEntityStorageMiddleware({ namespace, observer, storage }: EntityStorageMiddlewareOptions): Middleware { - const { load, save, hydrate } = createEntityStorageActions(namespace); - let processing = false; - return ({ dispatch, getState }) => { - return (next) => async (action) => { - const state = getState(); - // we don't want to be observing changes until the state is initialized - if (processing) { - return next(action); - } - - if (rehydrate.match(action)) { - dispatch(load()); - } - - if (load.match(action)) { - processing = true; - // we don't know the keys ahead of time, but we'll process the ones we already have - const keys = await storage.getKeys(); - return Promise.all( - keys.map(async (key) => { - const { condition: enabled, selector, asRaw } = observer; - // if the observer is not enabled, abort early - if (enabled === false) return; - const value = (asRaw ? await storage.getItemRaw(key) : await storage.getItem(key)) ?? selector(state, key); - // if the value doesn't exist, it was probably already removed, so we can clear it now - if (value === undefined) await storage.removeItem(key); - // if the value in storage matches the selected state, we can skip hydration since the value will remain the same anyways - if (value === selector(state, key)) return; - // dispatch a hydrate action with the injected value - return dispatch(hydrate({ [key]: value })); - }), - ).then((dispatched) => { - processing = false; - // don't dispatch the load action if nothing was actually loaded - if (!dispatched) return; - // once all processing is complete, dispatch the load action to trigger side-effects - return next(action); - }); - } - - // if we're saving, we don't want to cascade updates in other storage middleware - // this only really applies when we have two observers watching the same state - if (isAction(action) && action.type.startsWith("@@STORAGE")) { - return next(action); - } - - next(action); - const nextState = getState(); - // if the two states aren't equal, time to figure out what changed - if (nextState !== state) { - // we need to reference old keys in order to remove storage entries for removed entities - const allKeys = distinct([...observer.keys(nextState), ...observer.keys(state)]); - return Promise.resolve( - Object.values(allKeys).reduce( - (acc: { key: string | undefined; value: T | undefined }, key) => { - const { condition: enabled, selector, asRaw } = observer; - // if the observer is not enabled, abort early - if (enabled === false) return acc; - const value = selector(nextState, key); - // only save if the observed state changes - if (value === selector(state, key)) return acc; - // if the value doesn't exist, clear it from storage - if (value === undefined) { - storage.removeItem(key.toString()); - return { key, value: undefined }; - } - if (asRaw) { - storage.setItemRaw(key, value as MaybeDefined); - } else { - storage.setItem(key, value); - } - return { key, value }; - }, - { key: undefined, value: undefined }, - ), - ).then(({ key, value }) => { - // if there was a change, dispatch a save action with the updated field - if (key) dispatch(save({ key: key, value: value ?? null })); - }); - } - }; - }; -} diff --git a/src/store/setup.ts b/src/store/setup.ts index 26170908..36b33449 100644 --- a/src/store/setup.ts +++ b/src/store/setup.ts @@ -5,18 +5,49 @@ 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"; -import { default as ls } from "unstorage/drivers/localstorage"; -import { default as ss } from "unstorage/drivers/session-storage"; +import { default as createLocalStorageDriver } from "unstorage/drivers/localstorage"; +import { default as createSessionStorageDriver } from "unstorage/drivers/session-storage"; import { patchEnvironmentName } from "$/helpers/packaging.helpers"; import { createDriver, type LegacyStorageSchema } from "$/services/storage.service"; import { setupAppBeatmapFilestore, setupAppToaster } from "$/setup"; import { type App, type BeatmapId, EventColor, EventEditMode, EventTool, type IGridPresets, type Member, ObjectTool, ObstaclePlacementMode } from "$/types"; -import { init, loadGridPresets, loadSession, loadSongs, loadUser, tick, updateEventsEditorCursor } from "./actions"; +import { + hydrateGridPresets, + hydrateSongs, + init, + tick, + updateAnnouncements, + updateBeatDepth, + updateBloomEnabled, + updateEventsEditorColor, + updateEventsEditorCursor, + updateEventsEditorEditMode, + updateEventsEditorMirrorLock, + updateEventsEditorPreview, + updateEventsEditorTool, + updateEventsEditorTrackHeight, + updateEventsEditorTrackOpacity, + updateEventsEditorWindowLock, + updateEventsEditorZoomLevel, + updateNew, + updateNotesEditorDefaultObstacleDuration, + updateNotesEditorDirection, + updateNotesEditorTool, + updateObstaclePlacementMode, + updatePacerWait, + updatePlaybackRate, + updateProcessingDelay, + updateRenderScale, + updateSnap, + updateSongVolume, + updateTickType, + updateTickVolume, + updateUsername, +} from "./actions"; +import { createEntityStorageStrategy, createKeyValueStorageStrategy, createStorageEnhancer } from "./enhancers/storage.enhancer"; import { default as root } from "./features"; -import { createAllSharedMiddleware, createStorageMiddleware, type StorageObserver } from "./middleware"; -import { createEntityStorageMiddleware } from "./middleware/storage.middleware"; +import { createAllSharedMiddleware } from "./middleware"; import { selectAllGridPresetIds, selectAnnouncements, @@ -52,37 +83,6 @@ import { const STORAGE_PREFIX = location.hostname === "localhost" ? "beatmapper" : ""; -export type UserStorageObservers = { - "user.new": StorageObserver; - "user.announcements": StorageObserver; - "user.username": StorageObserver; - "audio.offset": StorageObserver; - "graphics.scale": StorageObserver; - "graphics.bloom": StorageObserver; - "controls.obstacles": StorageObserver; - "advanced.wait": StorageObserver; -}; -export type SessionStorageObservers = { - "track.snap": StorageObserver; - "track.spacing": StorageObserver; - "playback.rate": StorageObserver; - "playback.volume": StorageObserver; - "tick.volume": StorageObserver; - "tick.type": StorageObserver; - "notes.tool": StorageObserver; - "notes.direction": StorageObserver; - "notes.duration": StorageObserver; - "events.mode": StorageObserver; - "events.tool": StorageObserver; - "events.color": StorageObserver; - "events.zoom": StorageObserver; - "events.opacity": StorageObserver; - "events.height": StorageObserver; - "events.preview": StorageObserver; - "events.loop": StorageObserver; - "events.mirror": StorageObserver; -}; - /** @deprecated this is really only used during migration flow, don't use this elsewhere */ function resolveDifficultyFromBeatmapId(bid: BeatmapId) { for (const difficulty of ["ExpertPlus", "Expert", "Hard", "Normal", "Easy"].reverse()) { @@ -90,7 +90,7 @@ function resolveDifficultyFromBeatmapId(bid: BeatmapId) { } throw new Error(`Could not resolve difficulty from id: ${bid}`); } -const appStoreDriver = createDriver } }>({ +const createAppEntityStorageDriver = createDriver } }>({ name: "beat-mapper-state", version: 4, async upgrade(idb, _current, next, tx) { @@ -166,64 +166,168 @@ export async function createAppStore() { setupAppBeatmapFilestore(); setupAppToaster(); - const middleware = createAllSharedMiddleware(); + type LocalStorage = { + "user.new": boolean; + "user.announcements": string[]; + "user.username": string; + "audio.offset": number; + "graphics.scale": number; + "graphics.bloom": boolean; + "controls.obstacles": number; + "advanced.wait": number; + }; + const localStorageEnhancer = createStorageEnhancer( + createLocalStorageDriver({ base: STORAGE_PREFIX }), + createKeyValueStorageStrategy({ + "user.new": { + selectValue: selectNew, + hydrateValue: (value) => updateNew({ value }), + }, + "user.announcements": { + selectValue: selectAnnouncements, + hydrateValue: (value) => updateAnnouncements({ value }), + }, + "user.username": { + selectValue: (state) => selectUsername(state) ?? "", + hydrateValue: (value) => updateUsername({ value }), + }, + "audio.offset": { + selectValue: selectAudioProcessingDelay, + hydrateValue: (value) => updateProcessingDelay({ value }), + }, + "graphics.scale": { + selectValue: selectRenderScale, + hydrateValue: (value) => updateRenderScale({ value }), + }, + "graphics.bloom": { + selectValue: selectBloomEnabled, + hydrateValue: (checked) => updateBloomEnabled({ checked }), + }, + "controls.obstacles": { + selectValue: (state) => Object.values(ObstaclePlacementMode).indexOf(selectUserObstaclePlacementMode(state)), + hydrateValue: (index) => updateObstaclePlacementMode({ value: Object.values(ObstaclePlacementMode)[index] }), + }, + "advanced.wait": { + selectValue: selectPacerWait, + hydrateValue: (value) => updatePacerWait({ value }), + }, + }), + ); - const userMiddleware = createStorageMiddleware({ - namespace: "user", - storage: createStorage({ driver: ls({ base: STORAGE_PREFIX }) }), - observers: { - "user.new": { selector: selectNew }, - "user.announcements": { selector: selectAnnouncements }, - "user.username": { selector: (state) => selectUsername(state) ?? "" }, - "audio.offset": { selector: selectAudioProcessingDelay }, - "graphics.scale": { selector: selectRenderScale }, - "graphics.bloom": { selector: selectBloomEnabled }, - "controls.obstacles": { selector: (state) => Object.values(ObstaclePlacementMode).indexOf(selectUserObstaclePlacementMode(state)) }, - "advanced.wait": { selector: selectPacerWait }, - }, - }); - const sessionMiddleware = createStorageMiddleware({ - namespace: "session", - storage: createStorage({ driver: ss({ base: STORAGE_PREFIX }) }), - observers: { - "track.snap": { selector: selectSnap }, - "track.spacing": { selector: selectBeatDepth }, - "playback.rate": { selector: selectPlaybackRate }, - "playback.volume": { selector: selectSongVolume }, - "tick.volume": { selector: selectTickVolume }, - "tick.type": { selector: selectTickType }, - "notes.tool": { selector: (state) => Object.values(ObjectTool).indexOf(selectNotesEditorTool(state)) }, - "notes.direction": { selector: selectNotesEditorDirection }, - "notes.duration": { selector: selectDefaultObstacleDuration }, - "events.mode": { selector: (state) => Object.values(EventEditMode).indexOf(selectEventsEditorEditMode(state)) }, - "events.tool": { selector: (state) => Object.values(EventTool).indexOf(selectEventsEditorTool(state)) }, - "events.color": { selector: (state) => Object.values(EventColor).indexOf(selectEventsEditorColor(state)) }, - "events.zoom": { selector: selectEventsEditorZoomLevel }, - "events.opacity": { selector: selectEventsEditorTrackOpacity }, - "events.height": { selector: selectEventsEditorTrackHeight }, - "events.preview": { selector: selectEventsEditorPreview }, - "events.loop": { selector: selectEventsEditorWindowLock }, - "events.mirror": { selector: selectEventsEditorMirrorLock }, - }, - }); - const songStorageMiddleware = createEntityStorageMiddleware({ - namespace: "songs", - storage: createStorage({ driver: appStoreDriver({ name: "songs" }) }), - observer: { - keys: (state) => selectSongIds(state).map((x) => x.toString()), - selector: selectSongById, - asRaw: true, - }, - }); - const gridStorageMiddleware = createEntityStorageMiddleware>({ - namespace: "grids", - storage: createStorage({ driver: appStoreDriver({ name: "grids" }) }), - observer: { - keys: selectAllGridPresetIds, - selector: selectGridPresetById, - asRaw: true, - }, - }); + type SessionStorage = { + "track.snap": number; + "track.spacing": number; + "playback.rate": number; + "playback.volume": number; + "tick.volume": number; + "tick.type": number; + "notes.tool": number; + "notes.direction": NoteDirection; + "notes.duration": number; + "events.mode": number; + "events.tool": number; + "events.color": number; + "events.zoom": number; + "events.opacity": number; + "events.height": number; + "events.preview": boolean; + "events.loop": boolean; + "events.mirror": boolean; + }; + const sessionStorageEnhancer = createStorageEnhancer( + createSessionStorageDriver({ base: STORAGE_PREFIX }), + createKeyValueStorageStrategy({ + "track.snap": { + selectValue: selectSnap, + hydrateValue: (value) => updateSnap({ value }), + }, + "track.spacing": { + selectValue: selectBeatDepth, + hydrateValue: (value) => updateBeatDepth({ value }), + }, + "playback.rate": { + selectValue: selectPlaybackRate, + hydrateValue: (value) => updatePlaybackRate({ value }), + }, + "playback.volume": { + selectValue: selectSongVolume, + hydrateValue: (value) => updateSongVolume({ value }), + }, + "tick.volume": { + selectValue: selectTickVolume, + hydrateValue: (value) => updateTickVolume({ value }), + }, + "tick.type": { + selectValue: selectTickType, + hydrateValue: (value) => updateTickType({ value }), + }, + "notes.tool": { + selectValue: (state) => Object.values(ObjectTool).indexOf(selectNotesEditorTool(state)), + hydrateValue: (index) => updateNotesEditorTool({ tool: Object.values(ObjectTool)[index] }), + }, + "notes.direction": { + selectValue: selectNotesEditorDirection, + hydrateValue: (direction) => updateNotesEditorDirection({ direction }), + }, + "notes.duration": { + selectValue: selectDefaultObstacleDuration, + hydrateValue: (value) => updateNotesEditorDefaultObstacleDuration({ value }), + }, + "events.mode": { + selectValue: (state) => Object.values(EventEditMode).indexOf(selectEventsEditorEditMode(state)), + hydrateValue: (index) => updateEventsEditorEditMode({ editMode: Object.values(EventEditMode)[index] }), + }, + "events.tool": { + selectValue: (state) => Object.values(EventTool).indexOf(selectEventsEditorTool(state)), + hydrateValue: (index) => updateEventsEditorTool({ tool: Object.values(EventTool)[index] }), + }, + "events.color": { + selectValue: (state) => Object.values(EventColor).indexOf(selectEventsEditorColor(state)), + hydrateValue: (index) => updateEventsEditorColor({ color: Object.values(EventColor)[index] }), + }, + "events.zoom": { + selectValue: selectEventsEditorZoomLevel, + hydrateValue: (value) => updateEventsEditorZoomLevel({ value }), + }, + "events.opacity": { + selectValue: selectEventsEditorTrackOpacity, + hydrateValue: (newOpacity) => updateEventsEditorTrackOpacity({ newOpacity }), + }, + "events.height": { + selectValue: selectEventsEditorTrackHeight, + hydrateValue: (newHeight) => updateEventsEditorTrackHeight({ newHeight }), + }, + "events.preview": { + selectValue: selectEventsEditorPreview, + hydrateValue: (checked) => updateEventsEditorPreview({ checked }), + }, + "events.loop": { + selectValue: selectEventsEditorWindowLock, + hydrateValue: (checked) => updateEventsEditorWindowLock({ checked }), + }, + "events.mirror": { + selectValue: selectEventsEditorMirrorLock, + hydrateValue: (checked) => updateEventsEditorMirrorLock({ checked }), + }, + }), + ); + + const songStorageEnhancer = createStorageEnhancer( + createAppEntityStorageDriver({ name: "songs" }), + createEntityStorageStrategy({ + selectIds: (state) => selectSongIds(state).map((x) => x.toString()), + selectById: selectSongById, + hydrateEntities: hydrateSongs, + }), + ); + const gridStorageEnhancer = createStorageEnhancer( + createAppEntityStorageDriver({ name: "grids" }), + createEntityStorageStrategy>({ + selectIds: selectAllGridPresetIds, + selectById: selectGridPresetById, + hydrateEntities: hydrateGridPresets, + }), + ); const devTools: DevToolsEnhancerOptions = { name: "Beatmapper", @@ -233,17 +337,15 @@ export async function createAppStore() { const store = configureStore({ reducer: root.reducer, devTools: import.meta.env.VITE_ENABLE_DEVTOOLS ? devTools : undefined, - middleware: (native) => native({ serializableCheck: false, immutableCheck: false }).concat(...middleware, userMiddleware, sessionMiddleware, songStorageMiddleware, gridStorageMiddleware), - enhancers: (native) => native(), + middleware: (getDefaultMiddleware) => { + return getDefaultMiddleware().concat(createAllSharedMiddleware()); + }, + enhancers: (getDefaultEnhancers) => { + return getDefaultEnhancers().concat(localStorageEnhancer, sessionStorageEnhancer, songStorageEnhancer, gridStorageEnhancer); + }, }); - await Promise.all([ - store.dispatch(loadUser()), - store.dispatch(loadSession()), - store.dispatch(loadSongs()), - store.dispatch(loadGridPresets()), - // - ]).then(() => { + await store.hydrate().then(() => { store.dispatch(init()); }); From e50b7e2af715d6beae24498dbd8bff7836001e7e Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 14 Jul 2026 19:24:44 -0500 Subject: [PATCH 02/27] normalize real time calculation to seconds --- .../app/layouts/visualizer/markers.tsx | 5 ++-- .../app/templates/editor/visualizer.tsx | 2 +- .../templates/environment/side-lasers.tsx | 2 +- src/helpers/audio.helpers.test.ts | 24 ----------------- src/helpers/audio.helpers.ts | 27 ++++--------------- src/store/features/navigation.slice.ts | 3 +-- src/store/features/songs.slice.ts | 2 +- src/store/middleware/audio.middleware.ts | 4 +-- src/store/middleware/playback.middleware.ts | 13 +++------ src/store/selectors.ts | 27 ++++++++++++------- 10 files changed, 33 insertions(+), 76 deletions(-) delete mode 100644 src/helpers/audio.helpers.test.ts 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/editor/visualizer.tsx b/src/components/app/templates/editor/visualizer.tsx index 01075cfa..e2426d12 100644 --- a/src/components/app/templates/editor/visualizer.tsx +++ b/src/components/app/templates/editor/visualizer.tsx @@ -49,7 +49,7 @@ function EditorAudioVisualizer() { return ( - + {(ref) => } {duration !== null && ( diff --git a/src/components/scene/templates/environment/side-lasers.tsx b/src/components/scene/templates/environment/side-lasers.tsx index 15627228..de813eb9 100644 --- a/src/components/scene/templates/environment/side-lasers.tsx +++ b/src/components/scene/templates/environment/side-lasers.tsx @@ -32,7 +32,7 @@ function resolveLaserRotation(side: "left" | "right", beamIndex: number, current randomOffset = random() * Math.PI * 2; } - const angle = (currentTime / 1000) * laserSpeed * 0.35 + randomOffset; + const angle = currentTime * laserSpeed * 0.35 + randomOffset; return normalize(Math.sin(angle), -1, 1, defaultRotation, defaultRotation * -1); } diff --git a/src/helpers/audio.helpers.test.ts b/src/helpers/audio.helpers.test.ts deleted file mode 100644 index 0fc0f300..00000000 --- a/src/helpers/audio.helpers.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { convertBeatsToMilliseconds, convertMillisecondsToBeats } from "./audio.helpers"; - -describe("timescale conversion", () => { - describe(convertMillisecondsToBeats.name, () => { - it("converts 1000ms", () => { - expect(convertMillisecondsToBeats(1000, 120)).toEqual(2); - }); - it("does not produce floating point results for beats", () => { - expect(convertMillisecondsToBeats(1028.5714285714284, 175)).toEqual(3); - }); - }); - describe(convertBeatsToMilliseconds.name, () => { - it("converts 8 beats", () => { - expect(convertBeatsToMilliseconds(8, 60)).toEqual(8000); - }); - }); - it("converts in both directions", () => { - const bpm = 90; - expect(convertBeatsToMilliseconds(convertMillisecondsToBeats(250, bpm), bpm)).toEqual(250); - expect(convertBeatsToMilliseconds(convertMillisecondsToBeats(250, bpm), bpm)).toEqual(250); - }); -}); diff --git a/src/helpers/audio.helpers.ts b/src/helpers/audio.helpers.ts index cfb365b8..2aed30a2 100644 --- a/src/helpers/audio.helpers.ts +++ b/src/helpers/audio.helpers.ts @@ -3,22 +3,8 @@ import { createAudioData, createBPMEvent, type IWrapAudioData, type IWrapAudioDa import { default as WaveformData } from "waveform-data"; import { getAudioContext } from "$/setup"; -import { roundToNearest } from "$/utils"; import { convertFileToArrayBuffer } from "./file.helpers"; -export function convertMillisecondsToBeats(ms: number, bpm: number) { - const bps = bpm / 60; - const beats = (ms / 1000) * bps; - // To avoid floating-point issues like 2.999999997, let's round. We'll choose - // the lowest-common-multiple to "snap" to any possible value. - return roundToNearest(beats, 1 / 96); -} - -export function convertBeatsToMilliseconds(beats: number, bpm: number) { - const bps = bpm / 60; - return (beats / bps) * 1000; -} - export async function deriveAudioDataFromFile(file: Blob | MediaSource) { const audioContext = getAudioContext(); @@ -54,7 +40,7 @@ export async function createAudioDataContentsFromFile(songFile: File, options: { startSampleIndex: 0, endSampleIndex: sampleCount, startBeat: 0, - endBeat: convertMillisecondsToBeats(duration * 1000, options.bpm), + endBeat: duration * (options.bpm / 60), }; return createAudioData({ version: options.version, frequency, sampleCount, bpmData: [region] }); @@ -83,18 +69,15 @@ export function createBpmEventsFromAudioData({ bpmData, frequency }: IWrapAudioD const beatDelta = region.endBeat - region.startBeat; const sampleDelta = region.endSampleIndex - region.startSampleIndex; - const derivedBpm = sampleDelta === 0 ? 0 : (beatDelta / sampleDelta) * frequency * 60; + const bpm = sampleDelta === 0 ? 0 : (beatDelta / sampleDelta) * frequency * 60; - return createBPMEvent({ - time: region.startBeat, - bpm: Math.round(derivedBpm * 1000) / 1000, - }); + return createBPMEvent({ time: region.startBeat, bpm }); }); } export function formatCursorPosition(cursorPosition: number) { - const seconds = Math.floor((cursorPosition / 1000) % 60).toString(); - const minutes = Math.floor((cursorPosition / (1000 * 60)) % 60).toString(); + const seconds = Math.floor(cursorPosition % 60).toString(); + const minutes = Math.floor((cursorPosition / 60) % 60).toString(); return `${minutes.padStart(2, "0")}:${seconds.padStart(2, "0")}`; } diff --git a/src/store/features/navigation.slice.ts b/src/store/features/navigation.slice.ts index e74d6349..3475f60a 100644 --- a/src/store/features/navigation.slice.ts +++ b/src/store/features/navigation.slice.ts @@ -130,8 +130,7 @@ const slice = createSlice({ }, extraReducers: (builder) => { builder.addCase(reloadVisualizer, (state, action) => { - const { duration } = action.payload; - return { ...state, duration: duration * 1000 }; + return { ...state, duration: action.payload.duration }; }); builder.addCase(leaveEditor, (state) => { return { ...state, duration: null }; diff --git a/src/store/features/songs.slice.ts b/src/store/features/songs.slice.ts index b75ea927..367020fc 100644 --- a/src/store/features/songs.slice.ts +++ b/src/store/features/songs.slice.ts @@ -33,7 +33,7 @@ const slice = createSlice({ return song.bpm; }), selectEditorOffset: createSelector(selectById, (song) => { - return song.offset; + return song.offset / 1000; }), selectBeatmaps: createSelector(selectById, (song) => { return song.difficultiesById; diff --git a/src/store/middleware/audio.middleware.ts b/src/store/middleware/audio.middleware.ts index ed537599..f378ecb6 100644 --- a/src/store/middleware/audio.middleware.ts +++ b/src/store/middleware/audio.middleware.ts @@ -30,7 +30,7 @@ export default function createAudioMiddleware({ songSample, tickSample }: { song instance.startListening({ actionCreator: updateCursorPosition, effect: async (action) => { - songSample.setCurrentTime(action.payload.value / 1000); + songSample.setCurrentTime(action.payload.value); }, }); instance.startListening({ @@ -70,7 +70,7 @@ export default function createAudioMiddleware({ songSample, tickSample }: { song instance.startListening({ actionCreator: startPlayback, effect: (_, api) => { - songSample.play(selectCursorPosition(api.getState()) / 1000); + songSample.play(selectCursorPosition(api.getState())); }, }); instance.startListening({ diff --git a/src/store/middleware/playback.middleware.ts b/src/store/middleware/playback.middleware.ts index 72a798bc..3c62accb 100644 --- a/src/store/middleware/playback.middleware.ts +++ b/src/store/middleware/playback.middleware.ts @@ -1,4 +1,4 @@ -import { createDraftSafeSelector, createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/toolkit"; +import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/toolkit"; import { TimeProcessor } from "bsmap"; import { createBpmEventsFromAudioData } from "$/helpers/audio.helpers"; @@ -6,18 +6,11 @@ import { getRouter } from "$/router"; import type { AudioSample } from "$/services/audio.service"; import { getAppBeatmapFilestore } from "$/setup"; import { finishLoadingMap, jumpToBeat, jumpToEnd, jumpToStart, jumpToTime, pausePlayback, scrollThroughSong, seekBackwards, seekForwards, startPlayback, stopPlayback, tick, togglePlayback, updateCursorPosition, updateSong, updateTimescale } from "$/store/actions"; -import { selectBpm, selectCursorPosition, selectDuration, selectEditorOffset, selectEventsEditorBeatsPerZoomLevel, selectEventsEditorWindowLock, selectPlaying, selectSelectedBeatmap, selectSnap, selectTimeProcessor } from "$/store/selectors"; +import { selectBeatForTime, selectBpm, selectCursorPosition, selectDuration, selectEventsEditorBeatsPerZoomLevel, selectEventsEditorWindowLock, selectPlaying, selectSelectedBeatmap, selectSnap, selectTimeForBeat } from "$/store/selectors"; import type { RootState } from "$/store/setup"; import { type SongId, View } from "$/types"; import { floorToNearest } from "$/utils"; -const selectBeatForTime = createDraftSafeSelector([selectTimeProcessor, selectEditorOffset, (_1: Pick, _2: SongId, time: number) => time], (timeProcessor, offset, time) => { - return timeProcessor.toBeatTime((time - offset) / 1000); -}); -const selectTimeForBeat = createDraftSafeSelector([selectTimeProcessor, selectEditorOffset, (_1: Pick, _2: SongId, beat: number) => beat], (timeProcessor, offset, beat) => { - return timeProcessor.toRealTime(beat) * 1000 + offset; -}); - /** Manages all concerns related to audio playback and timescales. */ export default function createPlaybackMiddleware({ songSample }: { songSample: AudioSample }) { const instance = createListenerMiddleware(); @@ -74,7 +67,7 @@ export default function createPlaybackMiddleware({ songSample }: { songSample: A const onTick = () => { const state = api.getState(); - const currentTime = songSample.getCurrentTime() * 1000; + const currentTime = songSample.getCurrentTime(); const currentBeat = selectBeatForTime(state, songId, currentTime); const duration = selectDuration(state); diff --git a/src/store/selectors.ts b/src/store/selectors.ts index c2b72fc7..b292a50d 100644 --- a/src/store/selectors.ts +++ b/src/store/selectors.ts @@ -65,26 +65,33 @@ export const { export const { selectTimescale } = timeline.getSelectors((state: Pick) => state.timeline); -export const selectTimeProcessor = createDraftSafeSelector([selectBpm, selectEditorOffset, selectTimescale], (bpm, _, timeline) => { +export const selectTimeProcessor = createDraftSafeSelector([selectBpm, selectTimescale], (bpm, timeline) => { return new TimeProcessor(bpm, timeline, 0); }); +export const selectBeatForTime = createDraftSafeSelector([selectTimeProcessor, selectEditorOffset, (_1: Pick, _2: SongId, time: number) => time], (timeProcessor, offset, time) => { + return timeProcessor.toBeatTime(time - offset); +}); +export const selectTimeForBeat = createDraftSafeSelector([selectTimeProcessor, selectEditorOffset, (_1: Pick, _2: SongId, beat: number) => beat], (timeProcessor, offset, beat) => { + return timeProcessor.toRealTime(beat) + offset; +}); + export const { selectPlaying, selectCursorPosition, selectDuration, selectSnap, selectBeatDepth, selectAnimateTrack, selectAnimateEnvironment, selectPlaybackRate, selectSongVolume, selectTickVolume, selectTickType } = navigation.getSelectors((state: RootState) => { return state.navigation; }); -export const selectCursorPositionInBeats = createSelector([selectCursorPosition, selectTimeProcessor, selectEditorOffset], (cursorPosition, timeProcessor, offset) => { - return timeProcessor.toBeatTime((cursorPosition - offset) / 1000); +export const selectCursorPositionInBeats = createSelector([selectTimeProcessor, selectCursorPosition, selectEditorOffset], (timeProcessor, cursorPosition, offset) => { + return timeProcessor.toBeatTime(cursorPosition - offset); }); -export const selectDurationInBeats = createSelector([selectDuration, selectTimeProcessor], (duration, timeProcessor) => { +export const selectDurationInBeats = createSelector([selectTimeProcessor, selectDuration], (timeProcessor, duration) => { if (duration === null) return null; - return timeProcessor.toBeatTime(duration / 1000); + return timeProcessor.toBeatTime(duration); }); -export const selectEditorOffsetInBeats = createSelector([selectEditorOffset, selectTimeProcessor], (offset, timeProcessor) => { - return timeProcessor.toBeatTime(offset / 1000); +export const selectEditorOffsetInBeats = createSelector([selectTimeProcessor, selectEditorOffset], (timeProcessor, offset) => { + return timeProcessor.toBeatTime(offset); }); -export const selectBpmScale = createSelector([selectBpm, selectCursorPositionInBeats, selectTimeProcessor], (baseBpm, currentBeat, timeProcessor) => { +export const selectBpmScale = createSelector([selectTimeProcessor, selectBpm, selectCursorPositionInBeats], (timeProcessor, baseBpm, currentBeat) => { const timescales = timeProcessor.timescale; let activeBpm = baseBpm; @@ -112,7 +119,7 @@ export const { return state.user; }); -export const selectAudioProcessingDelayInBeats = createSelector([selectAudioProcessingDelay, selectTimeProcessor], (processingDelay, timeProcessor) => { +export const selectAudioProcessingDelayInBeats = createSelector([selectTimeProcessor, selectAudioProcessingDelay], (timeProcessor, processingDelay) => { return timeProcessor.toBeatTime(processingDelay / 1000); }); @@ -240,7 +247,7 @@ export const { selectAll: selectFutureColorNotes } = notes.getSelectors( export const selectVisibleNotes = createVisibleObjectsSelector(selectAllColorNotes); export const selectNoteDensity = createSelector(selectAllColorNotes, selectDuration, (notes, duration) => { - return calculateNps({ difficulty: { colorNotes: notes } }, duration ? duration / 1000 : 0); + return calculateNps({ difficulty: { colorNotes: notes } }, duration ?? 0); }); export const { From ac93270a53e119ef3b3f533c0bb470cf945bfd6f Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 14 Jul 2026 20:01:57 -0500 Subject: [PATCH 03/27] compute audio latency directly from audio context --- src/components/app/forms/settings/audio.tsx | 10 +++------- src/store/actions.ts | 2 +- src/store/features/user.slice.ts | 6 ------ src/store/middleware/audio.middleware.ts | 4 ++-- src/store/selectors.ts | 14 ++++---------- src/store/setup.ts | 8 -------- 6 files changed, 10 insertions(+), 34 deletions(-) diff --git a/src/components/app/forms/settings/audio.tsx b/src/components/app/forms/settings/audio.tsx index 9b7eaaf6..ae6bb7dc 100644 --- a/src/components/app/forms/settings/audio.tsx +++ b/src/components/app/forms/settings/audio.tsx @@ -1,9 +1,9 @@ import { createListCollection } from "@ark-ui/react/collection"; -import { Field, FieldInput, FieldSelectGroup } from "$/components/ui/compositions"; -import { updateProcessingDelay, updateTickType } from "$/store/actions"; +import { Field, FieldSelectGroup } from "$/components/ui/compositions"; +import { updateTickType } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectAudioProcessingDelay, selectTickType } from "$/store/selectors"; +import { selectTickType } from "$/store/selectors"; import { Stack, Wrap } from "$:styled-system/jsx"; const TICK_MAP = ["woodblock", "switch"]; @@ -14,7 +14,6 @@ const NOTE_TICK_COLLECTION = createListCollection({ function AppAudioSettings() { const dispatch = useAppDispatch(); - const processingDelay = useAppSelector(selectAudioProcessingDelay); const tickType = useAppSelector(selectTickType); return ( @@ -23,9 +22,6 @@ function AppAudioSettings() { dispatch(updateTickType({ value: TICK_MAP.indexOf(details.valueAsString) }))} /> - - dispatch(updateProcessingDelay({ value: details.valueAsNumber }))} /> - ); diff --git a/src/store/actions.ts b/src/store/actions.ts index 0f828ac0..6c971ebf 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -51,7 +51,7 @@ export const rehydrate = createAction("rehydrate", (args: { songId: SongId; beat return { payload: { ...args } }; }); -export const { updateNew, updateAnnouncements, dismissPrompt, updateUsername, updateProcessingDelay, updateRenderScale, updateBloomEnabled, updateObstaclePlacementMode, updatePacerWait } = user.actions; +export const { updateNew, updateAnnouncements, dismissPrompt, updateUsername, updateRenderScale, updateBloomEnabled, updateObstaclePlacementMode, updatePacerWait } = user.actions; export const startLoadingMap = createAction("startLoadingMap", (args: { songId: SongId; beatmapId: BeatmapId }) => { return { payload: { ...args } }; diff --git a/src/store/features/user.slice.ts b/src/store/features/user.slice.ts index 79d1a106..db0ad033 100644 --- a/src/store/features/user.slice.ts +++ b/src/store/features/user.slice.ts @@ -7,7 +7,6 @@ const initialState = { isNewUser: true, seenPrompts: [] as string[], stickyMapAuthorName: "", - processingDelay: 60, renderScale: 1, isBloomEnabled: true, obstaclePlacementMode: ObstaclePlacementMode.LEGACY as ObstaclePlacementMode, @@ -21,7 +20,6 @@ const slice = createSlice({ selectNew: (state) => state.isNewUser, selectAnnouncements: (state) => state.seenPrompts, selectUsername: (state) => state.stickyMapAuthorName, - selectProcessingDelay: (state) => (typeof state.processingDelay === "number" ? state.processingDelay : initialState.processingDelay), selectRenderScale: (state) => state.renderScale, selectBloomEnabled: (state) => state.isBloomEnabled, selectObstaclePlacementMode: (state) => state.obstaclePlacementMode, @@ -45,10 +43,6 @@ const slice = createSlice({ const { value } = action.payload; return { ...state, stickyMapAuthorName: value }; }), - updateProcessingDelay: api.reducer<{ value: number }>((state, action) => { - const { value } = action.payload; - return { ...state, processingDelay: value }; - }), updateRenderScale: api.reducer<{ value: number }>((state, action) => { const { value } = action.payload; return { ...state, renderScale: value }; diff --git a/src/store/middleware/audio.middleware.ts b/src/store/middleware/audio.middleware.ts index f378ecb6..153b9c3d 100644 --- a/src/store/middleware/audio.middleware.ts +++ b/src/store/middleware/audio.middleware.ts @@ -6,13 +6,13 @@ import { getRouter } from "$/router"; import type { AudioSample } from "$/services/audio.service"; import { getAppBeatmapFilestore } from "$/setup"; import { decrementPlaybackRate, finishLoadingMap, incrementPlaybackRate, pausePlayback, startPlayback, stopPlayback, tick, updateCursorPosition, updatePlaybackRate, updateSong, updateSongVolume, updateTickType, updateTickVolume } from "$/store/actions"; -import { selectAllColorNotes, selectAudioProcessingDelayInBeats, selectCursorPosition, selectPlaybackRate, selectTickVolume } from "$/store/selectors"; +import { selectAllColorNotes, selectAudioLatencyInBeats, selectCursorPosition, selectPlaybackRate, selectTickVolume } from "$/store/selectors"; import type { RootState } from "$/store/setup"; import { type SongId, View } from "$/types"; function getTickSchedule(state: RootState, songId: SongId): number[] { const notes = selectAllColorNotes(state); - const delayInBeats = selectAudioProcessingDelayInBeats(state, songId); + const delayInBeats = selectAudioLatencyInBeats(state, songId); return notes.map((note) => note.time - delayInBeats).sort((a, b) => a - b); } diff --git a/src/store/selectors.ts b/src/store/selectors.ts index b292a50d..b752dcc3 100644 --- a/src/store/selectors.ts +++ b/src/store/selectors.ts @@ -6,6 +6,7 @@ import { DEFAULT_GRID } from "$/constants"; import { calculateVisibleRange } from "$/helpers/editor.helpers"; import { isLightEffectActive, resolveBasicEventColor, resolveBasicEventEffect } from "$/helpers/events.helpers"; import { getGridSize } from "$/helpers/song.helpers"; +import { getAudioContext } from "$/setup"; import { type App, type BeatmapId, type ILightState, NotePlacementMode, ObjectTool, ObstaclePlacementMode, type SongId, View } from "$/types"; import { floorToNearest } from "$/utils"; import clipboard from "./features/clipboard.slice"; @@ -110,7 +111,6 @@ export const { selectNew, selectAnnouncements, selectUsername, - selectProcessingDelay: selectAudioProcessingDelay, selectRenderScale, selectBloomEnabled, selectObstaclePlacementMode: selectUserObstaclePlacementMode, @@ -119,17 +119,11 @@ export const { return state.user; }); -export const selectAudioProcessingDelayInBeats = createSelector([selectTimeProcessor, selectAudioProcessingDelay], (timeProcessor, processingDelay) => { - return timeProcessor.toBeatTime(processingDelay / 1000); +export const selectAudioLatencyInBeats = createSelector([selectTimeProcessor], (timeProcessor) => { + const { baseLatency } = getAudioContext(); + return timeProcessor.toBeatTime(baseLatency); }); -export const selectUsableAudioProcessingDelay = createSelector(selectAudioProcessingDelay, selectPlaying, (processingDelay, isPlaying) => { - // If we're not playing the track, we shouldn't have any processing delay. This is to prevent stuff from firing prematurely when scrubbing. - return isPlaying ? processingDelay : 0; -}); -export const selectUsableAudioProcessingDelayInBeats = createSelector(selectAudioProcessingDelayInBeats, selectPlaying, (processingDelay, isPlaying) => { - return isPlaying ? processingDelay : 0; -}); export const selectSurfaceDepth = createSelector(selectRenderScale, (renderScale) => { return Math.max(renderScale * 75, 25); }); diff --git a/src/store/setup.ts b/src/store/setup.ts index 36b33449..6ef15e74 100644 --- a/src/store/setup.ts +++ b/src/store/setup.ts @@ -37,7 +37,6 @@ import { updateObstaclePlacementMode, updatePacerWait, updatePlaybackRate, - updateProcessingDelay, updateRenderScale, updateSnap, updateSongVolume, @@ -51,7 +50,6 @@ import { createAllSharedMiddleware } from "./middleware"; import { selectAllGridPresetIds, selectAnnouncements, - selectAudioProcessingDelay, selectBeatDepth, selectBloomEnabled, selectDefaultObstacleDuration, @@ -109,7 +107,6 @@ const createAppEntityStorageDriver = createDriver(snapshot.songs.byId)) { await idb.set( "songs", @@ -170,7 +167,6 @@ export async function createAppStore() { "user.new": boolean; "user.announcements": string[]; "user.username": string; - "audio.offset": number; "graphics.scale": number; "graphics.bloom": boolean; "controls.obstacles": number; @@ -191,10 +187,6 @@ export async function createAppStore() { selectValue: (state) => selectUsername(state) ?? "", hydrateValue: (value) => updateUsername({ value }), }, - "audio.offset": { - selectValue: selectAudioProcessingDelay, - hydrateValue: (value) => updateProcessingDelay({ value }), - }, "graphics.scale": { selectValue: selectRenderScale, hydrateValue: (value) => updateRenderScale({ value }), From 20b336425402deee629632d811a57870ab836f11 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Wed, 15 Jul 2026 19:06:06 -0500 Subject: [PATCH 04/27] add shallow apis (and explicit types) for thunk action creators --- src/store/actions.ts | 32 +++++++++++------------ src/store/setup.ts | 9 +++++-- src/store/utils.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 18 deletions(-) create mode 100644 src/store/utils.ts diff --git a/src/store/actions.ts b/src/store/actions.ts index 6c971ebf..91858bd6 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,4 +1,4 @@ -import { createAction, createAsyncThunk } from "@reduxjs/toolkit"; +import { createAction, createAsyncThunk, type GetThunkAPI } from "@reduxjs/toolkit"; import type { EnvironmentName, ITrackDefinitions } from "bsmap"; import { HIGHEST_PRECISION } from "$/constants"; @@ -24,7 +24,7 @@ import { selectPlaying, selectSnap, } from "./selectors"; -import type { RootState } from "./setup"; +import type { AppThunkApiConfig, RootState } from "./setup"; // biome-ignore-start assist/source/organizeImports: circular dependencies @@ -149,8 +149,8 @@ export const { updateMirrorLock: updateEventsEditorMirrorLock, } = lightshow.actions; -export const drawEventSelectionBox = createAsyncThunk("drawEventSelectionBox", (args: { songId: SongId; tracks: ITrackDefinitions; selectionBoxInBeats: ISelectionBoxInBeats }, api) => { - const state = api.getState() as RootState; +export const drawEventSelectionBox = createAsyncThunk("drawEventSelectionBox", (args: { songId: SongId; tracks: ITrackDefinitions; selectionBoxInBeats: ISelectionBoxInBeats }, api: GetThunkAPI) => { + const state = api.getState(); const { startBeat, endBeat } = selectEventEditorStartAndEndBeat(state, args.songId); const metadata = { window: { startBeat, endBeat } }; return api.fulfillWithValue({ ...args, metadata }); @@ -164,8 +164,8 @@ export const cycleToPrevTool = createAction("cycleToPrevTool", (args: { view: Vi return { payload: { ...args } }; }); -export const addToCell = createAsyncThunk("addToCell", (args: { songId: SongId; posX: number; posY: number; direction?: number; tool: ObjectTool }, api) => { - const state = api.getState() as RootState; +export const addToCell = createAsyncThunk("addToCell", (args: { songId: SongId; posX: number; posY: number; direction?: number; tool: ObjectTool }, api: GetThunkAPI) => { + const state = api.getState(); const selectedDirection = args.direction ?? selectNotesEditorDirection(state); const selectedTool = selectNotesEditorTool(state); const cursorPositionInBeats = selectCursorPositionInBeats(state, args.songId); @@ -194,8 +194,8 @@ export const addToCell = createAsyncThunk("addToCell", (args: { songId: SongId; return api.fulfillWithValue({ query: { time: adjustedCursorPosition, posX: args.posX, posY: args.posY }, direction: selectedDirection, tool: selectedTool }); }); -export const removeFromCell = createAsyncThunk("removeFromCell", (args: { songId: SongId; posX: number; posY: number; tool: ObjectTool }, api) => { - const state = api.getState() as RootState; +export const removeFromCell = createAsyncThunk("removeFromCell", (args: { songId: SongId; posX: number; posY: number; tool: ObjectTool }, api: GetThunkAPI) => { + const state = api.getState(); const cursorPositionInBeats = selectCursorPositionInBeats(state, args.songId); if (cursorPositionInBeats === null) return api.rejectWithValue("Invalid beat number."); return api.fulfillWithValue({ query: { time: cursorPositionInBeats, posX: args.posX, posY: args.posY } }); @@ -229,8 +229,8 @@ export const finishManagingNoteSelection = createAction("finishManagingNoteSelec export const { addOne: addObstacle, updateOne: updateObstacle, selectOne: selectObstacle, deselectOne: deselectObstacle, updateAllSelected: updateAllSelectedObstacles, removeOne: removeObstacle } = obstacles.actions; -export const selectAllEntities = createAsyncThunk("selectAllEntities", (args: { songId: SongId; view: View }, api) => { - const state = api.getState() as RootState; +export const selectAllEntities = createAsyncThunk("selectAllEntities", (args: { songId: SongId; view: View }, api: GetThunkAPI) => { + const state = api.getState(); // For the events view, we don't actually want to select EVERY note. We only want to select what is visible in the current frame. let metadata = null; if (args.view === View.LIGHTSHOW) { @@ -244,8 +244,8 @@ export const deselectAllEntities = createAction("deselectAllEntities", (args: { return { payload: { ...args } }; }); -export const toggleSelectAllEntities = createAsyncThunk("toggleSelectAllEntities", (args: { songId: SongId; view: View }, api) => { - const state = api.getState() as RootState; +export const toggleSelectAllEntities = createAsyncThunk("toggleSelectAllEntities", (args: { songId: SongId; view: View }, api: GetThunkAPI) => { + const state = api.getState(); let anythingSelected = false; @@ -278,8 +278,8 @@ export const mirrorSelection = createAction("mirrorSelection", (args: { axis: "h return { payload: { ...args } }; }); -export const nudgeSelection = createAsyncThunk("nudgeSelection", (args: { direction: "forwards" | "backwards"; view: View }, api) => { - const state = api.getState() as RootState; +export const nudgeSelection = createAsyncThunk("nudgeSelection", (args: { direction: "forwards" | "backwards"; view: View }, api: GetThunkAPI) => { + const state = api.getState(); const snapTo = selectSnap(state); return api.fulfillWithValue({ ...args, amount: snapTo }); }); @@ -324,8 +324,8 @@ export const redoEvents = createAction("redoEvents", (args: { songId: SongId }) export const { cutSelection, copySelection } = clipboard.actions; -export const pasteSelection = createAsyncThunk("pasteSelection", (args: { songId: SongId; view: View }, api) => { - const state = api.getState() as RootState; +export const pasteSelection = createAsyncThunk("pasteSelection", (args: { songId: SongId; view: View }, api: GetThunkAPI) => { + const state = api.getState(); const data = selectClipboardData(state); // If there's nothing copied, do nothing if (!data) return api.rejectWithValue("Clipboard is empty."); diff --git a/src/store/setup.ts b/src/store/setup.ts index 6ef15e74..ffaa8e9f 100644 --- a/src/store/setup.ts +++ b/src/store/setup.ts @@ -1,6 +1,6 @@ // biome-ignore-all lint/suspicious/noExplicitAny: type validations for migration steps are not super necessary -import { configureStore, type DevToolsEnhancerOptions } from "@reduxjs/toolkit"; +import { configureStore, type DevToolsEnhancerOptions, type ThunkDispatch, type UnknownAction } from "@reduxjs/toolkit"; import { omit } from "@std/collections/omit"; import { toPascalCase } from "@std/text/to-pascal-case"; import type { NoteDirection } from "bsmap"; @@ -347,4 +347,9 @@ export async function createAppStore() { } export type RootState = ReturnType; -export type AppDispatch = Awaited>["dispatch"]; +export type AppDispatch = ThunkDispatch; + +export interface AppThunkApiConfig { + state: RootState; + dispatch: AppDispatch; +} diff --git a/src/store/utils.ts b/src/store/utils.ts new file mode 100644 index 00000000..5161da9b --- /dev/null +++ b/src/store/utils.ts @@ -0,0 +1,62 @@ +import type { ActionCreatorWithPayload, GetState, SerializedError, ThunkAction, ThunkDispatch, UnknownAction } from "@reduxjs/toolkit"; + +type IsAny = true | false extends (T extends never ? true : false) ? True : False; +type IsUnknown = unknown extends T ? IsAny : False; +type FallbackIfUnknown = IsUnknown; + +type GetDispatch = ThunkApiConfig extends { dispatch: infer Dispatch } ? FallbackIfUnknown, GetExtra, UnknownAction>> : ThunkDispatch, GetExtra, UnknownAction>; +type GetExtra = ThunkApiConfig extends { extra: infer Extra } ? Extra : unknown; +type GetMeta = ThunkApiConfig extends { meta: infer Meta } ? Meta : unknown; +type GetSerializedErrorType = ThunkApiConfig extends { serializedErrorType: infer TError } ? TError : SerializedError; + +type GetArgs = [undefined] extends [Arg] ? [_?: Arg] : [arg: Arg]; + +export interface GetShallowThunkAPI { + dispatch: GetDispatch; + getState: () => GetState; + extra: GetExtra; +} + +export interface ThunkActionCreator extends ActionCreatorWithPayload { + (...args: GetArgs): ThunkAction, GetExtra, UnknownAction>; +} + +export interface ThunkOptions { + condition?: (arg: Arg, api: Pick, "getState" | "extra">) => boolean | undefined; + getMeta?: (arg: Arg, api: Pick, "getState" | "extra">) => GetMeta; + serializeError?: (x: unknown) => GetSerializedErrorType; +} + +export function createThunk(type: string, payloadCreator: (arg: Arg, api: GetShallowThunkAPI) => Returned, options?: ThunkOptions): ThunkActionCreator { + const actionCreator = (...args: GetArgs) => { + const arg = args[0] as Arg; + + return (dispatch: GetDispatch, getState: () => GetState, extra: GetExtra) => { + const api: GetShallowThunkAPI = { dispatch, getState, extra }; + + if (options?.condition && !options.condition(arg, api)) { + return; + } + + const meta = options?.getMeta?.(arg, api); + + try { + const payload = payloadCreator(arg, api); + dispatch({ type, payload, meta }); + return { type, meta, payload }; + } catch (error) { + const serializedError = options?.serializeError?.(error) ?? error; + dispatch({ type, error: serializedError, meta }); + return { type, meta, error: serializedError }; + } + }; + }; + + return Object.assign(actionCreator, { + type: type, + toString: () => type, + match: (action: UnknownAction): action is ReturnType> => { + return action?.type === type; + }, + }) as ThunkActionCreator; +} From 710c7baadc4985d2ddadff1ed7bffd0808b32ca0 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Wed, 15 Jul 2026 19:16:15 -0500 Subject: [PATCH 05/27] add dedicated apis for matched route selectors --- src/router.ts | 28 +++++++++++++++++++++++++++- src/store/setup.ts | 15 +++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/router.ts b/src/router.ts index 7cb79480..a037221c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,4 +1,4 @@ -import { createRouter } from "@tanstack/react-router"; +import { createRouter, useParams, type MakeRouteMatch, type Register, type RouteIds } from "@tanstack/react-router"; import ErrorBoundary from "./components/app/templates/error-boundary"; import PendingBoundary from "./components/app/templates/pending-boundary"; @@ -18,3 +18,29 @@ declare module "@tanstack/react-router" { router: ReturnType; } } + +export function createRouteSelector, T>(options: { from: TFrom; selector: (route: MakeRouteMatch) => T }) { + return (router: Register["router"]) => { + const match = router.state.matches.find((m) => m.routeId === options.from); + + if (!match) { + throw new Error(`Tried to access state from unmatched route: ${options.from}`); + } + return options.selector(match as unknown as MakeRouteMatch); + }; +} + +export const selectActiveSongId = createRouteSelector({ + from: "/_/edit/$sid/$bid/_", + selector: ({ params }) => params.sid, +}); +export const selectActiveBeatmapId = createRouteSelector({ + from: "/_/edit/$sid/$bid/_", + selector: ({ params }) => params.bid, +}); +export const selectActiveView = createRouteSelector({ + from: "/_/edit/$sid/$bid/_", + selector: ({ context }) => context.view, +}); + +useParams; diff --git a/src/store/setup.ts b/src/store/setup.ts index ffaa8e9f..7e69d73d 100644 --- a/src/store/setup.ts +++ b/src/store/setup.ts @@ -79,6 +79,12 @@ import { selectUserObstaclePlacementMode, } from "./selectors"; +// biome-ignore-start assist/source/organizeImports: circular dependencies + +import { getRouter } from "$/router"; + +// biome-ignore-end assist/source/organizeImports: circular dependencies + const STORAGE_PREFIX = location.hostname === "localhost" ? "beatmapper" : ""; /** @deprecated this is really only used during migration flow, don't use this elsewhere */ @@ -159,6 +165,10 @@ const createAppEntityStorageDriver = createDriver { - return getDefaultMiddleware().concat(createAllSharedMiddleware()); + return getDefaultMiddleware({ thunk: { extraArgument: { getRouter } } }).concat(createAllSharedMiddleware()); }, enhancers: (getDefaultEnhancers) => { return getDefaultEnhancers().concat(localStorageEnhancer, sessionStorageEnhancer, songStorageEnhancer, gridStorageEnhancer); @@ -347,9 +357,10 @@ export async function createAppStore() { } export type RootState = ReturnType; -export type AppDispatch = ThunkDispatch; +export type AppDispatch = ThunkDispatch; export interface AppThunkApiConfig { state: RootState; dispatch: AppDispatch; + extra: AppExtraArgs; } From a23afaa79eead523c3b2c8d77ee2a0f5bf10d4e4 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Mon, 20 Jul 2026 12:11:49 -0500 Subject: [PATCH 06/27] simplify payloads for atomic update actions/hydrators --- src/components/app/forms/settings/audio.tsx | 2 +- .../app/forms/settings/controls.tsx | 2 +- .../app/forms/settings/graphics.tsx | 4 +- src/components/app/forms/settings/user.tsx | 2 +- .../editor/action-panel-groups/direction.tsx | 18 ++-- .../editor/action-panel-groups/tool.tsx | 8 +- .../templates/editor/navigation-controls.tsx | 2 +- .../app/templates/editor/status-bar.tsx | 18 ++-- .../app/templates/events/controls.tsx | 10 +-- src/components/app/templates/events/grid.tsx | 2 +- .../app/templates/shortcuts/default.tsx | 2 +- .../app/templates/shortcuts/events.tsx | 18 ++-- .../app/templates/shortcuts/notes.tsx | 56 ++++++------ src/routes/editor/layout.tsx | 5 +- src/store/actions.ts | 4 +- src/store/enhancers/storage.enhancer.ts | 8 ++ src/store/features/editor/beatmap.slice.ts | 15 ++-- src/store/features/editor/lightshow.slice.ts | 47 ++++------ src/store/features/navigation.slice.ts | 30 +++---- src/store/features/user.slice.ts | 40 +++------ src/store/middleware/audio.middleware.ts | 16 ++-- src/store/setup.ts | 90 +++++++++---------- 22 files changed, 188 insertions(+), 211 deletions(-) diff --git a/src/components/app/forms/settings/audio.tsx b/src/components/app/forms/settings/audio.tsx index ae6bb7dc..657a6e5b 100644 --- a/src/components/app/forms/settings/audio.tsx +++ b/src/components/app/forms/settings/audio.tsx @@ -20,7 +20,7 @@ function AppAudioSettings() { - dispatch(updateTickType({ value: TICK_MAP.indexOf(details.valueAsString) }))} /> + 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/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/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..dfb1df6f 100644 --- a/src/components/app/templates/editor/navigation-controls.tsx +++ b/src/components/app/templates/editor/navigation-controls.tsx @@ -45,7 +45,7 @@ function EditorNavigationControls() { return ( - dispatch(updateSnap(Number.parseFloat(ev.value[0])))} /> - diff --git a/src/components/app/templates/shortcuts/events.tsx b/src/components/app/templates/shortcuts/events.tsx index 18a3672f..389f9991 100644 --- a/src/components/app/templates/shortcuts/events.tsx +++ b/src/components/app/templates/shortcuts/events.tsx @@ -3,7 +3,7 @@ import { useCallback } from "react"; import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; import { usePrompter } from "$/components/ui/compositions"; -import { decrementEventsEditorZoom, incrementEventsEditorZoom, toggleSelectAllEntities, updateEventsEditorColor, updateEventsEditorEditMode, updateEventsEditorMirrorLock, updateEventsEditorTool, updateEventsEditorWindowLock } from "$/store/actions"; +import { decrementEventsEditorZoomLevel, incrementEventsEditorZoomLevel, toggleSelectAllEntities, updateEventsEditorColor, updateEventsEditorEditMode, updateEventsEditorMirrorLock, updateEventsEditorTool, updateEventsEditorWindowLock } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectLoading } from "$/store/selectors"; import { EventColor, EventEditMode, EventTool } from "$/types"; @@ -29,13 +29,13 @@ function EventsEditorShortcuts() { case "Minus": { if (metaKeyPressed) return; ev.preventDefault(); - return dispatch(decrementEventsEditorZoom()); + return dispatch(decrementEventsEditorZoomLevel()); } case "NumpadAdd": case "Equal": { if (metaKeyPressed) return; ev.preventDefault(); - return dispatch(incrementEventsEditorZoom()); + return dispatch(incrementEventsEditorZoomLevel()); } case "KeyA": { if (metaKeyPressed) { diff --git a/src/constants/editor.constants.ts b/src/constants/editor.constants.ts index 91f3825d..7d29a313 100644 --- a/src/constants/editor.constants.ts +++ b/src/constants/editor.constants.ts @@ -30,6 +30,8 @@ export const SNAPPING_INCREMENTS = [ { value: 16, label: "4 Bars", shortcutKey: 9 } as const, ]; +export const SNAPPING_INCREMENT_VALUES = SNAPPING_INCREMENTS.map((x) => x.value); + export const HIGHEST_PRECISION = SNAPPING_INCREMENTS[0].value; export const BEATS_PER_ZOOM_LEVEL = [32, 16, 8, 4, 2] as const; diff --git a/src/store/actions.ts b/src/store/actions.ts index 1e54485a..ffe3d2ca 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,7 +1,7 @@ import { createAction, createAsyncThunk, type GetThunkAPI } from "@reduxjs/toolkit"; import type { EnvironmentName, ITrackDefinitions } from "bsmap"; -import { HIGHEST_PRECISION } from "$/constants"; +import { HIGHEST_PRECISION, SNAPPING_INCREMENT_VALUES, ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN } from "$/constants"; import type { resolveEventId } from "$/helpers/events.helpers"; import type { resolveNoteId } from "$/helpers/notes.helpers"; import type { ExportMapArchiveOptions } from "$/services/packaging.service"; @@ -19,12 +19,17 @@ import { selectEarliestBeat, selectEventEditorStartAndEndBeat, selectEventsEditorCursor, + selectEventsEditorZoomLevel, selectNotesEditorDirection, selectNotesEditorTool, + selectPlaybackRate, selectPlaying, selectSnap, + selectSongVolume, + selectTickVolume, } from "./selectors"; import type { AppThunkApiConfig, RootState } from "./setup"; +import { createIncrementByIndexPayloadActionCreator, createIncrementByValuePayloadActionCreator, createThunk, type GetShallowThunkAPI } from "./utils"; // biome-ignore-start assist/source/organizeImports: circular dependencies @@ -116,16 +121,56 @@ export const { scrollThroughSong, updateTrackScale, updatePlaybackRate, - incrementPlaybackRate, - decrementPlaybackRate, updateSongVolume, updateTickVolume, updateTickType, updateSnap, - incrementSnap, - decrementSnap, } = navigation.actions; +const createIncrementSnap = createIncrementByIndexPayloadActionCreator(SNAPPING_INCREMENT_VALUES, { + select: selectSnap, + update: updateSnap, +}); +export const incrementSnap = createThunk("incrementSnap", (_, api: GetShallowThunkAPI) => { + return createIncrementSnap({ delta: 1 }, api); +}); +export const decrementSnap = createThunk("decrementSnap", (_, api: GetShallowThunkAPI) => { + return createIncrementSnap({ delta: -1 }, api); +}); + +const createIncrementPlaybackRate = createIncrementByValuePayloadActionCreator([0, 2], { + select: selectPlaybackRate, + update: updatePlaybackRate, +}); +export const incrementPlaybackRate = createThunk("incrementPlaybackRate", (_, api: GetShallowThunkAPI) => { + return createIncrementPlaybackRate({ delta: 0.25 }, api); +}); +export const decrementPlaybackRate = createThunk("decrementPlaybackRate", (_, api: GetShallowThunkAPI) => { + return createIncrementPlaybackRate({ delta: -0.25 }, api); +}); + +const createIncrementSongVolume = createIncrementByValuePayloadActionCreator([0, 1], { + select: selectSongVolume, + update: updateSongVolume, +}); +export const incrementSongVolume = createThunk("incrementSongVolume", (_, api: GetShallowThunkAPI) => { + return createIncrementSongVolume({ delta: 0.125 }, api); +}); +export const decrementSongVolume = createThunk("decrementSongVolume", (_, api: GetShallowThunkAPI) => { + return createIncrementSongVolume({ delta: -0.125 }, api); +}); + +const createIncrementTickVolume = createIncrementByValuePayloadActionCreator([0, 1], { + select: selectTickVolume, + update: updateTickVolume, +}); +export const incrementTickVolume = createThunk("incrementTickVolume", (_, api: GetShallowThunkAPI) => { + return createIncrementTickVolume({ delta: 0.125 }, api); +}); +export const decrementTickVolume = createThunk("decrementTickVolume", (_, api: GetShallowThunkAPI) => { + return createIncrementTickVolume({ delta: -0.125 }, api); +}); + export const { reloadVisualizer, updateZoom: zoomVisualizer } = visualizer.actions; export const { updateTool: updateNotesEditorTool, updateDirection: updateNotesEditorDirection, updateDefaultObstacleDuration: updateNotesEditorDefaultObstacleDuration, hydrateGridPresets, upsertGridPreset: saveGridPreset, removeGridPreset } = beatmap.actions; @@ -142,13 +187,22 @@ export const { updateTrackHeight: updateEventsEditorTrackHeight, updateTrackOpacity: updateEventsEditorTrackOpacity, updateZoomLevel: updateEventsEditorZoomLevel, - incrementZoom: incrementEventsEditorZoom, - decrementZoom: decrementEventsEditorZoom, updatePreview: updateEventsEditorPreview, updateWindowLock: updateEventsEditorWindowLock, updateMirrorLock: updateEventsEditorMirrorLock, } = lightshow.actions; +const createIncrementZoomLevel = createIncrementByValuePayloadActionCreator([ZOOM_LEVEL_MIN, ZOOM_LEVEL_MAX], { + select: selectEventsEditorZoomLevel, + update: updateEventsEditorZoomLevel, +}); +export const incrementEventsEditorZoomLevel = createThunk("incrementZoomLevel", (_, api: GetShallowThunkAPI) => { + return createIncrementZoomLevel({ delta: 1 }, api); +}); +export const decrementEventsEditorZoomLevel = createThunk("decrementZoomLevel", (_, api: GetShallowThunkAPI) => { + return createIncrementZoomLevel({ delta: -1 }, api); +}); + export const drawEventSelectionBox = createAsyncThunk("drawEventSelectionBox", (args: { songId: SongId; tracks: ITrackDefinitions; selectionBoxInBeats: ISelectionBoxInBeats }, api: GetThunkAPI) => { const state = api.getState(); const { startBeat, endBeat } = selectEventEditorStartAndEndBeat(state, args.songId); diff --git a/src/store/features/editor/lightshow.slice.ts b/src/store/features/editor/lightshow.slice.ts index c0958ee7..8b48849c 100644 --- a/src/store/features/editor/lightshow.slice.ts +++ b/src/store/features/editor/lightshow.slice.ts @@ -1,6 +1,6 @@ -import { createSlice, isAnyOf, type ReducerCreators } from "@reduxjs/toolkit"; +import { createSlice, isAnyOf } from "@reduxjs/toolkit"; -import { BEATS_PER_ZOOM_LEVEL, ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN } from "$/constants"; +import { BEATS_PER_ZOOM_LEVEL } from "$/constants"; import { cycleToNextTool, cycleToPrevTool } from "$/store/actions"; import { EventColor, EventEditMode, EventTool, View } from "$/types"; @@ -21,12 +21,6 @@ const initialState = { selectedColor: EVENT_COLORS[0], }; -function updateZoomLevel(api: ReducerCreators, update: (current: number) => number) { - return api.reducer((state) => { - return { ...state, zoomLevel: update(state.zoomLevel) }; - }); -} - const slice = createSlice({ name: "events", initialState: initialState, @@ -76,8 +70,6 @@ const slice = createSlice({ updateZoomLevel: api.reducer((state, action) => { return { ...state, zoomLevel: action.payload }; }), - incrementZoom: updateZoomLevel(api, (current) => Math.min(ZOOM_LEVEL_MAX, current + 1)), - decrementZoom: updateZoomLevel(api, (current) => Math.max(ZOOM_LEVEL_MIN, current - 1)), }; }, extraReducers: (builder) => { diff --git a/src/store/features/navigation.slice.ts b/src/store/features/navigation.slice.ts index 24b00cf9..282062fa 100644 --- a/src/store/features/navigation.slice.ts +++ b/src/store/features/navigation.slice.ts @@ -1,6 +1,5 @@ -import { isAnyOf, type ReducerCreators } from "@reduxjs/toolkit"; +import { isAnyOf } from "@reduxjs/toolkit"; -import { SNAPPING_INCREMENTS } from "$/constants"; import { leaveEditor, reloadVisualizer, scrollThroughSong, selectAllEntitiesInRange, updateSong } from "$/store/actions"; import { createSlice } from "$/store/helpers"; import type { SongId } from "$/types"; @@ -37,19 +36,6 @@ const slice = createSlice({ selectTickType: (state) => state.tickType, }, reducers: (api) => { - function nextSnappingIncrement(api: ReducerCreators, options: { delta: number }) { - return api.reducer((state) => { - const currentSnappingIncrementIndex = SNAPPING_INCREMENTS.findIndex((increment) => increment.value === state.snapTo); - // This shouldn't be possible, but if somehow we don't have a recognized interval, just reset to 1. - if (currentSnappingIncrementIndex === -1) return { ...state, snapTo: 1 }; - const nextSnappingIndex = currentSnappingIncrementIndex + options.delta; - const nextSnappingIncrement = SNAPPING_INCREMENTS[nextSnappingIndex]; - // If we're at one end of the scale and we try to push beyond it, we'll hit an undefined. Do nothing in those cases (no wrapping around desired). - if (!nextSnappingIncrement) return state; - return { ...state, snapTo: nextSnappingIncrement.value }; - }); - } - return { updateCursorPosition: api.reducer<{ value: number }>((state, action) => { const { value } = action.payload; @@ -97,20 +83,12 @@ const slice = createSlice({ updateSnap: api.reducer((state, action) => { return { ...state, snapTo: action.payload }; }), - incrementSnap: nextSnappingIncrement(api, { delta: 1 }), - decrementSnap: nextSnappingIncrement(api, { delta: -1 }), updateTrackScale: api.reducer((state, action) => { return { ...state, beatDepth: action.payload, animateBlockMotion: false }; }), updatePlaybackRate: api.reducer((state, action) => { return { ...state, playbackRate: action.payload }; }), - incrementPlaybackRate: api.reducer((state) => { - return { ...state, playbackRate: Math.min(state.playbackRate + 0.25, 2) }; - }), - decrementPlaybackRate: api.reducer((state) => { - return { ...state, playbackRate: Math.max(state.playbackRate - 0.25, 0) }; - }), updateSongVolume: api.reducer((state, action) => { return { ...state, songVolume: action.payload }; }), diff --git a/src/store/utils.ts b/src/store/utils.ts index 5161da9b..513516c4 100644 --- a/src/store/utils.ts +++ b/src/store/utils.ts @@ -1,4 +1,6 @@ -import type { ActionCreatorWithPayload, GetState, SerializedError, ThunkAction, ThunkDispatch, UnknownAction } from "@reduxjs/toolkit"; +import type { ActionCreatorWithPayload, Dispatch, GetState, SerializedError, ThunkAction, ThunkDispatch, UnknownAction } from "@reduxjs/toolkit"; + +import { cycle } from "$/utils"; type IsAny = true | false extends (T extends never ? true : false) ? True : False; type IsUnknown = unknown extends T ? IsAny : False; @@ -60,3 +62,19 @@ export function createThunk(type: string, payload }, }) as ThunkActionCreator; } + +export function createIncrementByIndexPayloadActionCreator(iterable: Iterable, options: { select: (state: TState) => TValue; update: (value: TValue) => UnknownAction }) { + const values = Object.values(iterable); + + return (args: { delta: number }, api: { getState: () => TState; dispatch: Dispatch }) => { + const value = options.select(api.getState()); + api.dispatch(options.update(cycle(values, value, args.delta, "stop"))); + }; +} +export function createIncrementByValuePayloadActionCreator([min, max]: [number, number], options: { select: (state: TState) => number; update: (value: number) => UnknownAction }) { + return (args: { delta: number }, api: { getState: () => TState; dispatch: Dispatch }) => { + const value = options.select(api.getState()); + const isIncrement = args.delta > 0; + api.dispatch(options.update((isIncrement ? Math.min : Math.max)(value + args.delta, isIncrement ? max : min))); + }; +} diff --git a/src/utils/array.utils.ts b/src/utils/array.utils.ts index 0db1a718..06e12a85 100644 --- a/src/utils/array.utils.ts +++ b/src/utils/array.utils.ts @@ -23,8 +23,21 @@ export function difference(arr1: T[], arr2: T[], comparator = (x: T) = return uniques; } -export function cycle(arr: T[], current: T, step = 1) { +export function cycle(arr: T[], current: T, delta = 1, mode: "loop" | "stop" = "loop") { const index = arr.indexOf(current); - const newIndex = (index + step) % arr.length; - return arr[newIndex >= 0 ? newIndex : newIndex + arr.length]; + + if (index === -1) { + return arr[0]; + } + + const targetIndex = index + delta; + + switch (mode) { + case "stop": { + return arr[Math.max(0, Math.min(arr.length - 1, targetIndex))]; + } + default: { + return arr[((targetIndex % arr.length) + arr.length) % arr.length]; + } + } } From 33128e9263f528039cdab23c8230210a657018a0 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Wed, 22 Jul 2026 13:30:58 -0500 Subject: [PATCH 08/27] add declarative types and implicit route accessors for listener middleware --- src/store/middleware/audio.middleware.ts | 13 +++++++------ src/store/middleware/backup.middleware.ts | 5 +++-- src/store/middleware/demo.middleware.ts | 10 +++++++--- src/store/middleware/file.middleware.ts | 5 +++-- src/store/middleware/history.middleware.ts | 10 +++++----- src/store/middleware/packaging.middleware.ts | 5 +++-- src/store/middleware/playback.middleware.ts | 17 +++++++++-------- 7 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/store/middleware/audio.middleware.ts b/src/store/middleware/audio.middleware.ts index cbd3b5b8..544a98e5 100644 --- a/src/store/middleware/audio.middleware.ts +++ b/src/store/middleware/audio.middleware.ts @@ -2,12 +2,12 @@ import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/ import { NOTE_TICK_TYPES } from "$/constants"; import { convertFileToArrayBuffer } from "$/helpers/file.helpers"; -import { getRouter } from "$/router"; +import { getRouter, selectActiveView } from "$/router"; import type { AudioSample } from "$/services/audio.service"; import { getAppBeatmapFilestore } from "$/setup"; import { decrementPlaybackRate, finishLoadingMap, incrementPlaybackRate, pausePlayback, startPlayback, stopPlayback, tick, updateCursorPosition, updatePlaybackRate, updateSong, updateSongVolume, updateTickType, updateTickVolume } from "$/store/actions"; import { selectAllColorNotes, selectAudioLatencyInBeats, selectCursorPosition, selectPlaybackRate, selectSongVolume, selectTickVolume } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/setup"; import { type SongId, View } from "$/types"; function getTickSchedule(state: RootState, songId: SongId): number[] { @@ -18,10 +18,11 @@ function getTickSchedule(state: RootState, songId: SongId): number[] { /** Manages all concerns related to audio samples. */ export default function createAudioMiddleware({ songSample, tickSample }: { songSample: AudioSample; tickSample: AudioSample }) { - const instance = createListenerMiddleware(); + const instance = createListenerMiddleware({ + extra: { getRouter }, + }); const filestore = getAppBeatmapFilestore(); - const router = getRouter(); tickSample.load(NOTE_TICK_TYPES[0]); @@ -57,9 +58,9 @@ export default function createAudioMiddleware({ songSample, tickSample }: { song const { lastBeat, currentBeat } = action.payload; if (selectTickVolume(api.getState()) > 0) { - const { context } = router.state.matches[router.state.matches.length - 1]; + const view = selectActiveView(api.extra.getRouter()); - if ("view" in context && (context.view === View.PREVIEW || context.view === View.BEATMAP)) { + if (view === View.PREVIEW || view === View.BEATMAP) { if (tickSchedule.some((t) => t >= lastBeat && t < currentBeat)) { tickSample.trigger(); } diff --git a/src/store/middleware/backup.middleware.ts b/src/store/middleware/backup.middleware.ts index 01648f55..cfd41b9d 100644 --- a/src/store/middleware/backup.middleware.ts +++ b/src/store/middleware/backup.middleware.ts @@ -3,11 +3,12 @@ import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/ import { createSaveHandler } from "$/services/backup.service"; import { leaveEditor, saveBeatmapContents, updateBeatmap, updateSong } from "$/store/actions"; import { selectSelectedBeatmap } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; +import type { AppDispatch, RootState } from "$/store/setup"; import type { App, BeatmapId, SongId } from "$/types"; export default function createBackupMiddleware() { - const instance = createListenerMiddleware(); + const instance = createListenerMiddleware(); + const save = createSaveHandler(); instance.startListening({ diff --git a/src/store/middleware/demo.middleware.ts b/src/store/middleware/demo.middleware.ts index 6ceace0a..0e757ff1 100644 --- a/src/store/middleware/demo.middleware.ts +++ b/src/store/middleware/demo.middleware.ts @@ -4,17 +4,21 @@ import { demoFileUrl } from "$/assets"; import { getRouter } from "$/router"; import { getAppToaster } from "$/setup"; import { addSongFromFile, loadDemoMap } from "$/store/actions"; -import type { RootState } from "$/store/setup"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/setup"; /** This middleware exists only to load (and possibly manage) the demo song that comes with this app. */ export default function createDemoMiddleware() { - const instance = createListenerMiddleware(); - const router = getRouter(); + const instance = createListenerMiddleware({ + extra: { getRouter }, + }); + const toaster = getAppToaster(); instance.startListening({ actionCreator: loadDemoMap, effect: async (_, api) => { + const router = api.extra.getRouter(); + try { const blob = await fetch(demoFileUrl).then((response) => response.blob()); const { songId, songData } = await api.dispatch(addSongFromFile({ file: blob, options: { readonly: true } })).unwrap(); diff --git a/src/store/middleware/file.middleware.ts b/src/store/middleware/file.middleware.ts index f7b378e7..b90f91a5 100644 --- a/src/store/middleware/file.middleware.ts +++ b/src/store/middleware/file.middleware.ts @@ -7,13 +7,14 @@ import { BeatmapFilestore } from "$/services/file.service"; import { getAppBeatmapFilestore } from "$/setup"; import { addBeatmap, addColorScheme, addSong, copyBeatmap, finishLoadingMap, loadBeatmapEntities, rehydrate, reloadVisualizer, removeBeatmap, removeColorScheme, removeSong, startLoadingMap, updateBeatmap, updateColorScheme, updateCustomColors, updateGridSize, updateModuleEnabled, updateSong } from "$/store/actions"; import { selectBeatmapIdsWithLightshowId, selectBpm, selectDuration, selectEditorOffsetInBeats, selectLightshowIdForBeatmap, selectSelectedBeatmap, selectSongById } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; +import type { AppDispatch, RootState } from "$/store/setup"; import type { SongId } from "$/types"; import { deepAssign } from "$/utils"; /** This middleware manages file storage concerns. */ export default function createFileMiddleware() { - const instance = createListenerMiddleware(); + const instance = createListenerMiddleware(); + const filestore = getAppBeatmapFilestore(); instance.startListening({ diff --git a/src/store/middleware/history.middleware.ts b/src/store/middleware/history.middleware.ts index 0b41635e..8d6b3f1e 100644 --- a/src/store/middleware/history.middleware.ts +++ b/src/store/middleware/history.middleware.ts @@ -1,4 +1,4 @@ -import { createListenerMiddleware, type Dispatch, type ListenerEffectAPI } from "@reduxjs/toolkit"; +import { createListenerMiddleware, type ListenerEffectAPI } from "@reduxjs/toolkit"; import { sortObjectFn } from "bsmap"; import { ActionCreators } from "redux-undo"; @@ -23,11 +23,11 @@ import { selectPastColorNotes, selectPastObstacles, } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; +import type { AppDispatch, RootState } from "$/store/setup"; import type { App, SongId } from "$/types"; import { difference } from "$/utils"; -function jumpToEarliestObject(api: ListenerEffectAPI, songId: SongId, args: { [K in "notes" | "bombs" | "obstacles"]: { before: App.IBeatmapEntities[K]; after: App.IBeatmapEntities[K] } }) { +function jumpToEarliestObject(api: ListenerEffectAPI, songId: SongId, args: { [K in "notes" | "bombs" | "obstacles"]: { before: App.IBeatmapEntities[K]; after: App.IBeatmapEntities[K] } }) { const relevantNotes = difference(args.notes.before, args.notes.after, resolveNoteId); const relevantBombs = difference(args.bombs.before, args.bombs.after, resolveNoteId); const relevantObstacles = difference(args.obstacles.before, args.obstacles.after, resolveObstacleId); @@ -38,7 +38,7 @@ function jumpToEarliestObject(api: ListenerEffectAPI, songI api.dispatch(jumpToBeat({ songId, value: earliestBeat, pauseTrack: true, animateJump: true })); } -function jumpToEarliestEvent(api: ListenerEffectAPI, songId: SongId, args: { [K in "basicEvents" | "boostEvents"]: { before: App.IBeatmapEntities[K]; after: App.IBeatmapEntities[K] } }) { +function jumpToEarliestEvent(api: ListenerEffectAPI, songId: SongId, args: { [K in "basicEvents" | "boostEvents"]: { before: App.IBeatmapEntities[K]; after: App.IBeatmapEntities[K] } }) { const relevantBasicEvents = difference(args.basicEvents.before, args.basicEvents.after, resolveEventId); const relevantBoostEvents = difference(args.boostEvents.before, args.boostEvents.after, resolveEventId); @@ -54,7 +54,7 @@ function jumpToEarliestEvent(api: ListenerEffectAPI, songId * This middleware listens for undo events, and handles updating the cursor position in response to these actions. */ export default function createHistoryMiddleware() { - const instance = createListenerMiddleware(); + const instance = createListenerMiddleware(); instance.startListening({ actionCreator: leaveEditor, diff --git a/src/store/middleware/packaging.middleware.ts b/src/store/middleware/packaging.middleware.ts index 8a0ffeb5..b4b26f7b 100644 --- a/src/store/middleware/packaging.middleware.ts +++ b/src/store/middleware/packaging.middleware.ts @@ -5,10 +5,11 @@ import { exportMapArchiveFromFilestore } from "$/services/packaging.service"; import { getAppToaster } from "$/setup"; import { downloadMapFiles } from "$/store/actions"; import { selectSongById } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; +import type { AppDispatch, RootState } from "$/store/setup"; export default function createPackagingMiddleware() { - const instance = createListenerMiddleware(); + const instance = createListenerMiddleware(); + const toaster = getAppToaster(); instance.startListening({ diff --git a/src/store/middleware/playback.middleware.ts b/src/store/middleware/playback.middleware.ts index 3c62accb..b983c370 100644 --- a/src/store/middleware/playback.middleware.ts +++ b/src/store/middleware/playback.middleware.ts @@ -2,21 +2,22 @@ import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/ import { TimeProcessor } from "bsmap"; import { createBpmEventsFromAudioData } from "$/helpers/audio.helpers"; -import { getRouter } from "$/router"; +import { getRouter, selectActiveView } from "$/router"; import type { AudioSample } from "$/services/audio.service"; import { getAppBeatmapFilestore } from "$/setup"; import { finishLoadingMap, jumpToBeat, jumpToEnd, jumpToStart, jumpToTime, pausePlayback, scrollThroughSong, seekBackwards, seekForwards, startPlayback, stopPlayback, tick, togglePlayback, updateCursorPosition, updateSong, updateTimescale } from "$/store/actions"; import { selectBeatForTime, selectBpm, selectCursorPosition, selectDuration, selectEventsEditorBeatsPerZoomLevel, selectEventsEditorWindowLock, selectPlaying, selectSelectedBeatmap, selectSnap, selectTimeForBeat } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/setup"; import { type SongId, View } from "$/types"; import { floorToNearest } from "$/utils"; /** Manages all concerns related to audio playback and timescales. */ export default function createPlaybackMiddleware({ songSample }: { songSample: AudioSample }) { - const instance = createListenerMiddleware(); + const instance = createListenerMiddleware({ + extra: { getRouter }, + }); const filestore = getAppBeatmapFilestore(); - const router = getRouter(); let animationFrameId: number; @@ -91,9 +92,9 @@ export default function createPlaybackMiddleware({ songSample }: { songSample: A const state = api.getState(); - const { context } = router.state.matches[router.state.matches.length - 1]; + const view = selectActiveView(api.extra.getRouter()); - if ("view" in context && context.view === View.LIGHTSHOW) { + if (view === View.LIGHTSHOW) { const beatsPerZoomLevel = selectEventsEditorBeatsPerZoomLevel(state); const currentTime = selectTimeForBeat(state, songId, currentBeat); @@ -157,9 +158,9 @@ export default function createPlaybackMiddleware({ songSample }: { songSample: A const durationInBeats = selectBeatForTime(state, songId, selectDuration(state) ?? 0); const cursorPositionInBeats = selectBeatForTime(state, songId, selectCursorPosition(state)); - const { context } = router.state.matches[router.state.matches.length - 1]; + const view = selectActiveView(api.extra.getRouter()); - const windowSize = "view" in context && context.view === View.LIGHTSHOW ? selectEventsEditorBeatsPerZoomLevel(state) : 32; + const windowSize = view === View.LIGHTSHOW ? selectEventsEditorBeatsPerZoomLevel(state) : 32; const threshold = Math.ceil(windowSize / 8); const progress = cursorPositionInBeats % windowSize; const currentWindowStart = cursorPositionInBeats - progress; From 9a13c7dc39d3a451c09bd321b18a02085a0c9983 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Thu, 30 Jul 2026 20:14:55 -0500 Subject: [PATCH 09/27] untangle circular references for storage enhancers --- src/store/actions.ts | 2 +- src/store/enhancers/storage.enhancer.ts | 20 +++++++++------ src/store/middleware/index.ts | 4 +-- src/store/setup.ts | 34 ++----------------------- 4 files changed, 17 insertions(+), 43 deletions(-) diff --git a/src/store/actions.ts b/src/store/actions.ts index ffe3d2ca..abdd99f0 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -53,7 +53,7 @@ import visualizer from "./features/visualizer.slice"; export const { init } = global.actions; export const rehydrate = createAction("rehydrate", (args: { songId: SongId; beatmapId: BeatmapId }) => { - return { payload: { ...args } }; + return { payload: { ...args }, meta: { hydrate: true } }; }); export const { updateNew, updateAnnouncements, updateUsername, updateRenderScale, updateBloomEnabled, updateObstaclePlacementMode, updatePacerWait } = user.actions; diff --git a/src/store/enhancers/storage.enhancer.ts b/src/store/enhancers/storage.enhancer.ts index c2460952..69c2df40 100644 --- a/src/store/enhancers/storage.enhancer.ts +++ b/src/store/enhancers/storage.enhancer.ts @@ -1,9 +1,13 @@ -import type { Store, StoreEnhancer, UnknownAction } from "@reduxjs/toolkit"; +import { isFluxStandardAction, type PayloadAction, type Store, type StoreEnhancer, type UnknownAction } from "@reduxjs/toolkit"; import { distinct } from "@std/collections/distinct"; import { createStorage, type Driver, type StorageValue } from "unstorage"; import type { MaybeDefined } from "$/types/vendor"; -import { rehydrate } from "../actions"; + +export function isHydrationAction

(action: unknown): action is PayloadAction { + if (!isFluxStandardAction(action)) return false; + return "meta" in action && typeof action.meta === "object" && action.meta !== null && "hydrate" in action.meta && typeof action.meta.hydrate === "boolean" && (action.meta as Record).hydrate === true; +} export interface StorageStrategy { getKeys: (prevState: TState, nextState: TState) => string[]; @@ -79,7 +83,7 @@ export function createStorageEnhancer(driver: Driver, { getKeys, getValu ...store, hydrate: hydrate, dispatch: (action) => { - if (rehydrate.match(action)) { + if (isHydrationAction(action)) { // run the hydration step if we manually rehydrate the app hydrate(); return store.dispatch(action); @@ -110,11 +114,11 @@ export function createStorageEnhancer(driver: Driver, { getKeys, getValu }; } -interface IKeyValueStorageObserver { +interface IKeyValueStorageObserver { selectValue: (s: TState) => TValue; hydrateValue: (v: TValue) => UnknownAction; } -export function createKeyValueStorageStrategy(observers: { [key in keyof TShape]: IKeyValueStorageObserver }): StorageStrategy { +export function createKeyValueStorageStrategy } ? S : never>(observers: { [key in keyof TShape]: IKeyValueStorageObserver }): StorageStrategy { const keys = Object.keys(observers); return { @@ -131,12 +135,12 @@ export function createKeyValueStorageStrategy { +interface IEntityStorageObserver { selectIds: (s: TState) => string[]; selectById: (s: TState, id: string) => TValue; hydrateEntities: (data: Record) => UnknownAction; } -export function createEntityStorageStrategy(observer: IEntityStorageObserver): StorageStrategy { +export function createEntityStorageStrategy(observer: IEntityStorageObserver): StorageStrategy { return { asRaw: true, getKeys: (prevState, nextState) => { @@ -151,7 +155,7 @@ export function createEntityStorageStrategy(observer: IEntitySto }; } -export function createEnumerableStorageObserver(entries: { [s: PropertyKey]: TValue }, { selectValue, hydrateValue }: IKeyValueStorageObserver): IKeyValueStorageObserver { +export function createEnumerableStorageObserver(entries: { [s: PropertyKey]: TValue }, { selectValue, hydrateValue }: IKeyValueStorageObserver): IKeyValueStorageObserver { const values = Object.values(entries); return { selectValue: (state) => values.indexOf(selectValue(state)), diff --git a/src/store/middleware/index.ts b/src/store/middleware/index.ts index 7539d77e..7e3a252a 100644 --- a/src/store/middleware/index.ts +++ b/src/store/middleware/index.ts @@ -2,7 +2,7 @@ import type { Middleware } from "@reduxjs/toolkit"; import { createStateSyncMiddleware } from "redux-state-sync"; import { AudioSample } from "$/services/audio.service"; -import { rehydrate } from "../actions"; +import { isHydrationAction } from "$/store/enhancers/storage.enhancer"; import createAudioMiddleware from "./audio.middleware"; import createBackupMiddleware from "./backup.middleware"; import createDemoMiddleware from "./demo.middleware"; @@ -14,7 +14,7 @@ import createPlaybackMiddleware from "./playback.middleware"; export function createAllSharedMiddleware() { const stateSyncMiddleware = createStateSyncMiddleware({ predicate: (action) => { - return rehydrate.match(action); + return isHydrationAction(action); }, }); diff --git a/src/store/setup.ts b/src/store/setup.ts index c7c4dc0c..6f179faa 100644 --- a/src/store/setup.ts +++ b/src/store/setup.ts @@ -3,7 +3,6 @@ import { configureStore, type DevToolsEnhancerOptions, type ThunkDispatch, type UnknownAction } 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 { default as createLocalStorageDriver } from "unstorage/drivers/localstorage"; import { default as createSessionStorageDriver } from "unstorage/drivers/session-storage"; @@ -173,18 +172,9 @@ export async function createAppStore() { setupAppBeatmapFilestore(); setupAppToaster(); - type LocalStorage = { - "user.new": boolean; - "user.announcements": string[]; - "user.username": string; - "graphics.scale": number; - "graphics.bloom": boolean; - "controls.obstacles": number; - "advanced.wait": number; - }; const localStorageEnhancer = createStorageEnhancer( createLocalStorageDriver({ base: STORAGE_PREFIX }), - createKeyValueStorageStrategy({ + createKeyValueStorageStrategy({ "user.new": { selectValue: selectNew, hydrateValue: updateNew, @@ -216,29 +206,9 @@ export async function createAppStore() { }), ); - type SessionStorage = { - "track.snap": number; - "track.spacing": number; - "playback.rate": number; - "playback.volume": number; - "tick.volume": number; - "tick.type": number; - "notes.tool": number; - "notes.direction": NoteDirection; - "notes.duration": number; - "events.mode": number; - "events.tool": number; - "events.color": number; - "events.zoom": number; - "events.opacity": number; - "events.height": number; - "events.preview": boolean; - "events.loop": boolean; - "events.mirror": boolean; - }; const sessionStorageEnhancer = createStorageEnhancer( createSessionStorageDriver({ base: STORAGE_PREFIX }), - createKeyValueStorageStrategy({ + createKeyValueStorageStrategy({ "track.snap": { selectValue: selectSnap, hydrateValue: updateSnap, From f9d64ce4e6288e23b8971c3ced973bfe3894c2cb Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 4 Aug 2026 13:24:31 -0500 Subject: [PATCH 10/27] consolidate beatmap entity slices with colocated history adapters --- package.json | 1 + pnpm-lock.yaml | 23 ++ .../app/layouts/event-grid/machine.ts | 45 ++- .../app/templates/events/basic-track.tsx | 33 +-- .../app/templates/events/boost-track.tsx | 13 +- .../scene/hooks/use-object-placement.ts | 92 +++--- .../scene/templates/placement-grid.tsx | 15 +- .../scene/templates/visualization.tsx | 59 ++-- src/helpers/events.helpers.ts | 4 +- src/helpers/item.helpers.ts | 31 +- src/store/actions.ts | 130 ++------- .../features/entities/beatmap/bombs.slice.ts | 134 --------- src/store/features/entities/beatmap/index.ts | 43 --- .../features/entities/beatmap/notes.slice.ts | 138 --------- .../entities/beatmap/obstacles.slice.ts | 134 --------- src/store/features/entities/events.slice.ts | 258 +++++++++++++++++ src/store/features/entities/index.ts | 8 +- .../entities/lightshow/basic.slice.ts | 128 --------- .../entities/lightshow/boost.slice.ts | 128 --------- .../features/entities/lightshow/index.ts | 40 --- src/store/features/entities/objects.slice.ts | 270 ++++++++++++++++++ src/store/helpers.ts | 94 +----- src/store/middleware/history.middleware.ts | 33 ++- src/store/selectors.ts | 200 +++---------- 24 files changed, 820 insertions(+), 1234 deletions(-) delete mode 100644 src/store/features/entities/beatmap/bombs.slice.ts delete mode 100644 src/store/features/entities/beatmap/index.ts delete mode 100644 src/store/features/entities/beatmap/notes.slice.ts delete mode 100644 src/store/features/entities/beatmap/obstacles.slice.ts create mode 100644 src/store/features/entities/events.slice.ts delete mode 100644 src/store/features/entities/lightshow/basic.slice.ts delete mode 100644 src/store/features/entities/lightshow/boost.slice.ts delete mode 100644 src/store/features/entities/lightshow/index.ts create mode 100644 src/store/features/entities/objects.slice.ts 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/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/templates/events/basic-track.tsx b/src/components/app/templates/events/basic-track.tsx index 3f1ebb41..aa7f2bdf 100644 --- a/src/components/app/templates/events/basic-track.tsx +++ b/src/components/app/templates/events/basic-track.tsx @@ -7,11 +7,12 @@ 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, @@ -21,7 +22,6 @@ import { selectEventsEditorMirrorLock, 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) => { @@ -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>(() => { 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/scene/hooks/use-object-placement.ts b/src/components/scene/hooks/use-object-placement.ts index 50e573c6..6a21110c 100644 --- a/src/components/scene/hooks/use-object-placement.ts +++ b/src/components/scene/hooks/use-object-placement.ts @@ -1,52 +1,55 @@ import type { EntityId } from "@reduxjs/toolkit"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; import { finishManagingNoteSelection, startManagingNoteSelection } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectNotesEditorSelectionMode } from "$/store/selectors"; -import { ObjectSelectionMode } from "$/types"; +import { type App, ObjectSelectionMode } from "$/types"; -interface UseObjectSelectionOptions { +interface UseObjectSelectionOptions { interactive?: boolean; selectId: (item: T) => EntityId; - selectItemSelected: (item: T) => boolean; - onItemSelect?: (item: T) => void; - onItemDeselect?: (item: T) => void; - onItemDelete?: (item: T) => void; - onItemModify?: (item: T) => void; - onItemWheel?: (item: T, delta: number) => void; + onItemSelect?: (item: T, id: EntityId) => void; + onItemDeselect?: (item: T, id: EntityId) => void; + onItemDelete?: (item: T, id: EntityId) => void; + onItemModify?: (item: T, id: EntityId) => void; + onItemWheel?: (item: T, id: EntityId, delta: number) => void; } -export function useObjectPlacement({ interactive, selectItemSelected, onItemSelect, onItemDeselect, onItemDelete, onItemModify, onItemWheel }: UseObjectSelectionOptions) { + +export function useObjectPlacement({ interactive, selectId, onItemSelect, onItemDeselect, onItemDelete, onItemModify, onItemWheel }: UseObjectSelectionOptions) { const dispatch = useAppDispatch(); const selectionMode = useAppSelector(selectNotesEditorSelectionMode); + const activePointerIdRef = useRef(null); + const handlePointerDown = useCallback( (event: PointerEvent, data: T) => { if (!interactive) return; - event.stopPropagation(); - const selected = selectItemSelected(data); + if (activePointerIdRef.current === event.pointerId) return; + activePointerIdRef.current = event.pointerId; + + event.stopPropagation(); - // We can rapidly select/deselect/delete notes by clicking, holding, and dragging the cursor across the field. + const id = selectId(data); let newSelectionMode: ObjectSelectionMode | null = null; switch (event.button) { case 0: { - newSelectionMode = selected ? ObjectSelectionMode.DESELECT : ObjectSelectionMode.SELECT; - const action = !selected ? onItemSelect : onItemDeselect; - if (action) action(data); + newSelectionMode = data.selected ? ObjectSelectionMode.DESELECT : ObjectSelectionMode.SELECT; + const action = !data.selected ? onItemSelect : onItemDeselect; + action?.(data, id); break; } case 1: { - // Middle clicks shouldnt affect selections newSelectionMode = null; - if (onItemModify) onItemModify(data); + onItemModify?.(data, id); break; } case 2: { newSelectionMode = ObjectSelectionMode.DELETE; - if (onItemDelete) onItemDelete(data); + onItemDelete?.(data, id); break; } } @@ -55,56 +58,57 @@ export function useObjectPlacement({ interactive, selectItemSelected, onItemS dispatch(startManagingNoteSelection({ selectionMode: newSelectionMode })); } }, - [interactive, dispatch, selectItemSelected, onItemSelect, onItemDeselect, onItemDelete, onItemModify], + [interactive, dispatch, selectId, onItemSelect, onItemDeselect, onItemDelete, onItemModify], ); - // I can click on a block to start selecting it. - // If I hold the mouse down, I can drag to select (or deselect) many notes at a time. - // For this to work, I need to know when they start clicking and stop clicking. - // For starting clicking, I can use the `SELECT_NOTE` action, triggered when clicking a block... but they might not be over a block when they release the mouse. - // So instead I need to use a mouseUp handler up here. const handlePointerUp = useCallback( (event: PointerEvent) => { if (!interactive) return; + + if (activePointerIdRef.current !== null && activePointerIdRef.current !== event.pointerId) { + return; + } + event.stopPropagation(); + activePointerIdRef.current = null; - // Wait 1 frame before wrapping up. This is to prevent the selection mode from changing before all event-handlers have been processed. - // Without the delay, the user might accidentally add notes to the placement grid - further up in the React tree - if they release the mouse while over a grid tile. window.requestAnimationFrame(() => dispatch(finishManagingNoteSelection())); }, [dispatch, interactive], ); - useGlobalEventListener("pointerup", handlePointerUp, { shouldFire: !!selectionMode }); + useGlobalEventListener("pointerup", handlePointerUp, { shouldFire: !!selectionMode || activePointerIdRef.current !== null }); + useGlobalEventListener("pointercancel", handlePointerUp, { shouldFire: !!selectionMode || activePointerIdRef.current !== null }); const handlePointerOver = useCallback( (event: PointerEvent, data: T) => { if (!interactive) return; event.stopPropagation(); - const selected = selectItemSelected(data); - // While selecting/deselecting/deleting notes, pointer-over events are important and should trump others. if (!selectionMode) return; + + const id = selectId(data); + switch (selectionMode) { case ObjectSelectionMode.SELECT: case ObjectSelectionMode.DESELECT: { - const alreadySelected = selected && selectionMode === ObjectSelectionMode.SELECT; - const alreadyDeselected = !selected && selectionMode === ObjectSelectionMode.DESELECT; + const alreadySelected = data.selected && selectionMode === ObjectSelectionMode.SELECT; + const alreadyDeselected = !data.selected && selectionMode === ObjectSelectionMode.DESELECT; if (alreadySelected || alreadyDeselected) return; - const action = !selected ? onItemSelect : onItemDeselect; - if (!action) return; - return action(data); + + const action = !data.selected ? onItemSelect : onItemDeselect; + action?.(data, id); + break; } case ObjectSelectionMode.DELETE: { - if (!onItemDelete) return; - return onItemDelete(data); - } - default: { - return; + onItemDelete?.(data, id); + break; } + default: + break; } }, - [interactive, selectionMode, selectItemSelected, onItemSelect, onItemDeselect, onItemDelete], + [interactive, selectionMode, selectId, onItemSelect, onItemDeselect, onItemDelete], ); const handlePointerOut = useCallback( @@ -120,12 +124,12 @@ export function useObjectPlacement({ interactive, selectItemSelected, onItemS if (!interactive) return; event.stopPropagation(); - // if we're not hovering over an object, no need to fire the event. + const id = selectId(data); const delta = event.deltaY > 0 ? -1 : 1; - if (onItemWheel) return onItemWheel(data, delta); + onItemWheel?.(data, id, delta); }, - [interactive, onItemWheel], + [interactive, selectId, onItemWheel], ); const handleWheel = useCallback( diff --git a/src/components/scene/templates/placement-grid.tsx b/src/components/scene/templates/placement-grid.tsx index 92b024b3..9fbde33c 100644 --- a/src/components/scene/templates/placement-grid.tsx +++ b/src/components/scene/templates/placement-grid.tsx @@ -11,16 +11,19 @@ import { Match, Switch } from "$/components/ui/atoms"; import { resolveColorForItem } from "$/helpers/colors.helpers"; import { createBombNoteFromMouseEvent, createColorNoteFromMouseEvent } from "$/helpers/notes.helpers"; import { createObstacleFromMouseEvent } from "$/helpers/obstacles.helpers"; -import { addObstacle, addToCell } from "$/store/actions"; +import { addBombNote, addColorNote, addObstacle } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectBeatDepth, selectColorScheme, selectDefaultObstacleDuration, selectGridSize, selectNotePlacementMode, selectNotesEditorDirection, selectNotesEditorSelectionMode, selectNotesEditorTool, selectObstaclePlacementMode } from "$/store/selectors"; +import { selectBeatDepth, selectColorScheme, selectCursorPositionInBeats, selectDefaultObstacleDuration, selectGridSize, selectNotePlacementMode, selectNotesEditorDirection, selectNotesEditorSelectionMode, selectNotesEditorTool, selectObstaclePlacementMode, selectSnap } from "$/store/selectors"; import { ObjectTool } from "$/types"; +import { roundToNearest } from "$/utils"; function EditorPlacementGrid({ onCellPointerDown, onCellWheel, ...rest }: Assign, Pick>) { const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const dispatch = useAppDispatch(); const selectionMode = useAppSelector(selectNotesEditorSelectionMode); + const cursorPositionInBeats = useAppSelector((state) => selectCursorPositionInBeats(state, sid)); + const snapTo = useAppSelector(selectSnap); const notePlacementMode = useAppSelector((state) => selectNotePlacementMode(state, sid)); const obstaclePlacementMode = useAppSelector((state) => selectObstaclePlacementMode(state, sid)); const grid = useAppSelector((state) => selectGridSize(state, sid)); @@ -39,21 +42,23 @@ function EditorPlacementGrid({ onCellPointerDown, onCellWheel, ...rest }: Assign onPointerUp: (_, ctx) => { if (selectionMode) return; + const time = roundToNearest(cursorPositionInBeats, snapTo); + switch (selectedTool) { case ObjectTool.LEFT_NOTE: case ObjectTool.RIGHT_NOTE: { const note = createColorNoteFromMouseEvent(ctx, notePlacementMode, grid, { direction: Math.round(ctx.direction ?? selectedDirection) }); - if (note) return dispatch(addToCell({ songId: sid, tool: selectedTool, posX: note.posX, posY: note.posY, direction: note.direction })); + if (note) return dispatch(addColorNote({ ...note, time })); break; } case ObjectTool.BOMB_NOTE: { const note = createBombNoteFromMouseEvent(ctx, notePlacementMode, grid); - if (note) return dispatch(addToCell({ songId: sid, tool: selectedTool, posX: note.posX, posY: note.posY })); + if (note) return dispatch(addBombNote({ ...note, time })); break; } case ObjectTool.OBSTACLE: { const obstacle = createObstacleFromMouseEvent(ctx, obstaclePlacementMode, grid, { duration: defaultObstacleDuration }); - if (obstacle) return dispatch(addObstacle({ songId: sid, obstacle })); + if (obstacle) return dispatch(addObstacle({ ...obstacle, time })); break; } } diff --git a/src/components/scene/templates/visualization.tsx b/src/components/scene/templates/visualization.tsx index f796bc11..04569651 100644 --- a/src/components/scene/templates/visualization.tsx +++ b/src/components/scene/templates/visualization.tsx @@ -1,6 +1,6 @@ import { type ThreeEvent, useThree } from "@react-three/fiber"; import { useParams } from "@tanstack/react-router"; -import { type IWrapBaseNote, type IWrapObstacle, NoteDirection } from "bsmap"; +import { type IWrapBombNote, type IWrapColorNote, type IWrapObstacle, mirrorNoteColor, NoteDirection } from "bsmap"; import { memo, useCallback, useMemo, useRef } from "react"; import type { Object3D } from "three"; @@ -13,7 +13,7 @@ import { Visualization } from "$/components/scene/layouts"; import { resolveColorForItem } from "$/helpers/colors.helpers"; import { isBombNote, isColorNote, resolveNoteId } from "$/helpers/notes.helpers"; import { isObstacle, resolveObstacleId } from "$/helpers/obstacles.helpers"; -import { deselectNote, deselectObstacle, mirrorColorNote, removeNote, removeObstacle, selectNote, selectObstacle, updateColorNote, updateObstacle } from "$/store/actions"; +import { deselectBombNote, deselectColorNote, deselectObstacle, removeBombNote, removeColorNote, removeObstacle, selectBombNote, selectColorNote, selectObstacle, updateColorNote, updateObstacle } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; import { selectAllVisibleObstacles, selectAnimateTrack, selectColorScheme, selectCursorPositionInBeats, selectNotesEditorSelectionMode, selectSnap, selectVisibleBombs, selectVisibleNotes } from "$/store/selectors"; import { type App, ObjectTool } from "$/types"; @@ -52,35 +52,40 @@ function MapVisualization({ timescale, beatDepth, surfaceDepth, interactive }: P const bombs = useAppSelector((state) => selectVisibleBombs(state, sid, { timescale, beatDepth, surfaceDepth, includeSpaceBeforeGrid: true })); const obstacles = useAppSelector((state) => selectAllVisibleObstacles(state, sid, { timescale, beatDepth, surfaceDepth, includeSpaceBeforeGrid: true })); - const noteActions = useObjectPlacement>({ + const noteActions = useObjectPlacement>({ interactive, selectId: resolveNoteId, - selectItemSelected: (x) => !!x.selected, - onItemSelect: (x) => dispatch(selectNote({ query: x })), - onItemDeselect: (x) => dispatch(deselectNote({ query: x })), - onItemDelete: (x) => dispatch(removeNote({ query: x })), - onItemModify: (x) => dispatch(mirrorColorNote({ query: x })), - onItemWheel: (x, delta) => { - if (!isColorNote(x)) return; + onItemSelect: (_, id) => dispatch(selectColorNote({ id })), + onItemDeselect: (_, id) => dispatch(deselectColorNote({ id })), + onItemDelete: (_, id) => dispatch(removeColorNote({ id })), + onItemModify: (data, id) => dispatch(updateColorNote({ id, changes: { color: mirrorNoteColor(data.color) } })), + onItemWheel: (data, id, delta) => { const step = 15 / delta; - if (Object.values(NoteDirection).includes(x.direction)) { - return dispatch(updateColorNote({ query: x, changes: { angleOffset: (x.angleOffset ?? 0) + step } })); + if (Object.values(NoteDirection).includes(data.direction)) { + return dispatch(updateColorNote({ id, changes: { angleOffset: (data.angleOffset ?? 0) + step } })); } }, }); + const bombActions = useObjectPlacement>({ + interactive, + selectId: resolveNoteId, + onItemSelect: (_, id) => dispatch(selectBombNote({ id })), + onItemDeselect: (_, id) => dispatch(deselectBombNote({ id })), + onItemDelete: (_, id) => dispatch(removeBombNote({ id })), + }); + const obstacleActions = useObjectPlacement>({ interactive, selectId: resolveObstacleId, - selectItemSelected: (x) => !!x.selected, - onItemSelect: (x) => dispatch(selectObstacle({ id: resolveObstacleId(x) })), - onItemDeselect: (x) => dispatch(deselectObstacle({ id: resolveObstacleId(x) })), - onItemDelete: (x) => dispatch(removeObstacle({ id: resolveObstacleId(x) })), - onItemWheel: (x, delta) => { + onItemSelect: (_, id) => dispatch(selectObstacle({ id })), + onItemDeselect: (_, id) => dispatch(deselectObstacle({ id })), + onItemDelete: (_, id) => dispatch(removeObstacle({ id })), + onItemWheel: (x, id, delta) => { const newDuration = x.duration + snapTo * delta; // the new duration value should never create an invalid obstacle. if (newDuration <= 0 || Math.abs(newDuration) < 0.01) return; - dispatch(updateObstacle({ id: resolveObstacleId(x), changes: { duration: x.duration + snapTo * delta } })); + dispatch(updateObstacle({ id, changes: { duration: x.duration + snapTo * delta } })); }, }); @@ -110,14 +115,15 @@ function MapVisualization({ timescale, beatDepth, surfaceDepth, interactive }: P isDispatchingEvent.current = true; try { const data = deriveUserDataFromTarget(target); + if (isColorNote(data)) return noteActions.handlePointerDown(event.nativeEvent, data); + if (isBombNote(data)) return bombActions.handlePointerDown(event.nativeEvent, data); if (isObstacle(data)) return obstacleActions.handlePointerDown(event.nativeEvent, data); - if (isColorNote(data) || isBombNote(data)) return noteActions.handlePointerDown(event.nativeEvent, data); } finally { isDispatchingEvent.current = false; } } }, - [raycaster, scene, selectionMode, deriveUserDataFromTarget, noteActions.handlePointerDown, obstacleActions.handlePointerDown], + [raycaster, scene, selectionMode, deriveUserDataFromTarget, noteActions.handlePointerDown, bombActions.handlePointerDown, obstacleActions.handlePointerDown], ); const handleCellWheel = useCallback( @@ -133,15 +139,16 @@ function MapVisualization({ timescale, beatDepth, surfaceDepth, interactive }: P isDispatchingEvent.current = true; try { const data = deriveUserDataFromTarget(target); - if (isObstacle(data)) return obstacleActions.handleWheel(event.nativeEvent, data); if (isColorNote(data)) return noteActions.handleWheel(event.nativeEvent, data); + if (isBombNote(data)) return bombActions.handleWheel(event.nativeEvent, data); + if (isObstacle(data)) return obstacleActions.handleWheel(event.nativeEvent, data); return; } finally { isDispatchingEvent.current = false; } } }, - [raycaster, scene, selectionMode, deriveUserDataFromTarget, noteActions.handleWheel, obstacleActions.handleWheel], + [raycaster, scene, selectionMode, deriveUserDataFromTarget, noteActions.handleWheel, bombActions.handleWheel, obstacleActions.handleWheel], ); const rotationOverrides = useMemo(() => { @@ -173,10 +180,10 @@ function MapVisualization({ timescale, beatDepth, surfaceDepth, interactive }: P noteActions.handlePointerDown(e.nativeEvent, data)} - onPointerOver={(e) => noteActions.handlePointerOver(e.nativeEvent, data)} - onPointerOut={(e) => noteActions.handlePointerOut(e.nativeEvent, data)} - onWheel={(e) => noteActions.handleWheel(e.nativeEvent, data)} + onPointerDown={(e) => bombActions.handlePointerDown(e.nativeEvent, data)} + onPointerOver={(e) => bombActions.handlePointerOver(e.nativeEvent, data)} + onPointerOut={(e) => bombActions.handlePointerOut(e.nativeEvent, data)} + onWheel={(e) => bombActions.handleWheel(e.nativeEvent, data)} /> )} diff --git a/src/helpers/events.helpers.ts b/src/helpers/events.helpers.ts index ffa0d076..80ebffbb 100644 --- a/src/helpers/events.helpers.ts +++ b/src/helpers/events.helpers.ts @@ -26,11 +26,11 @@ export function resolveGroupTrackIds(trackId: number, environment: EnvironmentNa return group.filter((id) => id !== trackId); } -export function isBasicEvent(data: unknown): data is IWrapBasicEvent { +export function isBasicEvent(data: unknown): data is T { if (typeof data !== "object" || !data) return false; return "type" in data; } -export function isBoostEvent(data: unknown): data is IWrapColorBoostEvent { +export function isBoostEvent(data: unknown): data is T { if (typeof data !== "object" || !data) return false; return "toggle" in data; } diff --git a/src/helpers/item.helpers.ts b/src/helpers/item.helpers.ts index 17d5f0ee..62d3262b 100644 --- a/src/helpers/item.helpers.ts +++ b/src/helpers/item.helpers.ts @@ -3,8 +3,10 @@ import { type IWrapBaseNote, type IWrapGridObject, mirrorNoteColor, mirrorNoteDi import { DEFAULT_NUM_COLS, DEFAULT_NUM_ROWS } from "$/constants"; import type { IGrid } from "$/types"; -export function nudgeItem(item: T, direction: "forwards" | "backwards", amount = 1) { - return { time: item.time - amount * (direction === "backwards" ? 1 : -1) } as Partial; +export function nudgeItem(direction: "forwards" | "backwards", amount = 1) { + return (data: T): Partial => { + return { time: data.time - amount * (direction === "backwards" ? 1 : -1) } as Partial; + }; } export function isExtendedCoordinate(x: number) { @@ -24,16 +26,21 @@ export function mirrorCoordinate(coordinate: number, count: number, offset?: num 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 mirrorGridObjectProperties(axis: "horizontal" | "vertical", grid?: IGrid, offset?: number) { + return (data: T): Partial => { + return { + posX: axis === "horizontal" ? mirrorCoordinate(data.posX, DEFAULT_NUM_COLS, offset) : data.posX, + posY: axis === "vertical" ? mirrorCoordinate(data.posY, grid?.numRows ?? DEFAULT_NUM_ROWS, offset) : data.posY, + } as Partial; + }; } -export function mirrorBaseNoteProperties(item: T, axis: "horizontal" | "vertical"): Partial { +export function mirrorBaseNoteProperties(axis: "horizontal" | "vertical") { const resolveDirection = axis === "horizontal" ? mirrorNoteDirectionHorizontally : mirrorNoteDirectionVertically; - return { - color: axis === "horizontal" ? mirrorNoteColor(item.color) : item.color, - direction: resolveDirection(item.direction), - } as Partial; + + return (data: T): Partial => { + return { + color: axis === "horizontal" ? mirrorNoteColor(data.color) : data.color, + direction: resolveDirection(data.direction), + } as Partial; + }; } diff --git a/src/store/actions.ts b/src/store/actions.ts index abdd99f0..12bffd15 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,34 +1,28 @@ import { createAction, createAsyncThunk, type GetThunkAPI } from "@reduxjs/toolkit"; -import type { EnvironmentName, ITrackDefinitions } from "bsmap"; +import type { ITrackDefinitions } from "bsmap"; -import { HIGHEST_PRECISION, SNAPPING_INCREMENT_VALUES, ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN } from "$/constants"; -import type { resolveEventId } from "$/helpers/events.helpers"; -import type { resolveNoteId } from "$/helpers/notes.helpers"; +import { SNAPPING_INCREMENT_VALUES, ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN } from "$/constants"; import type { ExportMapArchiveOptions } from "$/services/packaging.service"; -import { type App, type BeatmapId, type IGrid, type ISelectionBoxInBeats, type ObjectSelectionMode, type ObjectTool, type ObjectType, type SongId, View } from "$/types"; -import { roundToNearest } from "$/utils"; +import { type App, type BeatmapId, type IGrid, type ISelectionBoxInBeats, type ObjectSelectionMode, type ObjectType, type SongId, View } from "$/types"; import { selectAllBasicEvents, selectAllBombNotes, selectAllColorNotes, - selectAllNotes, selectAllObstacles, + selectAnySelectedEvents, + selectAnySelectedObjects, selectClipboardData, selectCursorPositionInBeats, - selectDurationInBeats, selectEarliestBeat, selectEventEditorStartAndEndBeat, selectEventsEditorCursor, selectEventsEditorZoomLevel, - selectNotesEditorDirection, - selectNotesEditorTool, selectPlaybackRate, - selectPlaying, selectSnap, selectSongVolume, selectTickVolume, } from "./selectors"; -import type { AppThunkApiConfig, RootState } from "./setup"; +import type { AppThunkApiConfig } from "./setup"; import { createIncrementByIndexPayloadActionCreator, createIncrementByValuePayloadActionCreator, createThunk, type GetShallowThunkAPI } from "./utils"; // biome-ignore-start assist/source/organizeImports: circular dependencies @@ -36,11 +30,9 @@ import { createIncrementByIndexPayloadActionCreator, createIncrementByValuePaylo import clipboard from "./features/clipboard.slice"; import beatmap from "./features/editor/beatmap.slice"; import lightshow from "./features/editor/lightshow.slice"; -import notes from "./features/entities/beatmap/notes.slice"; -import obstacles from "./features/entities/beatmap/obstacles.slice"; import bookmarks from "./features/entities/editor/bookmarks.slice"; -import basicEvents from "./features/entities/lightshow/basic.slice"; -import boostEvents from "./features/entities/lightshow/boost.slice"; +import events from "./features/entities/events.slice"; +import objects from "./features/entities/objects.slice"; import global from "./features/global.slice"; import navigation from "./features/navigation.slice"; import songs from "./features/songs.slice"; @@ -218,62 +210,10 @@ export const cycleToPrevTool = createAction("cycleToPrevTool", (args: { view: Vi return { payload: { ...args } }; }); -export const addToCell = createAsyncThunk("addToCell", (args: { songId: SongId; posX: number; posY: number; direction?: number; tool: ObjectTool }, api: GetThunkAPI) => { - const state = api.getState(); - const selectedDirection = args.direction ?? selectNotesEditorDirection(state); - const selectedTool = selectNotesEditorTool(state); - const cursorPositionInBeats = selectCursorPositionInBeats(state, args.songId); - const durationInBeats = selectDurationInBeats(state, args.songId); - if (cursorPositionInBeats < 0 || (durationInBeats !== null && cursorPositionInBeats > durationInBeats)) { - return api.rejectWithValue("Cannot place objects out-of-bounds."); - } - - function adjustNoteCursorPosition(cursorPositionInBeats: number, state: RootState) { - const isPlaying = selectPlaying(state); - - if (isPlaying) { - // If the user tries to place blocks while the song is playing, we want to snap to the nearest snapping interval. - // eg. if they're set to snap to 1/2 beats, and they click when the song is 3.476 beats in, we should round up to 3.5. - const snapTo = selectSnap(state); - return roundToNearest(cursorPositionInBeats, snapTo); - } - // If the song isn't playing, we want to snap to the highest precision we have. - // Note that this will mean a slight tweak for notes that are a multiple of 3 (eg. a note at 1.333 beats will be rounded to 1.328125) - return roundToNearest(cursorPositionInBeats, HIGHEST_PRECISION); - } - - const adjustedCursorPosition = adjustNoteCursorPosition(cursorPositionInBeats, state); - const alreadyExists = selectAllNotes(state).some((note) => note.time === adjustedCursorPosition && note.posX === args.posX && note.posY === args.posY); - if (alreadyExists) api.dispatch(removeFromCell(args)); - return api.fulfillWithValue({ query: { time: adjustedCursorPosition, posX: args.posX, posY: args.posY }, direction: selectedDirection, tool: selectedTool }); -}); - -export const removeFromCell = createAsyncThunk("removeFromCell", (args: { songId: SongId; posX: number; posY: number; tool: ObjectTool }, api: GetThunkAPI) => { - const state = api.getState(); - const cursorPositionInBeats = selectCursorPositionInBeats(state, args.songId); - if (cursorPositionInBeats === null) return api.rejectWithValue("Invalid beat number."); - return api.fulfillWithValue({ query: { time: cursorPositionInBeats, posX: args.posX, posY: args.posY } }); -}); - -export const { updateOne: updateColorNote, mirrorOne: mirrorColorNote } = notes.actions; - -export const selectNote = createAction("selectNote", (args: { query: Parameters[0] }) => { - return { payload: { ...args } }; -}); - -export const deselectNote = createAction("deselectNote", (args: { query: Parameters[0] }) => { - return { payload: { ...args } }; -}); - -export const removeNote = createAction("removeNote", (args: { query: Parameters[0] }) => { - return { payload: { ...args } }; -}); - -export const bulkRemoveNote = createAction("bulkRemoveNote", (args: { query: Parameters[0] }) => { - return { payload: { ...args } }; -}); - -export const removeAllSelectedObjects = createAction("removeAllSelectedObjects"); +export const { undo: undoObjects, redo: redoObjects, clearHistory: clearObjectHistory, removeAllSelectedObjects } = objects.actions; +export const { addColorNote, updateColorNote, selectColorNote, deselectColorNote, removeColorNote } = objects.actions; +export const { addBombNote, selectBombNote, deselectBombNote, removeBombNote } = objects.actions; +export const { addObstacle, updateObstacle, selectObstacle, deselectObstacle, updateAllSelectedObstacles, removeObstacle } = objects.actions; export const startManagingNoteSelection = createAction("startManagingNoteSelection", (args: { selectionMode: ObjectSelectionMode }) => { return { payload: { ...args } }; @@ -281,7 +221,9 @@ export const startManagingNoteSelection = createAction("startManagingNoteSelecti export const finishManagingNoteSelection = createAction("finishManagingNoteSelection"); -export const { addOne: addObstacle, updateOne: updateObstacle, selectOne: selectObstacle, deselectOne: deselectObstacle, updateAllSelected: updateAllSelectedObstacles, removeOne: removeObstacle } = obstacles.actions; +export const { undo: undoEvents, redo: redoEvents, clearHistory: clearEventHistory, removeAllSelectedEvents } = events.actions; +export const { addBasicEvent, updateBasicEvent, selectBasicEvent, deselectBasicEvent, removeBasicEvent } = events.actions; +export const { addBoostEvent, updateBoostEvent, selectBoostEvent, deselectBoostEvent, removeBoostEvent } = events.actions; export const selectAllEntities = createAsyncThunk("selectAllEntities", (args: { songId: SongId; view: View }, api: GetThunkAPI) => { const state = api.getState(); @@ -338,44 +280,6 @@ export const nudgeSelection = createAsyncThunk("nudgeSelection", (args: { direct return api.fulfillWithValue({ ...args, amount: snapTo }); }); -export const undoObjects = createAction("undoObjects", (args: { songId: SongId }) => { - return { payload: { ...args } }; -}); - -export const redoObjects = createAction("redoObjects", (args: { songId: SongId }) => { - return { payload: { ...args } }; -}); - -export const { addOne: addBasicEvent, addOne: bulkAddBasicEvent, updateOne: updateBasicEvent } = basicEvents.actions; - -export const { addOne: addBoostEvent, addOne: bulkAddBoostEvent, updateOne: updateBoostEvent } = boostEvents.actions; - -export const selectEvent = createAction("selectEvent", (args: { query: Parameters[0]; environment: EnvironmentName; areLasersLocked: boolean }) => { - return { payload: { ...args } }; -}); - -export const deselectEvent = createAction("deselectEvent", (args: { query: Parameters[0]; environment: EnvironmentName; areLasersLocked: boolean }) => { - return { payload: { ...args } }; -}); - -export const removeEvent = createAction("removeEvent", (args: { query: Parameters[0]; environment: EnvironmentName; areLasersLocked: boolean }) => { - return { payload: { ...args } }; -}); - -export const bulkRemoveEvent = createAction("bulkRemoveEvent", (args: { query: Parameters[0]; environment: EnvironmentName; areLasersLocked: boolean }) => { - return { payload: { ...args } }; -}); - -export const removeAllSelectedEvents = createAction("removeAllSelectedEvents"); - -export const undoEvents = createAction("undoEvents", (args: { songId: SongId }) => { - return { payload: { ...args } }; -}); - -export const redoEvents = createAction("redoEvents", (args: { songId: SongId }) => { - return { payload: { ...args } }; -}); - export const { cutSelection, copySelection } = clipboard.actions; export const pasteSelection = createAsyncThunk("pasteSelection", (args: { songId: SongId; view: View }, api: GetThunkAPI) => { @@ -383,6 +287,10 @@ export const pasteSelection = createAsyncThunk("pasteSelection", (args: { songId const data = selectClipboardData(state); // If there's nothing copied, do nothing if (!data) return api.rejectWithValue("Clipboard is empty."); + // when we're pasting, we need to deselect all currently selected entities + if (selectAnySelectedObjects(state) || selectAnySelectedEvents(state)) { + api.dispatch(deselectAllEntities({ view: args.view })); + } // When pasting in notes view, we want to paste at the cursor position, where the song is currently playing. // For the events view, we want to paste it where the mouse cursor is, the selected beat. const pasteAtBeat = args.view === View.BEATMAP ? selectCursorPositionInBeats(state, args.songId) : selectEventsEditorCursor(state); diff --git a/src/store/features/entities/beatmap/bombs.slice.ts b/src/store/features/entities/beatmap/bombs.slice.ts deleted file mode 100644 index 135b57a3..00000000 --- a/src/store/features/entities/beatmap/bombs.slice.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { createEntityAdapter, createSlice, type EntityId, isAnyOf } from "@reduxjs/toolkit"; -import { createBombNote, type IWrapBombNote, sortObjectFn } from "bsmap"; - -import { mirrorGridObjectProperties, nudgeItem } from "$/helpers/item.helpers"; -import { resolveNoteId } from "$/helpers/notes.helpers"; -import { - addSong, - addToCell, - bulkRemoveNote, - cutSelection, - deselectAllEntities, - deselectAllEntitiesOfType, - deselectNote, - leaveEditor, - loadBeatmapEntities, - mirrorSelection, - nudgeSelection, - pasteSelection, - removeAllSelectedObjects, - removeFromCell, - removeNote, - selectAllEntities, - selectAllEntitiesInRange, - selectNote, - startLoadingMap, -} from "$/store/actions"; -import { createEditorObjectReducers, createEditorObjectSelectors, createGridObjectReducerFactory } from "$/store/helpers"; -import { type App, ObjectTool, ObjectType, View } from "$/types"; - -const adapter = createEntityAdapter, EntityId>({ - selectId: resolveNoteId, - sortComparer: sortObjectFn, -}); - -const { selectAll, selectTotal } = adapter.getSelectors(); -const { selectAllSelected } = createEditorObjectSelectors(adapter); -const { removeAllSelected, updateAll, updateAllSelected, replaceAllSelected } = createEditorObjectReducers(adapter); - -const createNoteReducer = createGridObjectReducerFactory(adapter); - -const slice = createSlice({ - name: "bombs", - initialState: adapter.getInitialState(), - selectors: { - selectAll: selectAll, - selectAllSelected: selectAllSelected, - selectTotal: selectTotal, - }, - reducers: { - updateOne: createNoteReducer<{ changes: Partial }>((data, state, action) => { - return adapter.updateOne(state, { id: adapter.selectId(data), changes: action.payload.changes }); - }), - }, - extraReducers: (builder) => { - builder.addCase(loadBeatmapEntities, (state, action) => { - const { bombs } = action.payload; - return adapter.setAll(state, bombs ?? []); - }); - builder.addCase(addToCell.fulfilled, (state, action) => { - const { query, tool: selectedTool } = action.payload; - if (!selectedTool || selectedTool !== ObjectTool.BOMB_NOTE) return state; - return adapter.addOne(state, createBombNote({ ...query })); - }); - builder.addCase( - removeFromCell.fulfilled, - createNoteReducer((match, state) => { - return adapter.removeOne(state, adapter.selectId(match)); - }), - ); - builder.addCase(removeAllSelectedObjects, (state) => { - return removeAllSelected(state); - }); - builder.addCase(cutSelection.fulfilled, (state, action) => { - const { view } = action.payload; - if (view !== View.BEATMAP) return state; - return removeAllSelected(state); - }); - builder.addCase(pasteSelection.fulfilled, (state, action) => { - const { view, data, deltaBetweenPeriods } = action.payload; - if (view !== View.BEATMAP) return state; - if (!data.bombs) return state; - updateAll(state, () => ({ selected: false })); - return adapter.upsertMany( - state, - data.bombs.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), - ); - }); - builder.addCase(selectAllEntities.fulfilled, (state, action) => { - const { view } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAll(state, () => ({ selected: true })); - }); - builder.addCase(deselectAllEntities, (state, action) => { - const { view } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAll(state, () => ({ selected: false })); - }); - builder.addCase(selectAllEntitiesInRange, (state, action) => { - const { startBeat, endBeat, view } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAll(state, (x) => ({ selected: x.time >= startBeat - 0.01 && x.time < endBeat })); - }); - builder.addCase(mirrorSelection, (state, action) => { - const { axis, grid } = action.payload; - return replaceAllSelected(state, (x) => ({ ...mirrorGridObjectProperties(x, axis, grid, 0) })); - }); - builder.addCase(nudgeSelection.fulfilled, (state, action) => { - const { view, direction, amount } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAllSelected(state, (x) => nudgeItem(x, direction, amount)); - }); - builder.addCase(deselectAllEntitiesOfType, (state, action) => { - const { itemType } = action.payload; - if (itemType !== ObjectType.BOMB) return state; - return updateAll(state, () => ({ selected: false })); - }); - builder.addMatcher(isAnyOf(addSong, startLoadingMap, leaveEditor), () => adapter.getInitialState()); - builder.addMatcher( - isAnyOf(removeNote, bulkRemoveNote), - createNoteReducer((match, state) => { - return adapter.removeOne(state, adapter.selectId(match)); - }), - ); - builder.addMatcher( - isAnyOf(selectNote, deselectNote), - createNoteReducer((match, state, action) => { - return adapter.updateOne(state, { id: adapter.selectId(match), changes: { selected: selectNote.match(action) } }); - }), - ); - builder.addDefaultCase((state) => state); - }, -}); - -export default slice; diff --git a/src/store/features/entities/beatmap/index.ts b/src/store/features/entities/beatmap/index.ts deleted file mode 100644 index 506b4b26..00000000 --- a/src/store/features/entities/beatmap/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { combineReducers, type UnknownAction } from "@reduxjs/toolkit"; -import undoable, { type FilterFunction, type GroupByFunction, groupByActionTypes, includeAction } from "redux-undo"; - -import { addObstacle, addToCell, bulkRemoveNote, cutSelection, loadBeatmapEntities, mirrorColorNote, mirrorSelection, nudgeSelection, pasteSelection, redoObjects, removeAllSelectedObjects, removeNote, removeObstacle, undoObjects, updateAllSelectedObstacles, updateObstacle } from "$/store/actions"; -import bombs from "./bombs.slice"; -import notes from "./notes.slice"; -import obstacles from "./obstacles.slice"; - -const reducer = combineReducers({ - bombs: bombs.reducer, - notes: notes.reducer, - obstacles: obstacles.reducer, -}); - -const filter: FilterFunction, UnknownAction> = includeAction([ - loadBeatmapEntities.type, - addToCell.fulfilled.type, - removeNote.type, - bulkRemoveNote.type, - mirrorColorNote.type, - addObstacle.fulfilled.type, - updateObstacle.type, - updateAllSelectedObstacles.type, - removeObstacle.type, - mirrorSelection.type, - removeAllSelectedObjects.type, - cutSelection.fulfilled.type, - pasteSelection.fulfilled.type, - nudgeSelection.fulfilled.type, - // -]); -const groupBy: GroupByFunction, UnknownAction> = groupByActionTypes([bulkRemoveNote.type]); - -export default { - reducer: undoable(reducer, { - limit: 100, - ignoreInitialState: true, - undoType: undoObjects.type, - redoType: redoObjects.type, - filter: filter, - groupBy: groupBy, - }), -}; diff --git a/src/store/features/entities/beatmap/notes.slice.ts b/src/store/features/entities/beatmap/notes.slice.ts deleted file mode 100644 index 35008f4e..00000000 --- a/src/store/features/entities/beatmap/notes.slice.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { createEntityAdapter, createSlice, type EntityId, isAnyOf } from "@reduxjs/toolkit"; -import { createColorNote, type IWrapColorNote, mirrorNoteColor, sortObjectFn } from "bsmap"; - -import { mirrorBaseNoteProperties, mirrorGridObjectProperties, nudgeItem } from "$/helpers/item.helpers"; -import { resolveNoteId } from "$/helpers/notes.helpers"; -import { - addSong, - addToCell, - bulkRemoveNote, - cutSelection, - deselectAllEntities, - deselectAllEntitiesOfType, - deselectNote, - leaveEditor, - loadBeatmapEntities, - mirrorSelection, - nudgeSelection, - pasteSelection, - removeAllSelectedObjects, - removeFromCell, - removeNote, - selectAllEntities, - selectAllEntitiesInRange, - selectNote, - startLoadingMap, -} from "$/store/actions"; -import { createEditorObjectReducers, createEditorObjectSelectors, createGridObjectReducerFactory } from "$/store/helpers"; -import { type App, ObjectTool, ObjectType, View } from "$/types"; - -const adapter = createEntityAdapter, EntityId>({ - selectId: resolveNoteId, - sortComparer: sortObjectFn, -}); - -const { selectAll, selectTotal } = adapter.getSelectors(); -const { selectAllSelected } = createEditorObjectSelectors(adapter); -const { removeAllSelected, updateAll, updateAllSelected, replaceAllSelected } = createEditorObjectReducers(adapter); - -const createNoteReducer = createGridObjectReducerFactory(adapter); - -const slice = createSlice({ - name: "notes", - initialState: adapter.getInitialState(), - selectors: { - selectAll: selectAll, - selectAllSelected: selectAllSelected, - selectTotal: selectTotal, - }, - reducers: { - updateOne: createNoteReducer<{ changes: Partial }>((data, state, action) => { - return adapter.updateOne(state, { id: adapter.selectId(data), changes: action.payload.changes }); - }), - mirrorOne: createNoteReducer((data, state) => { - return adapter.updateOne(state, { id: adapter.selectId(data), changes: { color: mirrorNoteColor(data.color) } }); - }), - }, - extraReducers: (builder) => { - builder.addCase(loadBeatmapEntities, (state, action) => { - const { notes } = action.payload; - return adapter.setAll(state, notes ?? []); - }); - builder.addCase(addToCell.fulfilled, (state, action) => { - const { query, tool: selectedTool, direction } = action.payload; - if (!selectedTool || (selectedTool !== ObjectTool.LEFT_NOTE && selectedTool !== ObjectTool.RIGHT_NOTE)) return state; - const color = Object.values(ObjectTool).indexOf(selectedTool) as 0 | 1; - return adapter.upsertOne(state, createColorNote({ ...query, color: color, direction: direction })); - }); - builder.addCase( - removeFromCell.fulfilled, - createNoteReducer((match, state) => { - return adapter.removeOne(state, adapter.selectId(match)); - }), - ); - builder.addCase(removeAllSelectedObjects, (state) => { - return removeAllSelected(state); - }); - builder.addCase(cutSelection.fulfilled, (state, action) => { - const { view } = action.payload; - if (view !== View.BEATMAP) return state; - return removeAllSelected(state); - }); - builder.addCase(pasteSelection.fulfilled, (state, action) => { - const { view, data, deltaBetweenPeriods } = action.payload; - if (view !== View.BEATMAP) return state; - if (!data.notes) return state; - updateAll(state, () => ({ selected: false })); - return adapter.upsertMany( - state, - data.notes.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), - ); - }); - builder.addCase(selectAllEntities.fulfilled, (state, action) => { - const { view } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAll(state, () => ({ selected: true })); - }); - builder.addCase(deselectAllEntities, (state, action) => { - const { view } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAll(state, () => ({ selected: false })); - }); - builder.addCase(selectAllEntitiesInRange, (state, action) => { - const { startBeat, endBeat, view } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAll(state, (x) => ({ selected: x.time >= startBeat - 0.01 && x.time < endBeat })); - }); - builder.addCase(mirrorSelection, (state, action) => { - const { axis, grid } = action.payload; - return replaceAllSelected(state, (x) => ({ ...mirrorGridObjectProperties(x, axis, grid, 0), ...mirrorBaseNoteProperties(x, axis) })); - }); - builder.addCase(nudgeSelection.fulfilled, (state, action) => { - const { view, direction, amount } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAllSelected(state, (x) => nudgeItem(x, direction, amount)); - }); - builder.addCase(deselectAllEntitiesOfType, (state, action) => { - const { itemType } = action.payload; - if (itemType !== ObjectType.NOTE) return state; - return updateAll(state, () => ({ selected: false })); - }); - builder.addMatcher(isAnyOf(addSong, startLoadingMap, leaveEditor), () => adapter.getInitialState()); - builder.addMatcher( - isAnyOf(removeNote, bulkRemoveNote), - createNoteReducer((match, state) => { - return adapter.removeOne(state, adapter.selectId(match)); - }), - ); - builder.addMatcher( - isAnyOf(selectNote, deselectNote), - createNoteReducer((match, state, action) => { - return adapter.updateOne(state, { id: adapter.selectId(match), changes: { selected: selectNote.match(action) } }); - }), - ); - builder.addDefaultCase((state) => state); - }, -}); - -export default slice; diff --git a/src/store/features/entities/beatmap/obstacles.slice.ts b/src/store/features/entities/beatmap/obstacles.slice.ts deleted file mode 100644 index 1cc93082..00000000 --- a/src/store/features/entities/beatmap/obstacles.slice.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { type AsyncThunkPayloadCreator, createEntityAdapter, type EntityId, isAnyOf, type Update } from "@reduxjs/toolkit"; -import { createObstacle, type IWrapObstacle, sortObjectFn } from "bsmap"; - -import { mirrorGridObjectProperties, nudgeItem } 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 { createEditorObjectReducers, createEditorObjectSelectors, createSlice } from "$/store/helpers"; -import { selectCursorPositionInBeats } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; -import { type App, ObjectType, type SongId, View } from "$/types"; -import { roundAwayFloatingPointNonsense } from "$/utils"; - -const adapter = createEntityAdapter, EntityId>({ - selectId: resolveObstacleId, - sortComparer: sortObjectFn, -}); - -const { selectAll, selectTotal } = adapter.getSelectors(); -const { selectAllSelected } = createEditorObjectSelectors(adapter); - -const { removeAllSelected, updateAll, updateAllSelected, replaceAllSelected } = createEditorObjectReducers(adapter); - -const slice = createSlice({ - name: "obstacles", - initialState: adapter.getInitialState(), - selectors: { - selectAll: selectAll, - selectAllSelected: selectAllSelected, - selectTotal: selectTotal, - }, - reducers: (api) => { - const createFromState: AsyncThunkPayloadCreator<{ obstacle: Partial }, { songId: SongId; obstacle: Partial }> = (args, api) => { - const state = api.getState() as RootState; - const cursorPositionInBeats = selectCursorPositionInBeats(state, args.songId); - if (cursorPositionInBeats === null) return api.rejectWithValue("Invalid beat number."); - return api.fulfillWithValue({ - obstacle: { ...args.obstacle, time: roundAwayFloatingPointNonsense(cursorPositionInBeats) }, - }); - }; - - return { - addOne: api.asyncThunk(createFromState, { - fulfilled: (state, action) => { - const { obstacle: data } = action.payload; - return adapter.addOne(state, createObstacle(data)); - }, - }), - updateOne: api.reducer>((state, action) => { - return adapter.updateOne(state, action.payload); - }), - selectOne: api.reducer<{ id: EntityId }>((state, action) => { - const { id } = action.payload; - return adapter.updateOne(state, { id, changes: { selected: true } }); - }), - deselectOne: api.reducer<{ id: EntityId }>((state, action) => { - const { id } = action.payload; - return adapter.updateOne(state, { id, changes: { selected: false } }); - }), - updateAllSelected: api.reducer<{ changes: Partial }>((state, action) => { - const { changes } = action.payload; - const entities = selectAllSelected(state); - return adapter.updateMany( - state, - entities.map((x) => ({ id: adapter.selectId(x), changes })), - ); - }), - removeOne: api.reducer<{ id: EntityId }>((state, action) => { - return adapter.removeOne(state, action.payload.id); - }), - }; - }, - extraReducers: (builder) => { - builder.addCase(loadBeatmapEntities, (state, action) => { - const { obstacles } = action.payload; - return adapter.setAll(state, obstacles ?? []); - }); - builder.addCase(addObstacle.fulfilled, (state, action) => { - const { obstacle: data } = action.payload; - return adapter.addOne(state, createObstacle(data)); - }); - builder.addCase(removeAllSelectedObjects, (state) => { - return removeAllSelected(state); - }); - builder.addCase(cutSelection.fulfilled, (state, action) => { - const { view } = action.payload; - if (view !== View.BEATMAP) return state; - return removeAllSelected(state); - }); - builder.addCase(pasteSelection.fulfilled, (state, action) => { - const { view, data, deltaBetweenPeriods } = action.payload; - if (view !== View.BEATMAP) return state; - if (!data.obstacles) return state; - updateAll(state, () => ({ selected: false })); - return adapter.upsertMany( - state, - data.obstacles.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), - ); - }); - builder.addCase(selectAllEntities.fulfilled, (state, action) => { - const { view } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAll(state, () => ({ selected: true })); - }); - builder.addCase(deselectAllEntities, (state, action) => { - const { view } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAll(state, () => ({ selected: false })); - }); - builder.addCase(selectAllEntitiesInRange, (state, action) => { - const { startBeat, endBeat, view } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAll(state, (x) => ({ selected: x.time >= startBeat - 0.01 && x.time < endBeat })); - }); - builder.addCase(mirrorSelection, (state, action) => { - const { axis, grid } = action.payload; - if (axis === "vertical") return state; - return replaceAllSelected(state, (x) => ({ ...mirrorGridObjectProperties(x, axis, grid, 0) })); - }); - builder.addCase(nudgeSelection.fulfilled, (state, action) => { - const { view, direction, amount } = action.payload; - if (view !== View.BEATMAP) return state; - return updateAllSelected(state, (x) => nudgeItem(x, direction, amount)); - }); - builder.addCase(deselectAllEntitiesOfType, (state, action) => { - const { itemType } = action.payload; - if (itemType !== ObjectType.OBSTACLE) return state; - return updateAll(state, () => ({ selected: false })); - }); - builder.addMatcher(isAnyOf(addSong, startLoadingMap, leaveEditor), () => adapter.getInitialState()); - builder.addDefaultCase((state) => state); - }, -}); - -export default slice; diff --git a/src/store/features/entities/events.slice.ts b/src/store/features/entities/events.slice.ts new file mode 100644 index 00000000..bf3afe84 --- /dev/null +++ b/src/store/features/entities/events.slice.ts @@ -0,0 +1,258 @@ +import { createDraftSafeSelector, createEntityAdapter, createSlice, type EntityId, isAnyOf } from "@reduxjs/toolkit"; +import { type EnvironmentName, type IWrapBasicEvent, type IWrapColorBoostEvent, sortObjectFn } from "bsmap"; +import { createHistoryAdapter, type HistoryState } from "history-adapter/redux"; + +import { isBasicEvent, isBoostEvent, isTrackGroupable, resolveEventId, resolveGroupTrackIds, resolveTrackIdForEvent } from "$/helpers/events.helpers"; +import { nudgeItem } from "$/helpers/item.helpers"; +import { cutSelection, deselectAllEntities, drawEventSelectionBox, leaveEditor, loadBeatmapEntities, nudgeSelection, pasteSelection, selectAllEntities, selectAllEntitiesInRange, startLoadingMap } from "$/store/actions"; +import { createEditorObjectAdapter, selectNextSnapshot, selectPrevSnapshot } from "$/store/helpers"; +import { type App, View } from "$/types"; + +const basicEvents = createEntityAdapter, EntityId>({ selectId: resolveEventId, sortComparer: sortObjectFn }); +const boostEvents = createEntityAdapter, EntityId>({ selectId: resolveEventId, sortComparer: sortObjectFn }); + +const basicSelectors = basicEvents.getSelectors(); +const boostSelectors = boostEvents.getSelectors(); + +const { updateAll: updateAllBasicEvents, updateAllSelected: updateAllSelectedBasicEvents, removeAllSelected: removeAllSelectedBasicEvents } = createEditorObjectAdapter(basicEvents); +const { updateAll: updateAllBoostEvents, updateAllSelected: updateAllSelectedBoostEvents, removeAllSelected: removeAllSelectedBoostEvents } = createEditorObjectAdapter(boostEvents); + +interface State { + basicEvents: ReturnType; + boostEvents: ReturnType; +} + +const history = createHistoryAdapter({ limit: 100 }); + +const { selectPresent, selectCanRedo, selectCanUndo } = history.getSelectors(); + +const slice = createSlice({ + name: "events", + initialState: history.getInitialState({ + basicEvents: basicEvents.getInitialState(), + boostEvents: boostEvents.getInitialState(), + }), + selectors: { + selectCanRedo, + selectCanUndo, + selectAllBasicEvents: createDraftSafeSelector(selectPresent, (state) => basicSelectors.selectAll(state.basicEvents)), + selectAllBasicEventsForTrack: createDraftSafeSelector([selectPresent, (_, trackId: number) => trackId], (state, trackId) => { + return basicSelectors.selectAll(state.basicEvents).filter((x) => resolveTrackIdForEvent(x) === trackId); + }), + selectAllSelectedBasicEvents: createDraftSafeSelector(selectPresent, (state) => basicSelectors.selectAll(state.basicEvents).filter((data) => !!data.selected)), + selectTotalBasicEvents: (state) => basicSelectors.selectTotal(state.present.basicEvents), + selectPastBasicEvents: createDraftSafeSelector(selectPrevSnapshot, (state) => basicSelectors.selectAll(state.basicEvents)), + selectFutureBasicEvents: createDraftSafeSelector(selectNextSnapshot, (state) => basicSelectors.selectAll(state.basicEvents)), + selectAllBoostEvents: createDraftSafeSelector(selectPresent, (state) => boostSelectors.selectAll(state.boostEvents)), + selectAllSelectedBoostEvents: createDraftSafeSelector(selectPresent, (state) => boostSelectors.selectAll(state.boostEvents).filter((data) => !!data.selected)), + selectTotalBoostEvents: (state) => boostSelectors.selectTotal(state.present.boostEvents), + selectPastBoostEvents: createDraftSafeSelector(selectPrevSnapshot, (state) => boostSelectors.selectAll(state.boostEvents)), + selectFutureBoostEvents: createDraftSafeSelector(selectNextSnapshot, (state) => boostSelectors.selectAll(state.boostEvents)), + selectValueForTrackAtBeat: createDraftSafeSelector([selectPresent, (_: HistoryState, options: { trackId: number; beforeBeat: number }) => options], (state, options) => { + const events = basicSelectors.selectAll(state.basicEvents).filter((x) => { + return x.time < options.beforeBeat && resolveTrackIdForEvent(x) === options.trackId; + }); + return events[events.length - 1]?.value ?? 0; + }), + selectColorBoostAtBeat: createDraftSafeSelector([selectPresent, (_: HistoryState, options: { beforeBeat: number }) => options], (state, options) => { + const events = boostSelectors.selectAll(state.boostEvents).filter((x) => { + return x.time < options.beforeBeat; + }); + return events[events.length - 1]?.toggle ?? false; + }), + selectSelectedEvents: createDraftSafeSelector(selectPresent, (state) => { + const basicEvents = basicSelectors.selectAll(state.basicEvents).filter((data) => !!data.selected); + const boostEvents = boostSelectors.selectAll(state.boostEvents).filter((data) => !!data.selected); + return { + basicEvents: basicEvents.length > 0 ? basicEvents : undefined, + boostEvents: boostEvents.length > 0 ? boostEvents : undefined, + }; + }), + selectAnySelectedEvents: createDraftSafeSelector(selectPresent, (state) => { + const basicEvents = basicSelectors.selectAll(state.basicEvents).filter((data) => !!data.selected); + const boostEvents = boostSelectors.selectAll(state.boostEvents).filter((data) => !!data.selected); + return basicEvents.length + boostEvents.length > 0; + }), + }, + reducers: (api) => { + return { + undo: history.undo, + redo: history.redo, + clearHistory: history.clearHistory, + addBasicEvent: api.reducer<{ data: IWrapBasicEvent; overwrite?: boolean; environment: EnvironmentName; areLasersLocked?: boolean }>( + history.undoable((state, action) => { + const trackId = resolveTrackIdForEvent(action.payload.data); + basicEvents.upsertOne(state.basicEvents, action.payload.data); + if (action.payload.areLasersLocked && isTrackGroupable(trackId, action.payload.environment)) { + const groupTrackIds = resolveGroupTrackIds(trackId, action.payload.environment); + for (const mirrorTrackId of groupTrackIds) { + basicEvents.upsertOne(state.basicEvents, { ...action.payload.data, type: mirrorTrackId }); + } + } + }), + ), + updateBasicEvent: api.reducer<{ id: EntityId; changes: Partial; environment: EnvironmentName; areLasersLocked?: boolean }>( + history.undoable((state, action) => { + const data = basicSelectors.selectById(state.basicEvents, action.payload.id); + const trackId = resolveTrackIdForEvent(data); + basicEvents.updateOne(state.basicEvents, { id: action.payload.id, changes: action.payload.changes }); + if (action.payload.areLasersLocked && isTrackGroupable(trackId, action.payload.environment)) { + const groupTrackIds = resolveGroupTrackIds(trackId, action.payload.environment); + for (const mirrorTrackId of groupTrackIds) { + const mirrorEventId = basicEvents.selectId({ ...data, type: mirrorTrackId }); + basicEvents.updateOne(state.basicEvents, { id: mirrorEventId, changes: action.payload.changes }); + } + } + }), + ), + removeBasicEvent: api.reducer<{ id: EntityId; environment: EnvironmentName; areLasersLocked?: boolean }>( + history.undoable((state, action) => { + const data = basicSelectors.selectById(state.basicEvents, action.payload.id); + const trackId = resolveTrackIdForEvent(data); + basicEvents.removeOne(state.basicEvents, action.payload.id); + if (action.payload.areLasersLocked && isTrackGroupable(trackId, action.payload.environment)) { + const groupTrackIds = resolveGroupTrackIds(trackId, action.payload.environment); + for (const mirrorTrackId of groupTrackIds) { + const mirrorEventId = basicEvents.selectId({ ...data, type: mirrorTrackId }); + basicEvents.removeOne(state.basicEvents, mirrorEventId); + } + } + }), + ), + selectBasicEvent: api.reducer<{ id: EntityId; environment: EnvironmentName; areLasersLocked?: boolean }>((state, action) => { + basicEvents.updateOne(state.present.basicEvents, { id: action.payload.id, changes: { selected: true } }); + }), + deselectBasicEvent: api.reducer<{ id: EntityId; environment: EnvironmentName; areLasersLocked?: boolean }>((state, action) => { + boostEvents.updateOne(state.present.boostEvents, { id: action.payload.id, changes: { selected: false } }); + }), + addBoostEvent: api.reducer<{ data: IWrapColorBoostEvent; overwrite?: boolean; environment: EnvironmentName; areLasersLocked?: boolean }>( + history.undoable((state, action) => { + boostEvents.upsertOne(state.boostEvents, action.payload.data); + }), + ), + updateBoostEvent: api.reducer<{ id: EntityId; changes: Partial; environment: EnvironmentName; areLasersLocked?: boolean }>( + history.undoable((state, action) => { + boostEvents.updateOne(state.boostEvents, action.payload); + }), + ), + removeBoostEvent: api.reducer<{ id: EntityId; environment: EnvironmentName; areLasersLocked?: boolean }>( + history.undoable((state, action) => { + boostEvents.removeOne(state.boostEvents, action.payload.id); + }), + ), + selectBoostEvent: api.reducer<{ id: EntityId; environment: EnvironmentName; areLasersLocked?: boolean }>((state, action) => { + boostEvents.updateOne(state.present.boostEvents, { id: action.payload.id, changes: { selected: true } }); + }), + deselectBoostEvent: api.reducer<{ id: EntityId; environment: EnvironmentName; areLasersLocked?: boolean }>((state, action) => { + boostEvents.updateOne(state.present.boostEvents, { id: action.payload.id, changes: { selected: false } }); + }), + removeAllSelectedEvents: api.reducer( + history.undoable((state) => { + removeAllSelectedBasicEvents(state.basicEvents); + removeAllSelectedBoostEvents(state.boostEvents); + }), + ), + }; + }, + extraReducers: (builder) => { + builder.addCase(loadBeatmapEntities, (state, action) => { + basicEvents.setAll(state.present.basicEvents, action.payload.basicEvents ?? []); + boostEvents.setAll(state.present.boostEvents, action.payload.boostEvents ?? []); + }); + builder.addCase( + nudgeSelection.fulfilled, + history.undoable((state, action) => { + if (action.payload.view !== View.LIGHTSHOW) return state; + const { direction, amount } = action.payload; + updateAllSelectedBasicEvents(state.basicEvents, nudgeItem(direction, amount)); + updateAllSelectedBoostEvents(state.boostEvents, nudgeItem(direction, amount)); + }), + ); + builder.addCase( + cutSelection.fulfilled, + history.undoable((state, action) => { + if (action.payload.view !== View.LIGHTSHOW) return state; + removeAllSelectedBasicEvents(state.basicEvents); + removeAllSelectedBoostEvents(state.boostEvents); + }), + ); + builder.addCase( + pasteSelection.fulfilled, + history.undoable((state, action) => { + if (action.payload.view !== View.LIGHTSHOW) return state; + if (action.payload.data.basicEvents) { + basicEvents.upsertMany( + state.basicEvents, + action.payload.data.basicEvents.map((x) => ({ ...x, selected: true, time: x.time + action.payload.deltaBetweenPeriods })), + ); + } + if (action.payload.data.boostEvents) { + boostEvents.upsertMany( + state.boostEvents, + action.payload.data.boostEvents.map((x) => ({ ...x, selected: true, time: x.time + action.payload.deltaBetweenPeriods })), + ); + } + }), + ); + builder.addCase(selectAllEntities.fulfilled, (state, action) => { + if (action.payload.view !== View.LIGHTSHOW) return state; + updateAllBasicEvents(state.present.basicEvents, () => ({ selected: true })); + updateAllBoostEvents(state.present.boostEvents, () => ({ selected: true })); + }); + builder.addCase(deselectAllEntities, (state, action) => { + if (action.payload.view !== View.LIGHTSHOW) return state; + updateAllBasicEvents(state.present.basicEvents, () => ({ selected: false })); + updateAllBoostEvents(state.present.boostEvents, () => ({ selected: false })); + }); + builder.addCase(selectAllEntitiesInRange, (state, action) => { + if (action.payload.view !== View.LIGHTSHOW) return state; + updateAllBasicEvents(state.present.basicEvents, (x) => ({ selected: x.time >= action.payload.startBeat - 0.01 && x.time < action.payload.endBeat })); + updateAllBoostEvents(state.present.boostEvents, (x) => ({ selected: x.time >= action.payload.startBeat - 0.01 && x.time < action.payload.endBeat })); + }); + builder.addCase(drawEventSelectionBox.fulfilled, (state, action) => { + const { tracks, selectionBoxInBeats, metadata } = action.payload; + + const allTracks = Object.keys(tracks).concat("5"); + const allBasicEvents = basicSelectors.selectAll(state.present.basicEvents); + const allBoostEvents = boostSelectors.selectAll(state.present.boostEvents); + + if (!selectionBoxInBeats.withPrevious) { + basicEvents.updateMany( + state.present.basicEvents, + allBasicEvents.filter((x) => x.selected).map((x) => ({ id: basicEvents.selectId(x), changes: { selected: false } })), + ); + boostEvents.updateMany( + state.present.boostEvents, + allBoostEvents.filter((x) => x.selected).map((x) => ({ id: boostEvents.selectId(x), changes: { selected: false } })), + ); + } + + const allVisibleEvents = [...allBasicEvents, ...allBoostEvents].filter((x) => { + const isInWindow = x.time >= metadata.window.startBeat && x.time <= metadata.window.endBeat; + const isInVisibleTracks = x.time >= selectionBoxInBeats.startBeat && x.time <= selectionBoxInBeats.endBeat; + return isInWindow && isInVisibleTracks; + }); + + for (const event of allVisibleEvents) { + const eventTrackIndex = allTracks.findIndex((id) => Number.parseInt(id, 10) === resolveTrackIdForEvent(event)); + const isInSelectionBox = eventTrackIndex >= selectionBoxInBeats.startTrackIndex && eventTrackIndex <= selectionBoxInBeats.endTrackIndex; + + if (isBasicEvent>(event)) { + basicEvents.updateOne(state.present.basicEvents, { id: basicEvents.selectId(event), changes: { selected: isInSelectionBox || (selectionBoxInBeats.withPrevious && event.selected) } }); + } + if (isBoostEvent>(event)) { + boostEvents.updateOne(state.present.boostEvents, { id: boostEvents.selectId(event), changes: { selected: isInSelectionBox || (selectionBoxInBeats.withPrevious && event.selected) } }); + } + } + }); + builder.addMatcher(isAnyOf(startLoadingMap, leaveEditor), () => { + history.getInitialState({ + basicEvents: basicEvents.getInitialState(), + boostEvents: boostEvents.getInitialState(), + }); + }); + builder.addDefaultCase((state) => state); + }, +}); + +export default slice; diff --git a/src/store/features/entities/index.ts b/src/store/features/entities/index.ts index 8d2d3488..9d046956 100644 --- a/src/store/features/entities/index.ts +++ b/src/store/features/entities/index.ts @@ -1,12 +1,12 @@ import { combineReducers } from "@reduxjs/toolkit"; -import beatmap from "./beatmap"; import editor from "./editor"; -import lightshow from "./lightshow"; +import events from "./events.slice"; +import objects from "./objects.slice"; const reducer = combineReducers({ - beatmap: beatmap.reducer, - lightshow: lightshow.reducer, + beatmap: objects.reducer, + lightshow: events.reducer, editor: editor.reducer, }); diff --git a/src/store/features/entities/lightshow/basic.slice.ts b/src/store/features/entities/lightshow/basic.slice.ts deleted file mode 100644 index 914ca606..00000000 --- a/src/store/features/entities/lightshow/basic.slice.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { createEntityAdapter, createSlice, type EntityId, isAnyOf } from "@reduxjs/toolkit"; -import { createBasicEvent, type IWrapBasicEvent, sortObjectFn } from "bsmap"; - -import { resolveEventId, resolveTrackIdForEvent } from "$/helpers/events.helpers"; -import { nudgeItem } from "$/helpers/item.helpers"; -import { addSong, bulkRemoveEvent, cutSelection, deselectAllEntities, deselectEvent, drawEventSelectionBox, leaveEditor, loadBeatmapEntities, nudgeSelection, pasteSelection, removeAllSelectedEvents, removeEvent, selectAllEntities, selectAllEntitiesInRange, selectEvent, startLoadingMap } from "$/store/actions"; -import { createEditorObjectReducers, createEditorObjectSelectors, createEventReducerFactory, createEventSelectors } from "$/store/helpers"; -import { type App, View } from "$/types"; - -const adapter = createEntityAdapter, EntityId>({ - selectId: resolveEventId, - sortComparer: sortObjectFn, -}); - -const { selectAll } = adapter.getSelectors(); -const { selectAllSelected } = createEditorObjectSelectors(adapter); -const { selectAllForTrack, createEventSelector } = createEventSelectors(adapter); -const { removeAllSelected, updateAll, updateAllSelected } = createEditorObjectReducers(adapter); - -const createEventReducer = createEventReducerFactory(adapter); - -const slice = createSlice({ - name: "basicEvents", - initialState: adapter.getInitialState(), - selectors: { - selectAll: selectAll, - selectAllSelected: selectAllSelected, - selectAllForTrack: selectAllForTrack, - selectValueForTrackAtBeat: createEventSelector((data) => data.value, 0), - }, - reducers: () => { - return { - addOne: createEventReducer<{ data: IWrapBasicEvent; overwrite?: boolean }>(({ match, trackId }, state, action) => { - const { data, overwrite } = action.payload; - if (!overwrite && match) return state; - return adapter.upsertOne(state, createBasicEvent({ ...data, type: trackId })); - }), - updateOne: createEventReducer<{ changes: Partial }>(({ match, trackId }, state, action) => { - if (!match) return state; - return adapter.updateOne(state, { id: adapter.selectId({ ...match, type: trackId }), changes: action.payload.changes }); - }), - }; - }, - extraReducers: (builder) => { - builder.addCase(loadBeatmapEntities, (state, action) => { - const { basicEvents } = action.payload; - return adapter.setAll(state, basicEvents ?? []); - }); - builder.addCase(removeAllSelectedEvents, (state) => { - return removeAllSelected(state); - }); - builder.addCase(cutSelection.fulfilled, (state, action) => { - const { view } = action.payload; - if (view !== View.LIGHTSHOW) return state; - return removeAllSelected(state); - }); - builder.addCase(pasteSelection.fulfilled, (state, action) => { - const { view, data, deltaBetweenPeriods } = action.payload; - if (view !== View.LIGHTSHOW) return state; - if (!data.basicEvents) return state; - updateAll(state, () => ({ selected: false })); - return adapter.upsertMany( - state, - data.basicEvents.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), - ); - }); - builder.addCase(selectAllEntities.fulfilled, (state, action) => { - const { view, metadata } = action.payload; - if (view !== View.LIGHTSHOW || !metadata) return state; - return updateAll(state, () => ({ selected: true })); - }); - builder.addCase(deselectAllEntities, (state, action) => { - const { view } = action.payload; - if (view !== View.LIGHTSHOW) return state; - return updateAll(state, () => ({ selected: false })); - }); - builder.addCase(selectAllEntitiesInRange, (state, action) => { - const { startBeat, endBeat, view } = action.payload; - if (view !== View.LIGHTSHOW) return state; - return updateAll(state, (x) => ({ selected: x.time >= startBeat - 0.01 && x.time < endBeat })); - }); - builder.addCase(drawEventSelectionBox.fulfilled, (state, action) => { - const { tracks, selectionBoxInBeats, metadata } = action.payload; - const allEntities = selectAll(state); - const allTracks = Object.keys(tracks).concat("5"); - if (!selectionBoxInBeats.withPrevious) { - const allSelected = allEntities.filter((x) => x.selected); - adapter.updateMany( - state, - allSelected.map((x) => ({ id: adapter.selectId(x), changes: { selected: false } })), - ); - } - const allVisible = allEntities.filter((x) => { - const isInWindow = x.time >= metadata.window.startBeat && x.time <= metadata.window.endBeat; - const isInVisibleTracks = x.time >= selectionBoxInBeats.startBeat && x.time <= selectionBoxInBeats.endBeat; - return isInWindow && isInVisibleTracks; - }); - for (const event of allVisible) { - const eventTrackIndex = allTracks.findIndex((id) => Number.parseInt(id, 10) === resolveTrackIdForEvent(event)); - const isInSelectionBox = eventTrackIndex >= selectionBoxInBeats.startTrackIndex && eventTrackIndex <= selectionBoxInBeats.endTrackIndex; - adapter.updateOne(state, { id: adapter.selectId(event), changes: { selected: isInSelectionBox || (selectionBoxInBeats.withPrevious && event.selected) } }); - } - }); - builder.addCase(nudgeSelection.fulfilled, (state, action) => { - const { view, direction, amount } = action.payload; - if (view !== View.LIGHTSHOW) return state; - return updateAllSelected(state, (x) => nudgeItem(x, direction, amount)); - }); - builder.addMatcher(isAnyOf(addSong, startLoadingMap, leaveEditor), () => adapter.getInitialState()); - builder.addMatcher( - isAnyOf(removeEvent, bulkRemoveEvent), - createEventReducer(({ match, trackId }, state) => { - if (!match) return state; - return adapter.removeOne(state, adapter.selectId({ ...match, type: trackId })); - }), - ); - builder.addMatcher( - isAnyOf(selectEvent, deselectEvent), - createEventReducer(({ match, trackId }, state, action) => { - if (!match) return state; - return adapter.updateOne(state, { id: adapter.selectId({ ...match, type: trackId }), changes: { selected: selectEvent.match(action) } }); - }), - ); - builder.addDefaultCase((state) => state); - }, -}); - -export default slice; diff --git a/src/store/features/entities/lightshow/boost.slice.ts b/src/store/features/entities/lightshow/boost.slice.ts deleted file mode 100644 index 2d5c62ed..00000000 --- a/src/store/features/entities/lightshow/boost.slice.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { createEntityAdapter, createSlice, type EntityId, isAnyOf } from "@reduxjs/toolkit"; -import { createColorBoostEvent, type IWrapColorBoostEvent, sortObjectFn } from "bsmap"; - -import { isBoostEvent, resolveEventId, resolveTrackIdForEvent } from "$/helpers/events.helpers"; -import { nudgeItem } from "$/helpers/item.helpers"; -import { addSong, bulkRemoveEvent, cutSelection, deselectAllEntities, deselectEvent, drawEventSelectionBox, leaveEditor, loadBeatmapEntities, nudgeSelection, pasteSelection, removeAllSelectedEvents, removeEvent, selectAllEntities, selectAllEntitiesInRange, selectEvent, startLoadingMap } from "$/store/actions"; -import { createEditorObjectReducers, createEditorObjectSelectors, createEventReducerFactory, createEventSelectors } from "$/store/helpers"; -import { type App, View } from "$/types"; - -const adapter = createEntityAdapter, EntityId>({ - selectId: resolveEventId, - sortComparer: sortObjectFn, -}); - -const { selectAll } = adapter.getSelectors(); -const { selectAllSelected } = createEditorObjectSelectors(adapter); -const { createEventSelector } = createEventSelectors(adapter); -const { removeAllSelected, updateAll, updateAllSelected } = createEditorObjectReducers(adapter); - -const createEventReducer = createEventReducerFactory(adapter); - -const slice = createSlice({ - name: "basicEvents", - initialState: adapter.getInitialState(), - selectors: { - selectAll: selectAll, - selectAllSelected: selectAllSelected, - selectToggleAtBeat: createEventSelector((data) => data.toggle, false), - }, - reducers: () => { - return { - addOne: createEventReducer<{ data: IWrapColorBoostEvent; overwrite?: boolean }>(({ match }, state, action) => { - const { data, overwrite } = action.payload; - if (!overwrite && match) return state; - if (!isBoostEvent(data)) return state; - return adapter.upsertOne(state, createColorBoostEvent({ ...data })); - }), - updateOne: createEventReducer<{ changes: Partial }>(({ match }, state, action) => { - if (!match) return state; - return adapter.updateOne(state, { id: adapter.selectId({ ...match }), changes: action.payload.changes }); - }), - }; - }, - extraReducers: (builder) => { - builder.addCase(loadBeatmapEntities, (state, action) => { - const { boostEvents } = action.payload; - return adapter.setAll(state, boostEvents ?? []); - }); - builder.addCase(removeAllSelectedEvents, (state) => { - return removeAllSelected(state); - }); - builder.addCase(cutSelection.fulfilled, (state, action) => { - const { view } = action.payload; - if (view !== View.LIGHTSHOW) return state; - return removeAllSelected(state); - }); - builder.addCase(pasteSelection.fulfilled, (state, action) => { - const { view, data, deltaBetweenPeriods } = action.payload; - if (view !== View.LIGHTSHOW) return state; - if (!data.boostEvents) return state; - updateAll(state, () => ({ selected: false })); - return adapter.upsertMany( - state, - data.boostEvents.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), - ); - }); - builder.addCase(selectAllEntities.fulfilled, (state, action) => { - const { view, metadata } = action.payload; - if (view !== View.LIGHTSHOW || !metadata) return state; - return updateAll(state, () => ({ selected: true })); - }); - builder.addCase(deselectAllEntities, (state, action) => { - const { view } = action.payload; - if (view !== View.LIGHTSHOW) return state; - return updateAll(state, () => ({ selected: false })); - }); - builder.addCase(selectAllEntitiesInRange, (state, action) => { - const { startBeat, endBeat, view } = action.payload; - if (view !== View.LIGHTSHOW) return state; - return updateAll(state, (x) => ({ selected: x.time >= startBeat - 0.01 && x.time < endBeat })); - }); - builder.addCase(drawEventSelectionBox.fulfilled, (state, action) => { - const { tracks, selectionBoxInBeats, metadata } = action.payload; - const allEntities = selectAll(state); - const allTracks = Object.keys(tracks).concat("5"); - if (!selectionBoxInBeats.withPrevious) { - const allSelected = allEntities.filter((x) => x.selected); - adapter.updateMany( - state, - allSelected.map((x) => ({ id: adapter.selectId(x), changes: { selected: false } })), - ); - } - const allVisible = allEntities.filter((x) => { - const isInWindow = x.time >= metadata.window.startBeat && x.time <= metadata.window.endBeat; - const isInVisibleTracks = x.time >= selectionBoxInBeats.startBeat && x.time <= selectionBoxInBeats.endBeat; - return isInWindow && isInVisibleTracks; - }); - for (const event of allVisible) { - const eventTrackIndex = allTracks.findIndex((id) => Number.parseInt(id, 10) === resolveTrackIdForEvent(event)); - const isInSelectionBox = eventTrackIndex >= selectionBoxInBeats.startTrackIndex && eventTrackIndex <= selectionBoxInBeats.endTrackIndex; - adapter.updateOne(state, { id: adapter.selectId(event), changes: { selected: isInSelectionBox || (selectionBoxInBeats.withPrevious && event.selected) } }); - } - }); - builder.addCase(nudgeSelection.fulfilled, (state, action) => { - const { view, direction, amount } = action.payload; - if (view !== View.LIGHTSHOW) return state; - return updateAllSelected(state, (x) => nudgeItem(x, direction, amount)); - }); - builder.addMatcher(isAnyOf(addSong, startLoadingMap, leaveEditor), () => adapter.getInitialState()); - builder.addMatcher( - isAnyOf(removeEvent, bulkRemoveEvent), - createEventReducer(({ match }, state) => { - if (!match) return state; - return adapter.removeOne(state, adapter.selectId({ ...match })); - }), - ); - builder.addMatcher( - isAnyOf(selectEvent, deselectEvent), - createEventReducer(({ match }, state, action) => { - if (!match) return state; - return adapter.updateOne(state, { id: adapter.selectId({ ...match }), changes: { selected: selectEvent.match(action) } }); - }), - ); - builder.addDefaultCase((state) => state); - }, -}); - -export default slice; diff --git a/src/store/features/entities/lightshow/index.ts b/src/store/features/entities/lightshow/index.ts deleted file mode 100644 index c7401b82..00000000 --- a/src/store/features/entities/lightshow/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { combineReducers, type UnknownAction } from "@reduxjs/toolkit"; -import undoable, { type FilterFunction, type GroupByFunction, groupByActionTypes, includeAction } from "redux-undo"; - -import { addBasicEvent, addBoostEvent, bulkAddBasicEvent, bulkAddBoostEvent, bulkRemoveEvent, cutSelection, loadBeatmapEntities, nudgeSelection, pasteSelection, redoEvents, removeAllSelectedEvents, removeEvent, undoEvents, updateBasicEvent, updateBoostEvent } from "$/store/actions"; -import basicEvents from "./basic.slice"; -import boostEvents from "./boost.slice"; - -const reducer = combineReducers({ - basicEvents: basicEvents.reducer, - boostEvents: boostEvents.reducer, -}); - -const filter: FilterFunction, UnknownAction> = includeAction([ - loadBeatmapEntities.type, - addBasicEvent.type, - bulkAddBasicEvent.type, - updateBasicEvent.type, - addBoostEvent.type, - bulkAddBoostEvent.type, - updateBoostEvent.type, - removeEvent.type, - bulkRemoveEvent.type, - removeAllSelectedEvents.type, - cutSelection.fulfilled.type, - pasteSelection.fulfilled.type, - nudgeSelection.fulfilled.type, - // -]); -const groupBy: GroupByFunction, UnknownAction> = groupByActionTypes([bulkAddBasicEvent.type, bulkRemoveEvent.type]); - -export default { - reducer: undoable(reducer, { - limit: 100, - ignoreInitialState: true, - undoType: undoEvents.type, - redoType: redoEvents.type, - filter: filter, - groupBy: groupBy, - }), -}; diff --git a/src/store/features/entities/objects.slice.ts b/src/store/features/entities/objects.slice.ts new file mode 100644 index 00000000..4da9e472 --- /dev/null +++ b/src/store/features/entities/objects.slice.ts @@ -0,0 +1,270 @@ +import { createDraftSafeSelector, createEntityAdapter, createSelector, createSlice, type EntityId, isAnyOf, type Update } from "@reduxjs/toolkit"; +import { type IWrapBombNote, type IWrapColorNote, type IWrapObstacle, mirrorNoteColor, sortObjectFn } from "bsmap"; +import { createHistoryAdapter } from "history-adapter/redux"; + +import { mirrorBaseNoteProperties, mirrorGridObjectProperties, nudgeItem } from "$/helpers/item.helpers"; +import { resolveNoteId } from "$/helpers/notes.helpers"; +import { resolveObstacleId } from "$/helpers/obstacles.helpers"; +import { cutSelection, deselectAllEntities, deselectAllEntitiesOfType, leaveEditor, loadBeatmapEntities, mirrorSelection, nudgeSelection, pasteSelection, selectAllEntities, selectAllEntitiesInRange, startLoadingMap } from "$/store/actions"; +import { createEditorObjectAdapter, selectNextSnapshot, selectPrevSnapshot } from "$/store/helpers"; +import { type App, ObjectType, View } from "$/types"; + +const notes = createEntityAdapter, EntityId>({ selectId: resolveNoteId, sortComparer: sortObjectFn }); +const bombs = createEntityAdapter, EntityId>({ selectId: resolveNoteId, sortComparer: sortObjectFn }); +const obstacles = createEntityAdapter, EntityId>({ selectId: resolveObstacleId, sortComparer: sortObjectFn }); + +const noteSelectors = notes.getSelectors(); +const bombSelectors = bombs.getSelectors(); +const obstacleSelectors = obstacles.getSelectors(); + +const { updateAll: updateAllColorNotes, updateAllSelected: updateAllSelectedColorNotes, replaceAllSelected: replaceAllSelectedColorNotes, removeAllSelected: removeAllSelectedColorNotes } = createEditorObjectAdapter(notes); +const { updateAll: updateAllBombNotes, updateAllSelected: updateAllSelectedBombNotes, replaceAllSelected: replaceAllSelectedBombNotes, removeAllSelected: removeAllSelectedBombNotes } = createEditorObjectAdapter(bombs); +const { updateAll: updateAllObstacles, updateAllSelected: updateAllSelectedObstacles, replaceAllSelected: replaceAllSelectedObstacles, removeAllSelected: removeAllSelectedObstacles } = createEditorObjectAdapter(obstacles); + +interface State { + notes: ReturnType; + bombs: ReturnType; + obstacles: ReturnType; +} + +const history = createHistoryAdapter({ limit: 100 }); + +const { selectPresent, selectCanRedo, selectCanUndo } = history.getSelectors(); + +const slice = createSlice({ + name: "objects", + initialState: history.getInitialState({ + notes: notes.getInitialState(), + bombs: bombs.getInitialState(), + obstacles: obstacles.getInitialState(), + }), + selectors: { + selectCanRedo, + selectCanUndo, + selectAllColorNotes: createDraftSafeSelector(selectPresent, (state) => noteSelectors.selectAll(state.notes)), + selectAllSelectedColorNotes: createDraftSafeSelector(selectPresent, (state) => noteSelectors.selectAll(state.notes).filter((data) => !!data.selected)), + selectTotalColorNotes: (state) => noteSelectors.selectTotal(state.present.notes), + selectPastColorNotes: createDraftSafeSelector(selectPrevSnapshot, (state) => noteSelectors.selectAll(state.notes)), + selectFutureColorNotes: createDraftSafeSelector(selectNextSnapshot, (state) => noteSelectors.selectAll(state.notes)), + selectAllBombNotes: createDraftSafeSelector(selectPresent, (state) => bombSelectors.selectAll(state.bombs)), + selectAllSelectedBombNotes: createDraftSafeSelector(selectPresent, (state) => bombSelectors.selectAll(state.bombs).filter((data) => !!data.selected)), + selectTotalBombNotes: (state) => bombSelectors.selectTotal(state.present.bombs), + selectPastBombNotes: createDraftSafeSelector(selectPrevSnapshot, (state) => bombSelectors.selectAll(state.bombs)), + selectFutureBombNotes: createDraftSafeSelector(selectNextSnapshot, (state) => bombSelectors.selectAll(state.bombs)), + selectAllNotes: createDraftSafeSelector(selectPresent, (state) => [...noteSelectors.selectAll(state.notes), ...bombSelectors.selectAll(state.bombs)]), + selectAllObstacles: createDraftSafeSelector(selectPresent, (state) => obstacleSelectors.selectAll(state.obstacles)), + selectAllSelectedObstacles: createDraftSafeSelector(selectPresent, (state) => obstacleSelectors.selectAll(state.obstacles).filter((data) => !!data.selected)), + selectTotalObstacles: (state) => obstacleSelectors.selectTotal(state.present.obstacles), + selectPastObstacles: createDraftSafeSelector(selectPrevSnapshot, (state) => obstacleSelectors.selectAll(state.obstacles)), + selectFutureObstacles: createDraftSafeSelector(selectNextSnapshot, (state) => obstacleSelectors.selectAll(state.obstacles)), + selectSelectedObjects: createSelector(selectPresent, (state) => { + const notes = noteSelectors.selectAll(state.notes).filter((data) => !!data.selected); + const bombs = bombSelectors.selectAll(state.bombs).filter((data) => !!data.selected); + const obstacles = obstacleSelectors.selectAll(state.obstacles).filter((data) => !!data.selected); + return { + notes: notes.length > 0 ? notes : undefined, + bombs: bombs.length > 0 ? bombs : undefined, + obstacles: obstacles.length > 0 ? obstacles : undefined, + }; + }), + selectAnySelectedObjects: createSelector(selectPresent, (state) => { + const notes = noteSelectors.selectAll(state.notes).filter((data) => !!data.selected); + const bombs = bombSelectors.selectAll(state.bombs).filter((data) => !!data.selected); + const obstacles = obstacleSelectors.selectAll(state.obstacles).filter((data) => !!data.selected); + return notes.length + bombs.length + obstacles.length > 0; + }), + }, + reducers: (api) => { + return { + undo: history.undo, + redo: history.redo, + clearHistory: history.clearHistory, + addColorNote: api.reducer( + history.undoable((state, action) => { + notes.upsertOne(state.notes, action.payload); + }), + ), + updateColorNote: api.reducer<{ id: EntityId; changes: Partial }>( + history.undoable((state, action) => { + notes.updateOne(state.notes, { id: action.payload.id, changes: action.payload.changes }); + }), + ), + mirrorColorNote: api.reducer<{ id: EntityId }>( + history.undoable((state, action) => { + const data = noteSelectors.selectById(state.notes, action.payload.id); + notes.updateOne(state.notes, { id: action.payload.id, changes: { color: mirrorNoteColor(data.color) } }); + }), + ), + removeColorNote: api.reducer<{ id: EntityId }>( + history.undoable((state, action) => { + notes.removeOne(state.notes, action.payload.id); + }), + ), + selectColorNote: api.reducer<{ id: EntityId }>((state, action) => { + notes.updateOne(state.present.notes, { id: action.payload.id, changes: { selected: true } }); + }), + deselectColorNote: api.reducer<{ id: EntityId }>((state, action) => { + notes.updateOne(state.present.notes, { id: action.payload.id, changes: { selected: false } }); + }), + addBombNote: api.reducer( + history.undoable((state, action) => { + bombs.upsertOne(state.bombs, action.payload); + }), + ), + removeBombNote: api.reducer<{ id: EntityId }>( + history.undoable((state, action) => { + bombs.removeOne(state.bombs, action.payload.id); + }), + ), + selectBombNote: api.reducer<{ id: EntityId }>((state, action) => { + bombs.updateOne(state.present.bombs, { id: action.payload.id, changes: { selected: true } }); + }), + deselectBombNote: api.reducer<{ id: EntityId }>((state, action) => { + bombs.updateOne(state.present.bombs, { id: action.payload.id, changes: { selected: false } }); + }), + addObstacle: api.reducer( + history.undoable((state, action) => { + obstacles.upsertOne(state.obstacles, action.payload); + }), + ), + updateObstacle: api.reducer>( + history.undoable((state, action) => { + obstacles.updateOne(state.obstacles, action.payload); + }), + ), + removeObstacle: api.reducer<{ id: EntityId }>( + history.undoable((state, action) => { + obstacles.removeOne(state.obstacles, action.payload.id); + }), + ), + selectObstacle: api.reducer<{ id: EntityId }>((state, action) => { + obstacles.updateOne(state.present.obstacles, { id: action.payload.id, changes: { selected: true } }); + }), + deselectObstacle: api.reducer<{ id: EntityId }>((state, action) => { + obstacles.updateOne(state.present.obstacles, { id: action.payload.id, changes: { selected: false } }); + }), + updateAllSelectedObstacles: api.reducer<{ changes: Partial }>( + history.undoable((state, action) => { + const entities = obstacleSelectors.selectAll(state.obstacles).filter((data) => !!data.selected); + obstacles.updateMany( + state.obstacles, + entities.map((data) => ({ id: obstacles.selectId(data), changes: action.payload.changes })), + ); + }), + ), + removeAllSelectedObjects: api.reducer( + history.undoable((state) => { + removeAllSelectedColorNotes(state.notes); + removeAllSelectedBombNotes(state.bombs); + removeAllSelectedObstacles(state.obstacles); + }), + ), + }; + }, + extraReducers: (builder) => { + builder.addCase(loadBeatmapEntities, (state, action) => { + notes.setAll(state.present.notes, action.payload.notes ?? []); + bombs.setAll(state.present.bombs, action.payload.bombs ?? []); + obstacles.setAll(state.present.obstacles, action.payload.obstacles ?? []); + }); + builder.addCase( + mirrorSelection, + history.undoable((state, action) => { + const { axis, grid } = action.payload; + replaceAllSelectedColorNotes(state.notes, mirrorGridObjectProperties(axis, grid, 0)); + replaceAllSelectedColorNotes(state.notes, mirrorBaseNoteProperties(axis)); + replaceAllSelectedBombNotes(state.bombs, mirrorGridObjectProperties(axis, grid, 0)); + if (axis === "horizontal") { + replaceAllSelectedObstacles(state.obstacles, mirrorGridObjectProperties(axis, grid, 0)); + } + }), + ); + builder.addCase( + nudgeSelection.fulfilled, + history.undoable((state, action) => { + if (action.payload.view !== View.BEATMAP) return state; + const { direction, amount } = action.payload; + updateAllSelectedColorNotes(state.notes, nudgeItem(direction, amount)); + updateAllSelectedBombNotes(state.bombs, nudgeItem(direction, amount)); + updateAllSelectedObstacles(state.obstacles, nudgeItem(direction, amount)); + }), + ); + builder.addCase( + cutSelection.fulfilled, + history.undoable((state, action) => { + if (action.payload.view !== View.BEATMAP) return state; + removeAllSelectedColorNotes(state.notes); + removeAllSelectedBombNotes(state.bombs); + removeAllSelectedObstacles(state.obstacles); + }), + ); + builder.addCase( + pasteSelection.fulfilled, + history.undoable((state, action) => { + if (action.payload.view !== View.BEATMAP) return state; + if (action.payload.data.notes) { + notes.upsertMany( + state.notes, + action.payload.data.notes.map((x) => ({ ...x, selected: true, time: x.time + action.payload.deltaBetweenPeriods })), + ); + } + if (action.payload.data.bombs) { + bombs.upsertMany( + state.bombs, + action.payload.data.bombs.map((x) => ({ ...x, selected: true, time: x.time + action.payload.deltaBetweenPeriods })), + ); + } + if (action.payload.data.obstacles) { + obstacles.upsertMany( + state.obstacles, + action.payload.data.obstacles.map((x) => ({ ...x, selected: true, time: x.time + action.payload.deltaBetweenPeriods })), + ); + } + }), + ); + builder.addCase(selectAllEntities.fulfilled, (state, action) => { + if (action.payload.view !== View.BEATMAP) return state; + updateAllColorNotes(state.present.notes, () => ({ selected: true })); + updateAllBombNotes(state.present.bombs, () => ({ selected: true })); + updateAllObstacles(state.present.obstacles, () => ({ selected: true })); + }); + builder.addCase(deselectAllEntities, (state, action) => { + if (action.payload.view !== View.BEATMAP) return state; + updateAllColorNotes(state.present.notes, () => ({ selected: false })); + updateAllBombNotes(state.present.bombs, () => ({ selected: false })); + updateAllObstacles(state.present.obstacles, () => ({ selected: false })); + }); + builder.addCase(selectAllEntitiesInRange, (state, action) => { + if (action.payload.view !== View.BEATMAP) return state; + updateAllColorNotes(state.present.notes, (x) => ({ selected: x.time >= action.payload.startBeat - 0.01 && x.time < action.payload.endBeat })); + updateAllBombNotes(state.present.bombs, (x) => ({ selected: x.time >= action.payload.startBeat - 0.01 && x.time < action.payload.endBeat })); + updateAllObstacles(state.present.obstacles, (x) => ({ selected: x.time >= action.payload.startBeat - 0.01 && x.time < action.payload.endBeat })); + }); + builder.addCase(deselectAllEntitiesOfType, (state, action) => { + switch (action.payload.itemType) { + case ObjectType.NOTE: { + updateAllColorNotes(state.present.notes, () => ({ selected: false })); + break; + } + case ObjectType.BOMB: { + updateAllBombNotes(state.present.bombs, () => ({ selected: false })); + break; + } + case ObjectType.OBSTACLE: { + updateAllObstacles(state.present.obstacles, () => ({ selected: false })); + break; + } + } + }); + builder.addMatcher(isAnyOf(startLoadingMap, leaveEditor), () => { + history.getInitialState({ + notes: notes.getInitialState(), + bombs: bombs.getInitialState(), + obstacles: obstacles.getInitialState(), + }); + }); + builder.addDefaultCase((state) => state); + }, +}); + +export default slice; diff --git a/src/store/helpers.ts b/src/store/helpers.ts index b3a02d2b..d4002830 100644 --- a/src/store/helpers.ts +++ b/src/store/helpers.ts @@ -1,10 +1,7 @@ -import { asyncThunkCreator, buildCreateSlice, type CaseReducer, createDraftSafeSelector, type EntityAdapter, type EntityId, type EntityState, type PayloadAction, type WritableDraft } from "@reduxjs/toolkit"; +import { asyncThunkCreator, buildCreateSlice, createDraftSafeSelector, type EntityAdapter, type EntityId, type EntityState } from "@reduxjs/toolkit"; import { pick } from "@std/collections/pick"; -import type { EnvironmentName, IWrapBaseNote, IWrapBaseObject } from "bsmap"; -import type { StateWithHistory } from "redux-undo"; +import type { HistoryState } from "history-adapter/redux"; -import { isTrackGroupable, type resolveEventId, resolveGroupTrackIds, resolveTrackIdForEvent } from "$/helpers/events.helpers"; -import type { resolveNoteId } from "$/helpers/notes.helpers"; import type { App } from "$/types"; export const createSlice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } }); @@ -25,26 +22,22 @@ export function selectSnapshot(state: T) { }; } -export function selectHistory(snapshotSelector: (state: T) => StateWithHistory["past" | "future"], entitiesSelector: (snapshot: ReturnType[0]) => R) { - return (state: T) => { - const snapshots = snapshotSelector(state); - const mostRecentSnapshot = snapshots[snapshots.length - 1]; - return entitiesSelector(mostRecentSnapshot); - }; +export function selectPrevSnapshot(state: HistoryState) { + return state.past[state.past.length - 1]; } - -export function createEditorObjectSelectors(adapter: EntityAdapter) { - const selectors = adapter.getSelectors(); - - return { - selectAllSelected: createDraftSafeSelector(selectors.selectAll, (state) => state.filter((x) => x.selected === true)), - }; +export function selectNextSnapshot(state: HistoryState) { + return state.future[state.future.length - 1]; } -export function createEditorObjectReducers(adapter: EntityAdapter) { + +export function createEditorObjectAdapter(adapter: EntityAdapter) { const { selectAll } = adapter.getSelectors(); - const { selectAllSelected } = createEditorObjectSelectors(adapter); + + const selectAllSelected = createDraftSafeSelector(selectAll, (state) => state.filter((x) => x.selected === true)); return { + getSelectors: () => { + return { selectAllSelected }; + }, removeAllSelected: (state: EntityState) => { const entities = selectAllSelected(state); return adapter.removeMany( @@ -79,64 +72,3 @@ export function createEditorObjectReducers, Id extends EntityId>(adapter: EntityAdapter) { - const { selectAll } = adapter.getSelectors(); - - const selectAllForTrackBeforeBeat = createDraftSafeSelector([selectAll, (_, query: { trackId: number; beforeBeat: number }) => query], (state, { trackId, beforeBeat }) => { - return state.filter((x) => resolveTrackIdForEvent(x) === trackId && x.time < beforeBeat); - }); - - return { - selectAllForTrack: createDraftSafeSelector([selectAll, (_, trackId: number) => trackId], (state, trackId) => { - return state.filter((x) => resolveTrackIdForEvent(x) === trackId); - }), - createEventSelector: (selector: (data: T) => Value | undefined, fallback: Value) => { - return createDraftSafeSelector(selectAllForTrackBeforeBeat, (state) => { - return state[state.length - 1] ? (selector(state[state.length - 1]) ?? fallback) : fallback; - }); - }, - }; -} - -export function createGridObjectReducerFactory, Id extends EntityId>(adapter: EntityAdapter) { - const selectors = adapter.getSelectors(); - - const selectByQuery = createDraftSafeSelector([selectors.selectAll, (_, query: Pick) => query], (state, { time, posX, posY }) => { - return state.find((x) => x.time === time && x.posX === posX && x.posY === posY); - }); - - return

(callback: (data: T, state: WritableDraft>, action: PayloadAction

) => EntityState): CaseReducer, PayloadAction<{ query: Parameters[0] } & P>> => { - return (state, action) => { - const { query } = action.payload; - const match = selectByQuery(state, query); - if (!match) return state; - return callback(query as T, state, action); - }; - }; -} -export function createEventReducerFactory, Id extends EntityId>(adapter: EntityAdapter) { - const selectors = adapter.getSelectors(); - - const selectByQuery = createDraftSafeSelector([selectors.selectAll, (_, query: Pick) => query], (state, query) => { - return state.find((x) => x.time === query.time && resolveTrackIdForEvent(x) === resolveTrackIdForEvent(query)); - }); - - return

( - callback: (data: { match: T | undefined; trackId: number }, state: WritableDraft>, action: PayloadAction

) => EntityState, - ): CaseReducer, PayloadAction<{ query: Parameters[0]; environment: EnvironmentName; areLasersLocked?: boolean } & P>> => { - return (state, action) => { - const { query, environment, areLasersLocked } = action.payload; - const match = selectByQuery(state, query); - const trackId = resolveTrackIdForEvent(query); - callback({ match, trackId }, state, action); - if (areLasersLocked && isTrackGroupable(trackId, environment)) { - const groupTrackIds = resolveGroupTrackIds(trackId, environment); - for (const mirrorTrackId of groupTrackIds) { - // Important: if the side lasers are "locked" we need to mimic this event from the left laser to the right laser. - callback({ match, trackId: mirrorTrackId }, state, action); - } - } - }; - }; -} diff --git a/src/store/middleware/history.middleware.ts b/src/store/middleware/history.middleware.ts index 8d6b3f1e..7f7eeb0d 100644 --- a/src/store/middleware/history.middleware.ts +++ b/src/store/middleware/history.middleware.ts @@ -1,11 +1,11 @@ import { createListenerMiddleware, type ListenerEffectAPI } from "@reduxjs/toolkit"; import { sortObjectFn } from "bsmap"; -import { ActionCreators } from "redux-undo"; import { resolveEventId } from "$/helpers/events.helpers"; import { resolveNoteId } from "$/helpers/notes.helpers"; import { resolveObstacleId } from "$/helpers/obstacles.helpers"; -import { jumpToBeat, leaveEditor, redoEvents, redoObjects, undoEvents, undoObjects } from "$/store/actions"; +import { getRouter, selectActiveSongId } from "$/router"; +import { clearEventHistory, clearObjectHistory, jumpToBeat, leaveEditor, redoEvents, redoObjects, undoEvents, undoObjects } from "$/store/actions"; import { selectAllBasicEvents, selectAllBombNotes, @@ -23,7 +23,7 @@ import { selectPastColorNotes, selectPastObstacles, } from "$/store/selectors"; -import type { AppDispatch, RootState } from "$/store/setup"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/setup"; import type { App, SongId } from "$/types"; import { difference } from "$/utils"; @@ -54,19 +54,23 @@ function jumpToEarliestEvent(api: ListenerEffectAPI, son * This middleware listens for undo events, and handles updating the cursor position in response to these actions. */ export default function createHistoryMiddleware() { - const instance = createListenerMiddleware(); + const instance = createListenerMiddleware({ + extra: { getRouter }, + }); instance.startListening({ actionCreator: leaveEditor, effect: (_, api) => { - api.dispatch(ActionCreators.clearHistory()); + api.dispatch(clearObjectHistory()); + api.dispatch(clearEventHistory()); }, }); instance.startListening({ actionCreator: undoObjects, - effect: (action, api) => { + effect: (_, api) => { const state = api.getState(); - const { songId } = action.payload; + const songId = selectActiveSongId(api.extra.getRouter()); + jumpToEarliestObject(api, songId, { notes: { before: selectFutureColorNotes(state), after: selectAllColorNotes(state) }, bombs: { before: selectFutureBombNotes(state), after: selectAllBombNotes(state) }, @@ -76,9 +80,10 @@ export default function createHistoryMiddleware() { }); instance.startListening({ actionCreator: redoObjects, - effect: (action, api) => { + effect: (_, api) => { const state = api.getState(); - const { songId } = action.payload; + const songId = selectActiveSongId(api.extra.getRouter()); + jumpToEarliestObject(api, songId, { notes: { before: selectPastColorNotes(state), after: selectAllColorNotes(state) }, bombs: { before: selectPastBombNotes(state), after: selectAllBombNotes(state) }, @@ -88,9 +93,10 @@ export default function createHistoryMiddleware() { }); instance.startListening({ actionCreator: undoEvents, - effect: (action, api) => { + effect: (_, api) => { const state = api.getState(); - const { songId } = action.payload; + const songId = selectActiveSongId(api.extra.getRouter()); + jumpToEarliestEvent(api, songId, { basicEvents: { before: selectFutureBasicEvents(state), after: selectAllBasicEvents(state) }, boostEvents: { before: selectFutureBoostEvents(state), after: selectAllBoostEvents(state) }, @@ -99,9 +105,10 @@ export default function createHistoryMiddleware() { }); instance.startListening({ actionCreator: redoEvents, - effect: (action, api) => { + effect: (_, api) => { const state = api.getState(); - const { songId } = action.payload; + const songId = selectActiveSongId(api.extra.getRouter()); + jumpToEarliestEvent(api, songId, { basicEvents: { before: selectPastBasicEvents(state), after: selectAllBasicEvents(state) }, boostEvents: { before: selectPastBoostEvents(state), after: selectAllBoostEvents(state) }, diff --git a/src/store/selectors.ts b/src/store/selectors.ts index b752dcc3..67fa5580 100644 --- a/src/store/selectors.ts +++ b/src/store/selectors.ts @@ -1,5 +1,5 @@ import { createDraftSafeSelector, createSelector } from "@reduxjs/toolkit"; -import { calculateNps, type IWrapBaseObject, sortObjectFn, TimeProcessor } from "bsmap"; +import { calculateNps, type IWrapBaseObject, TimeProcessor } from "bsmap"; import { shallowEqual } from "react-redux"; import { DEFAULT_GRID } from "$/constants"; @@ -12,19 +12,15 @@ import { floorToNearest } from "$/utils"; import clipboard from "./features/clipboard.slice"; import beatmap from "./features/editor/beatmap.slice"; import lightshow from "./features/editor/lightshow.slice"; -import bombs from "./features/entities/beatmap/bombs.slice"; -import notes from "./features/entities/beatmap/notes.slice"; -import obstacles from "./features/entities/beatmap/obstacles.slice"; import bookmarks from "./features/entities/editor/bookmarks.slice"; -import basicEvents from "./features/entities/lightshow/basic.slice"; -import boostEvents from "./features/entities/lightshow/boost.slice"; +import events from "./features/entities/events.slice"; +import objects from "./features/entities/objects.slice"; import global from "./features/global.slice"; import navigation from "./features/navigation.slice"; import songs from "./features/songs.slice"; import timeline from "./features/timeline.slice"; import user from "./features/user.slice"; import visualizer from "./features/visualizer.slice"; -import { selectHistory } from "./helpers"; import type { RootState } from "./setup"; export const { selectInitialized, selectLoading, selectProcessingImport } = global.getSelectors((state: Pick) => { @@ -189,20 +185,34 @@ export const selectEventEditorStartAndEndBeat = createSelector(selectCursorPosit return { startBeat: startBeat, numOfBeatsToShow: beatsPerZoomLevel, endBeat: startBeat + beatsPerZoomLevel }; }); -export const selectObjectsCanUndo = createSelector( - (state: RootState) => state.entities.beatmap, - (history) => { - return history.past.length > 0; - }, -); -export const selectObjectsCanRedo = createSelector( - (state: RootState) => state.entities.beatmap, - (history) => { - return history.future.length > 0; - }, -); - -export function createVisibleObjectsSelector(selector: (state: RootState) => T[]) { +export const { + selectCanUndo: selectObjectsCanUndo, + selectCanRedo: selectObjectsCanRedo, + selectAllColorNotes, + selectAllSelectedColorNotes, + selectTotalColorNotes, + selectPastColorNotes, + selectFutureColorNotes, + selectAllBombNotes, + selectAllSelectedBombNotes, + selectTotalBombNotes: selectedTotalBombNotes, + selectPastBombNotes, + selectFutureBombNotes, + selectAllNotes, + selectAllObstacles, + selectAllSelectedObstacles, + selectTotalObstacles, + selectPastObstacles, + selectFutureObstacles, + selectSelectedObjects, + selectAnySelectedObjects, +} = objects.getSelectors((state: RootState) => state.entities.beatmap); + +export const selectNoteDensity = createSelector(selectAllColorNotes, selectDuration, (notes, duration) => { + return calculateNps({ difficulty: { colorNotes: notes } }, duration ?? 0); +}); + +function createVisibleObjectsSelector(selector: (state: RootState) => T[]) { return createSelector( [selector, (_1, _2, options: { timescale: (time: number) => number; beatDepth: number; surfaceDepth: number; includeSpaceBeforeGrid?: boolean }) => options, selectCursorPositionInBeats], (objects, { timescale, beatDepth, surfaceDepth, includeSpaceBeforeGrid }, cursorPositionInBeats) => { @@ -219,129 +229,25 @@ export function createVisibleObjectsSelector(selector ); } -export const { - selectAll: selectAllColorNotes, - selectAllSelected: selectAllSelectedColorNotes, - selectTotal: selectTotalColorNotes, -} = notes.getSelectors((state: Pick) => { - return state.entities.beatmap.present.notes; -}); -export const { selectAll: selectPastColorNotes } = notes.getSelectors( - selectHistory( - (state: Pick) => state.entities.beatmap.past, - (state) => state?.notes ?? notes.getInitialState(), - ), -); -export const { selectAll: selectFutureColorNotes } = notes.getSelectors( - selectHistory( - (state: Pick) => state.entities.beatmap.future, - (state) => state?.notes ?? notes.getInitialState(), - ), -); export const selectVisibleNotes = createVisibleObjectsSelector(selectAllColorNotes); - -export const selectNoteDensity = createSelector(selectAllColorNotes, selectDuration, (notes, duration) => { - return calculateNps({ difficulty: { colorNotes: notes } }, duration ?? 0); -}); - -export const { - selectAll: selectAllBombNotes, - selectAllSelected: selectAllSelectedBombNotes, - selectTotal: selectedTotalBombNotes, -} = bombs.getSelectors((state: Pick) => { - return state.entities.beatmap.present.bombs; -}); -export const { selectAll: selectPastBombNotes } = bombs.getSelectors( - selectHistory( - (state: Pick) => state.entities.beatmap.past, - (state) => state?.bombs ?? bombs.getInitialState(), - ), -); -export const { selectAll: selectFutureBombNotes } = bombs.getSelectors( - selectHistory( - (state: Pick) => state.entities.beatmap.future, - (state) => state?.bombs ?? bombs.getInitialState(), - ), -); export const selectVisibleBombs = createVisibleObjectsSelector(selectAllBombNotes); - -export const selectAllNotes = createDraftSafeSelector(selectAllColorNotes, selectAllBombNotes, (notes, bombs) => [...notes, ...bombs]); - -export const { - selectAll: selectAllObstacles, - selectAllSelected: selectAllSelectedObstacles, - selectTotal: selectTotalObstacles, -} = obstacles.getSelectors((state: Pick) => { - return state.entities.beatmap.present.obstacles; -}); -export const { selectAll: selectPastObstacles } = obstacles.getSelectors( - selectHistory( - (state: Pick) => state.entities.beatmap.past, - (state) => state?.obstacles ?? obstacles.getInitialState(), - ), -); -export const { selectAll: selectFutureObstacles } = obstacles.getSelectors( - selectHistory( - (state: Pick) => state.entities.beatmap.future, - (state) => state?.obstacles ?? obstacles.getInitialState(), - ), -); export const selectAllVisibleObstacles = createVisibleObjectsSelector(selectAllObstacles); -export const selectSelectedObjects = createSelector(selectAllSelectedColorNotes, selectAllSelectedBombNotes, selectAllSelectedObstacles, (notes, bombs, obstacles) => { - return { - notes: notes.length > 0 ? notes : undefined, - bombs: bombs.length > 0 ? bombs : undefined, - obstacles: obstacles.length > 0 ? obstacles : undefined, - }; -}); -export const selectAllSelectedObjects = createSelector(selectAllSelectedColorNotes, selectAllSelectedBombNotes, selectAllSelectedObstacles, (notes, bombs, obstacles) => { - return [...notes, ...bombs, ...obstacles].sort(sortObjectFn); -}); -export const selectAnySelectedObjects = createSelector(selectAllSelectedObjects, (objects) => { - return objects.length > 0; -}); - export const { - selectAll: selectAllBasicEvents, - selectAllSelected: selectAllSelectedBasicEvents, - selectAllForTrack: selectAllBasicEventsForTrack, + selectAllBasicEvents, + selectAllSelectedBasicEvents, + selectAllBasicEventsForTrack, selectValueForTrackAtBeat, -} = basicEvents.getSelectors((state: Pick) => { - return state.entities.lightshow.present.basicEvents; -}); -export const { selectAll: selectPastBasicEvents } = basicEvents.getSelectors( - selectHistory( - (state: Pick) => state.entities.lightshow.past, - (state) => state?.basicEvents ?? basicEvents.getInitialState(), - ), -); -export const { selectAll: selectFutureBasicEvents } = basicEvents.getSelectors( - selectHistory( - (state: Pick) => state.entities.lightshow.future, - (state) => state?.basicEvents ?? basicEvents.getInitialState(), - ), -); - -export const { - selectAll: selectAllBoostEvents, - selectAllSelected: selectAllSelectedBoostEvents, - selectToggleAtBeat, -} = boostEvents.getSelectors((state: Pick) => { - return state.entities.lightshow.present.boostEvents; -}); -export const { selectAll: selectPastBoostEvents } = boostEvents.getSelectors( - selectHistory( - (state: Pick) => state.entities.lightshow.past, - (state) => state?.boostEvents ?? boostEvents.getInitialState(), - ), -); -export const { selectAll: selectFutureBoostEvents } = boostEvents.getSelectors( - selectHistory( - (state: Pick) => state.entities.lightshow.future, - (state) => state?.boostEvents ?? boostEvents.getInitialState(), - ), -); + selectPastBasicEvents, + selectFutureBasicEvents, + selectAllBoostEvents, + selectAllSelectedBoostEvents, + selectColorBoostAtBeat, + selectPastBoostEvents, + selectFutureBoostEvents, + selectSelectedEvents, + selectAnySelectedEvents, +} = events.getSelectors((state: RootState) => state.entities.lightshow); export const selectCurrentLightStateForTrack = createDraftSafeSelector([selectEventEditorStartAndEndBeat, selectEventTracksForEnvironment, (state: RootState, _songId: SongId, _beatmapId: BeatmapId, trackId: number) => selectAllBasicEventsForTrack(state, trackId)], ({ startBeat }, tracks, events): ILightState => { const basicEventsInWindow = events.filter((event) => event.time <= startBeat); @@ -356,19 +262,6 @@ export const selectCurrentLightStateForTrack = createDraftSafeSelector([selectEv }; }); -export const selectSelectedEvents = createSelector(selectAllSelectedBasicEvents, selectAllSelectedBoostEvents, (basicEvents, boostEvents) => { - return { - basicEvents: basicEvents.length > 0 ? basicEvents : undefined, - boostEvents: boostEvents.length > 0 ? boostEvents : undefined, - }; -}); -export const selectAllSelectedEvents = createSelector(selectAllSelectedBasicEvents, selectAllSelectedBoostEvents, (basicEvents, boostEvents) => { - return [...basicEvents, ...boostEvents].sort(sortObjectFn); -}); -export const selectAnySelectedEvents = createSelector(selectAllSelectedEvents, (events) => { - return events.length > 0; -}); - export const selectSelectedBeatmapEntities = createSelector([selectSelectedObjects, selectSelectedEvents, (_, view: View) => view], (objects, events, view): Partial> => { return { notes: view === View.BEATMAP ? objects.notes : undefined, @@ -378,11 +271,8 @@ export const selectSelectedBeatmapEntities = createSelector([selectSelectedObjec boostEvents: view === View.LIGHTSHOW ? events.boostEvents : undefined, }; }); -export const selectAllSelectedBeatmapEntities = createSelector([selectAllSelectedObjects, selectAllSelectedEvents], (objects, events) => { - return [...objects, ...events].sort(sortObjectFn); -}); -export const { selectAll: selectAllBookmarks } = bookmarks.getSelectors((state: Pick) => { +export const { selectAll: selectAllBookmarks } = bookmarks.getSelectors((state: RootState) => { return state.entities.editor.bookmarks; }); From fc7cd85ba39212be19278b1e3a0702a0324e776a Mon Sep 17 00:00:00 2001 From: officialMECH Date: Thu, 6 Aug 2026 12:43:20 -0500 Subject: [PATCH 11/27] normalize reducer paths for slices --- src/store/actions.ts | 10 +-- .../features/{editor => }/beatmap.slice.ts | 2 +- .../{entities/editor => }/bookmarks.slice.ts | 0 src/store/features/editor/index.ts | 13 ---- src/store/features/entities/editor/index.ts | 9 --- src/store/features/entities/index.ts | 13 ---- .../features/{entities => }/events.slice.ts | 0 src/store/features/index.ts | 27 ++----- .../features/{editor => }/lightshow.slice.ts | 2 +- .../features/{entities => }/objects.slice.ts | 0 src/store/selectors.ts | 78 +++++-------------- src/store/setup.ts | 6 +- 12 files changed, 38 insertions(+), 122 deletions(-) rename src/store/features/{editor => }/beatmap.slice.ts (99%) rename src/store/features/{entities/editor => }/bookmarks.slice.ts (100%) delete mode 100644 src/store/features/editor/index.ts delete mode 100644 src/store/features/entities/editor/index.ts delete mode 100644 src/store/features/entities/index.ts rename src/store/features/{entities => }/events.slice.ts (100%) rename src/store/features/{editor => }/lightshow.slice.ts (99%) rename src/store/features/{entities => }/objects.slice.ts (100%) diff --git a/src/store/actions.ts b/src/store/actions.ts index 12bffd15..d6860c0b 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -27,14 +27,14 @@ import { createIncrementByIndexPayloadActionCreator, createIncrementByValuePaylo // biome-ignore-start assist/source/organizeImports: circular dependencies +import beatmap from "./features/beatmap.slice"; +import bookmarks from "./features/bookmarks.slice"; import clipboard from "./features/clipboard.slice"; -import beatmap from "./features/editor/beatmap.slice"; -import lightshow from "./features/editor/lightshow.slice"; -import bookmarks from "./features/entities/editor/bookmarks.slice"; -import events from "./features/entities/events.slice"; -import objects from "./features/entities/objects.slice"; +import events from "./features/events.slice"; import global from "./features/global.slice"; +import lightshow from "./features/lightshow.slice"; import navigation from "./features/navigation.slice"; +import objects from "./features/objects.slice"; import songs from "./features/songs.slice"; import timeline from "./features/timeline.slice"; import user from "./features/user.slice"; diff --git a/src/store/features/editor/beatmap.slice.ts b/src/store/features/beatmap.slice.ts similarity index 99% rename from src/store/features/editor/beatmap.slice.ts rename to src/store/features/beatmap.slice.ts index bc0062c6..3c221e97 100644 --- a/src/store/features/editor/beatmap.slice.ts +++ b/src/store/features/beatmap.slice.ts @@ -24,7 +24,7 @@ const fetchGridSize: AsyncThunkPayloadCreator<{ presetSlot: string; grid: IGrid }; const slice = createSlice({ - name: "notes", + name: "beatmap", initialState: initialState, selectors: { selectTool: (state) => state.selectedTool, diff --git a/src/store/features/entities/editor/bookmarks.slice.ts b/src/store/features/bookmarks.slice.ts similarity index 100% rename from src/store/features/entities/editor/bookmarks.slice.ts rename to src/store/features/bookmarks.slice.ts diff --git a/src/store/features/editor/index.ts b/src/store/features/editor/index.ts deleted file mode 100644 index 7c9db844..00000000 --- a/src/store/features/editor/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { combineReducers } from "@reduxjs/toolkit"; - -import beatmap from "./beatmap.slice"; -import lightshow from "./lightshow.slice"; - -const reducer = combineReducers({ - notes: beatmap.reducer, - events: lightshow.reducer, -}); - -export default { - reducer: reducer, -}; diff --git a/src/store/features/entities/editor/index.ts b/src/store/features/entities/editor/index.ts deleted file mode 100644 index fd7a987e..00000000 --- a/src/store/features/entities/editor/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { combineReducers } from "@reduxjs/toolkit"; - -import bookmarks from "./bookmarks.slice"; - -const reducer = combineReducers({ - bookmarks: bookmarks.reducer, -}); - -export default { reducer }; diff --git a/src/store/features/entities/index.ts b/src/store/features/entities/index.ts deleted file mode 100644 index 9d046956..00000000 --- a/src/store/features/entities/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { combineReducers } from "@reduxjs/toolkit"; - -import editor from "./editor"; -import events from "./events.slice"; -import objects from "./objects.slice"; - -const reducer = combineReducers({ - beatmap: objects.reducer, - lightshow: events.reducer, - editor: editor.reducer, -}); - -export default { reducer }; diff --git a/src/store/features/entities/events.slice.ts b/src/store/features/events.slice.ts similarity index 100% rename from src/store/features/entities/events.slice.ts rename to src/store/features/events.slice.ts diff --git a/src/store/features/index.ts b/src/store/features/index.ts index 71c38050..053ad685 100644 --- a/src/store/features/index.ts +++ b/src/store/features/index.ts @@ -1,27 +1,16 @@ -import { combineReducers } from "@reduxjs/toolkit"; +import { combineSlices } from "@reduxjs/toolkit"; +import beatmap from "./beatmap.slice"; +import bookmarks from "./bookmarks.slice"; import clipboard from "./clipboard.slice"; -import editor from "./editor"; -import entities from "./entities"; +import events from "./events.slice"; import global from "./global.slice"; +import lightshow from "./lightshow.slice"; import navigation from "./navigation.slice"; +import objects from "./objects.slice"; import songs from "./songs.slice"; import timeline from "./timeline.slice"; import user from "./user.slice"; -import waveform from "./visualizer.slice"; +import visualizer from "./visualizer.slice"; -const reducer = combineReducers({ - songs: songs.reducer, - clipboard: clipboard.reducer, - entities: entities.reducer, - waveform: waveform.reducer, - navigation: navigation.reducer, - timeline: timeline.reducer, - editor: editor.reducer, - global: global.reducer, - user: user.reducer, -}); - -export default { - reducer, -}; +export default combineSlices(global, user, navigation, visualizer, beatmap, lightshow, songs, timeline, objects, events, bookmarks, clipboard); diff --git a/src/store/features/editor/lightshow.slice.ts b/src/store/features/lightshow.slice.ts similarity index 99% rename from src/store/features/editor/lightshow.slice.ts rename to src/store/features/lightshow.slice.ts index 8b48849c..217027c1 100644 --- a/src/store/features/editor/lightshow.slice.ts +++ b/src/store/features/lightshow.slice.ts @@ -22,7 +22,7 @@ const initialState = { }; const slice = createSlice({ - name: "events", + name: "lightshow", initialState: initialState, selectors: { selectTool: (state) => state.selectedTool, diff --git a/src/store/features/entities/objects.slice.ts b/src/store/features/objects.slice.ts similarity index 100% rename from src/store/features/entities/objects.slice.ts rename to src/store/features/objects.slice.ts diff --git a/src/store/selectors.ts b/src/store/selectors.ts index 67fa5580..058bda3d 100644 --- a/src/store/selectors.ts +++ b/src/store/selectors.ts @@ -9,23 +9,21 @@ import { getGridSize } from "$/helpers/song.helpers"; import { getAudioContext } from "$/setup"; import { type App, type BeatmapId, type ILightState, NotePlacementMode, ObjectTool, ObstaclePlacementMode, type SongId, View } from "$/types"; import { floorToNearest } from "$/utils"; +import beatmap from "./features/beatmap.slice"; +import bookmarks from "./features/bookmarks.slice"; import clipboard from "./features/clipboard.slice"; -import beatmap from "./features/editor/beatmap.slice"; -import lightshow from "./features/editor/lightshow.slice"; -import bookmarks from "./features/entities/editor/bookmarks.slice"; -import events from "./features/entities/events.slice"; -import objects from "./features/entities/objects.slice"; +import events from "./features/events.slice"; import global from "./features/global.slice"; +import lightshow from "./features/lightshow.slice"; import navigation from "./features/navigation.slice"; +import objects from "./features/objects.slice"; import songs from "./features/songs.slice"; import timeline from "./features/timeline.slice"; import user from "./features/user.slice"; import visualizer from "./features/visualizer.slice"; import type { RootState } from "./setup"; -export const { selectInitialized, selectLoading, selectProcessingImport } = global.getSelectors((state: Pick) => { - return state.global; -}); +export const { selectInitialized, selectLoading, selectProcessingImport } = global.getSelectors(global.selectSlice); export const { selectId: selectSongId, @@ -56,26 +54,22 @@ export const { selectDemo, selectModuleEnabled, selectCustomColors, -} = songs.getSelectors((state: Pick) => { - return state.songs; -}); +} = songs.getSelectors(songs.selectSlice); -export const { selectTimescale } = timeline.getSelectors((state: Pick) => state.timeline); +export const { selectTimescale } = timeline.getSelectors(timeline.selectSlice); export const selectTimeProcessor = createDraftSafeSelector([selectBpm, selectTimescale], (bpm, timeline) => { return new TimeProcessor(bpm, timeline, 0); }); -export const selectBeatForTime = createDraftSafeSelector([selectTimeProcessor, selectEditorOffset, (_1: Pick, _2: SongId, time: number) => time], (timeProcessor, offset, time) => { +export const selectBeatForTime = createDraftSafeSelector([selectTimeProcessor, selectEditorOffset, (_1: RootState, _2: SongId, time: number) => time], (timeProcessor, offset, time) => { return timeProcessor.toBeatTime(time - offset); }); -export const selectTimeForBeat = createDraftSafeSelector([selectTimeProcessor, selectEditorOffset, (_1: Pick, _2: SongId, beat: number) => beat], (timeProcessor, offset, beat) => { +export const selectTimeForBeat = createDraftSafeSelector([selectTimeProcessor, selectEditorOffset, (_1: RootState, _2: SongId, beat: number) => beat], (timeProcessor, offset, beat) => { return timeProcessor.toRealTime(beat) + offset; }); -export const { selectPlaying, selectCursorPosition, selectDuration, selectSnap, selectBeatDepth, selectAnimateTrack, selectAnimateEnvironment, selectPlaybackRate, selectSongVolume, selectTickVolume, selectTickType } = navigation.getSelectors((state: RootState) => { - return state.navigation; -}); +export const { selectPlaying, selectCursorPosition, selectDuration, selectSnap, selectBeatDepth, selectAnimateTrack, selectAnimateEnvironment, selectPlaybackRate, selectSongVolume, selectTickVolume, selectTickType } = navigation.getSelectors(navigation.selectSlice); export const selectCursorPositionInBeats = createSelector([selectTimeProcessor, selectCursorPosition, selectEditorOffset], (timeProcessor, cursorPosition, offset) => { return timeProcessor.toBeatTime(cursorPosition - offset); @@ -103,17 +97,7 @@ export const selectBpmScale = createSelector([selectTimeProcessor, selectBpm, se return activeBpm / baseBpm; }); -export const { - selectNew, - selectAnnouncements, - selectUsername, - selectRenderScale, - selectBloomEnabled, - selectObstaclePlacementMode: selectUserObstaclePlacementMode, - selectPacerWait, -} = user.getSelectors((state: Pick) => { - return state.user; -}); +export const { selectNew, selectAnnouncements, selectUsername, selectRenderScale, selectBloomEnabled, selectObstaclePlacementMode: selectUserObstaclePlacementMode, selectPacerWait } = user.getSelectors(user.selectSlice); export const selectAudioLatencyInBeats = createSelector([selectTimeProcessor], (timeProcessor) => { const { baseLatency } = getAudioContext(); @@ -133,21 +117,9 @@ export const selectObstaclePlacementMode = createSelector(selectSongById, select return userPlacementMode; }); -export const { selectWaveformData } = visualizer.getSelectors((state: RootState) => { - return state.waveform; -}); +export const { selectWaveformData } = visualizer.getSelectors(visualizer.selectSlice); -export const { - selectTool: selectNotesEditorTool, - selectDirection: selectNotesEditorDirection, - selectSelectionMode: selectNotesEditorSelectionMode, - selectDefaultObstacleDuration, - selectGridPresets, - selectAllGridPresetIds, - selectGridPresetById, -} = beatmap.getSelectors((state: RootState) => { - return state.editor.notes; -}); +export const { selectTool: selectNotesEditorTool, selectDirection: selectNotesEditorDirection, selectSelectionMode: selectNotesEditorSelectionMode, selectDefaultObstacleDuration, selectGridPresets, selectAllGridPresetIds, selectGridPresetById } = beatmap.getSelectors(beatmap.selectSlice); export const selectGridSize = createSelector(selectSongById, selectNotesEditorTool, selectObstaclePlacementMode, (song, tool, obstaclePlacementMode) => { switch (tool) { @@ -174,9 +146,8 @@ export const { selectMirrorLock: selectEventsEditorMirrorLock, selectZoomLevel: selectEventsEditorZoomLevel, selectBeatsPerZoomLevel: selectEventsEditorBeatsPerZoomLevel, -} = lightshow.getSelectors((state: RootState) => { - return state.editor.events; -}); +} = lightshow.getSelectors(lightshow.selectSlice); + export const selectEventEditorZoomLevelStartBeat = createSelector(selectCursorPositionInBeats, selectEventsEditorBeatsPerZoomLevel, (cursorPositionInBeats, beatsPerZoomLevel) => { return floorToNearest(cursorPositionInBeats ?? 0, beatsPerZoomLevel); }); @@ -206,7 +177,7 @@ export const { selectFutureObstacles, selectSelectedObjects, selectAnySelectedObjects, -} = objects.getSelectors((state: RootState) => state.entities.beatmap); +} = objects.getSelectors(objects.selectSlice); export const selectNoteDensity = createSelector(selectAllColorNotes, selectDuration, (notes, duration) => { return calculateNps({ difficulty: { colorNotes: notes } }, duration ?? 0); @@ -247,7 +218,7 @@ export const { selectFutureBoostEvents, selectSelectedEvents, selectAnySelectedEvents, -} = events.getSelectors((state: RootState) => state.entities.lightshow); +} = events.getSelectors(events.selectSlice); export const selectCurrentLightStateForTrack = createDraftSafeSelector([selectEventEditorStartAndEndBeat, selectEventTracksForEnvironment, (state: RootState, _songId: SongId, _beatmapId: BeatmapId, trackId: number) => selectAllBasicEventsForTrack(state, trackId)], ({ startBeat }, tracks, events): ILightState => { const basicEventsInWindow = events.filter((event) => event.time <= startBeat); @@ -272,19 +243,10 @@ export const selectSelectedBeatmapEntities = createSelector([selectSelectedObjec }; }); -export const { selectAll: selectAllBookmarks } = bookmarks.getSelectors((state: RootState) => { - return state.entities.editor.bookmarks; -}); +export const { selectAll: selectAllBookmarks } = bookmarks.getSelectors(bookmarks.selectSlice); export const selectBeatmapEntities = createSelector([selectAllColorNotes, selectAllBombNotes, selectAllObstacles, selectAllBasicEvents, selectAllBoostEvents, selectAllBookmarks], (notes, bombs, obstacles, basicEvents, boostEvents, bookmarks): App.IBeatmapEntities => { return { notes, bombs, obstacles, basicEvents, boostEvents, bookmarks }; }); -export const { - selectData: selectClipboardData, - selectHasObjects: selectClipboardHasObjects, - selectHasEvents: selectClipboardHasEvents, - selectEarliestBeat, -} = clipboard.getSelectors((state: Pick) => { - return state.clipboard; -}); +export const { selectData: selectClipboardData, selectHasObjects: selectClipboardHasObjects, selectHasEvents: selectClipboardHasEvents, selectEarliestBeat } = clipboard.getSelectors(clipboard.selectSlice); diff --git a/src/store/setup.ts b/src/store/setup.ts index 6f179faa..45dee0ed 100644 --- a/src/store/setup.ts +++ b/src/store/setup.ts @@ -44,7 +44,7 @@ import { updateUsername, } from "./actions"; import { createEntityStorageStrategy, createEnumerableStorageObserver, createKeyValueStorageStrategy, createStorageEnhancer } from "./enhancers/storage.enhancer"; -import { default as root } from "./features"; +import { default as reducer } from "./features"; import { createAllSharedMiddleware } from "./middleware"; import { selectAllGridPresetIds, @@ -307,7 +307,7 @@ export async function createAppStore() { }; const store = configureStore({ - reducer: root.reducer, + reducer: reducer, devTools: import.meta.env.VITE_ENABLE_DEVTOOLS ? devTools : undefined, middleware: (getDefaultMiddleware) => { return getDefaultMiddleware({ thunk: { extraArgument: { getRouter } } }).concat(createAllSharedMiddleware()); @@ -326,7 +326,7 @@ export async function createAppStore() { return store; } -export type RootState = ReturnType; +export type RootState = ReturnType; export type AppDispatch = ThunkDispatch; export interface AppThunkApiConfig { From abd5974b8950c920c7ec6fcc5daa47645a4abf1f Mon Sep 17 00:00:00 2001 From: officialMECH Date: Thu, 6 Aug 2026 12:54:47 -0500 Subject: [PATCH 12/27] rework file structure for store modules and setup methods --- src/services/backup.service.ts | 2 +- src/setup.ts | 3 +- src/store/_setup.ts | 42 ++++++++++++ src/store/actions.ts | 4 +- src/store/{setup.ts => enhancers/_setup.ts} | 64 ++----------------- src/store/enhancers/index.ts | 0 src/store/features/{index.ts => _setup.ts} | 0 src/store/features/beatmap.slice.ts | 19 +++--- src/store/features/bookmarks.slice.ts | 24 +++---- src/store/features/clipboard.slice.ts | 50 +++++++-------- src/store/features/events.slice.ts | 3 +- src/store/features/navigation.slice.ts | 5 +- src/store/features/objects.slice.ts | 3 +- src/store/features/songs.slice.ts | 6 +- src/store/features/timeline.slice.ts | 4 +- .../{helpers.ts => helpers/editor.helpers.ts} | 29 +-------- src/store/helpers/selectors.ts | 8 +++ src/store/hooks.ts | 2 +- src/store/middleware/{index.ts => _setup.ts} | 20 +----- src/store/middleware/audio.middleware.ts | 2 +- src/store/middleware/backup.middleware.ts | 2 +- src/store/middleware/demo.middleware.ts | 2 +- src/store/middleware/file.middleware.ts | 2 +- src/store/middleware/history.middleware.ts | 2 +- src/store/middleware/packaging.middleware.ts | 2 +- src/store/middleware/playback.middleware.ts | 2 +- src/store/selectors.ts | 2 +- src/store/types.ts | 17 +++++ src/store/{utils.ts => utils/thunk.utils.ts} | 0 29 files changed, 146 insertions(+), 175 deletions(-) create mode 100644 src/store/_setup.ts rename src/store/{setup.ts => enhancers/_setup.ts} (84%) delete mode 100644 src/store/enhancers/index.ts rename src/store/features/{index.ts => _setup.ts} (100%) rename src/store/{helpers.ts => helpers/editor.helpers.ts} (57%) create mode 100644 src/store/helpers/selectors.ts rename src/store/middleware/{index.ts => _setup.ts} (66%) create mode 100644 src/store/types.ts rename src/store/{utils.ts => utils/thunk.utils.ts} (100%) diff --git a/src/services/backup.service.ts b/src/services/backup.service.ts index 8e011aa2..5955d4ea 100644 --- a/src/services/backup.service.ts +++ b/src/services/backup.service.ts @@ -1,7 +1,7 @@ import { serializeBeatmapContents, serializeInfoContents } from "$/helpers/packaging.helpers"; import { getAppBeatmapFilestore, getAppToaster } from "$/setup"; import { selectBeatmapEntities, selectBeatmapIds, selectBeatmapIdsWithLightshowId, selectDuration, selectEditorOffsetInBeats, selectLightshowIdForBeatmap, selectSelectedBeatmap, selectSongById } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; +import type { RootState } from "$/store/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. diff --git a/src/setup.ts b/src/setup.ts index 80c08254..348b00d5 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -7,7 +7,6 @@ import { createStorage, type Driver, 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 { createLazySingleton } from "./utils"; @@ -92,4 +91,4 @@ export const { get: getAppToaster, setup: setupAppToaster } = createLazySingleto return createToaster({ placement: "bottom-end", overlap: true, max: 8 }); }); -export const { get: getAppStore, setup: setupAppStore } = createLazySingleton(createAppStore); +export { getAppStore, setupAppStore } from "./store/_setup"; diff --git a/src/store/_setup.ts b/src/store/_setup.ts new file mode 100644 index 00000000..7b6f368a --- /dev/null +++ b/src/store/_setup.ts @@ -0,0 +1,42 @@ +import { configureStore, type DevToolsEnhancerOptions } from "@reduxjs/toolkit"; +import { initStateWithPrevTab } from "redux-state-sync"; + +import { createLazySingleton } from "$/utils"; +import { init, tick, updateEventsEditorCursor } from "./actions"; +import { createAppEnhancers } from "./enhancers/_setup"; +import { default as reducer } from "./features/_setup"; +import { createAppMiddleware } from "./middleware/_setup"; + +// biome-ignore-start assist/source/organizeImports: circular dependencies + +import { getRouter } from "$/router"; + +// biome-ignore-end assist/source/organizeImports: circular dependencies + +export async function createAppStore() { + const devTools: DevToolsEnhancerOptions = { + name: "Beatmapper", + actionsDenylist: [tick.type, updateEventsEditorCursor.type], + }; + + const store = configureStore({ + reducer: reducer, + devTools: import.meta.env.VITE_ENABLE_DEVTOOLS ? devTools : undefined, + middleware: (getDefaultMiddleware) => { + return getDefaultMiddleware({ thunk: { extraArgument: { getRouter } } }).concat(createAppMiddleware()); + }, + enhancers: (getDefaultEnhancers) => { + return getDefaultEnhancers().concat(createAppEnhancers()); + }, + }); + + await store.hydrate().then(() => { + store.dispatch(init()); + }); + + initStateWithPrevTab(store); + + return store; +} + +export const { get: getAppStore, setup: setupAppStore } = createLazySingleton(createAppStore); diff --git a/src/store/actions.ts b/src/store/actions.ts index d6860c0b..77d49f29 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -22,8 +22,8 @@ import { selectSongVolume, selectTickVolume, } from "./selectors"; -import type { AppThunkApiConfig } from "./setup"; -import { createIncrementByIndexPayloadActionCreator, createIncrementByValuePayloadActionCreator, createThunk, type GetShallowThunkAPI } from "./utils"; +import type { AppThunkApiConfig } from "./types"; +import { createIncrementByIndexPayloadActionCreator, createIncrementByValuePayloadActionCreator, createThunk, type GetShallowThunkAPI } from "./utils/thunk.utils"; // biome-ignore-start assist/source/organizeImports: circular dependencies diff --git a/src/store/setup.ts b/src/store/enhancers/_setup.ts similarity index 84% rename from src/store/setup.ts rename to src/store/enhancers/_setup.ts index 45dee0ed..d2e3ab76 100644 --- a/src/store/setup.ts +++ b/src/store/enhancers/_setup.ts @@ -1,25 +1,18 @@ // biome-ignore-all lint/suspicious/noExplicitAny: type validations for migration steps are not super necessary -import { configureStore, type DevToolsEnhancerOptions, type ThunkDispatch, type UnknownAction } from "@reduxjs/toolkit"; import { omit } from "@std/collections/omit"; import { toPascalCase } from "@std/text/to-pascal-case"; -import { initStateWithPrevTab } from "redux-state-sync"; import { default as createLocalStorageDriver } from "unstorage/drivers/localstorage"; import { default as createSessionStorageDriver } from "unstorage/drivers/session-storage"; import { patchEnvironmentName } from "$/helpers/packaging.helpers"; import { createDriver, type LegacyStorageSchema } from "$/services/storage.service"; -import { setupAppBeatmapFilestore, setupAppToaster } from "$/setup"; -import { type App, type BeatmapId, EventColor, EventEditMode, EventTool, type IGridPresets, type Member, ObjectTool, ObstaclePlacementMode } from "$/types"; import { hydrateGridPresets, hydrateSongs, - init, - tick, updateAnnouncements, updateBloomEnabled, updateEventsEditorColor, - updateEventsEditorCursor, updateEventsEditorEditMode, updateEventsEditorMirrorLock, updateEventsEditorPreview, @@ -42,10 +35,7 @@ import { updateTickVolume, updateTrackScale, updateUsername, -} from "./actions"; -import { createEntityStorageStrategy, createEnumerableStorageObserver, createKeyValueStorageStrategy, createStorageEnhancer } from "./enhancers/storage.enhancer"; -import { default as reducer } from "./features"; -import { createAllSharedMiddleware } from "./middleware"; +} from "$/store/actions"; import { selectAllGridPresetIds, selectAnnouncements, @@ -76,13 +66,9 @@ import { selectTickVolume, selectUsername, selectUserObstaclePlacementMode, -} from "./selectors"; - -// biome-ignore-start assist/source/organizeImports: circular dependencies - -import { getRouter } from "$/router"; - -// biome-ignore-end assist/source/organizeImports: circular dependencies +} from "$/store/selectors"; +import { type App, type BeatmapId, EventColor, EventEditMode, EventTool, type IGridPresets, type Member, ObjectTool, ObstaclePlacementMode } from "$/types"; +import { createEntityStorageStrategy, createEnumerableStorageObserver, createKeyValueStorageStrategy, createStorageEnhancer } from "./storage.enhancer"; const STORAGE_PREFIX = location.hostname === "localhost" ? "beatmapper" : ""; @@ -164,14 +150,7 @@ const createAppEntityStorageDriver = createDriver { - return getDefaultMiddleware({ thunk: { extraArgument: { getRouter } } }).concat(createAllSharedMiddleware()); - }, - enhancers: (getDefaultEnhancers) => { - return getDefaultEnhancers().concat(localStorageEnhancer, sessionStorageEnhancer, songStorageEnhancer, gridStorageEnhancer); - }, - }); - - await store.hydrate().then(() => { - store.dispatch(init()); - }); - - initStateWithPrevTab(store); - - return store; -} - -export type RootState = ReturnType; -export type AppDispatch = ThunkDispatch; - -export interface AppThunkApiConfig { - state: RootState; - dispatch: AppDispatch; - extra: AppExtraArgs; + return [localStorageEnhancer, sessionStorageEnhancer, songStorageEnhancer, gridStorageEnhancer] as const; } diff --git a/src/store/enhancers/index.ts b/src/store/enhancers/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/store/features/index.ts b/src/store/features/_setup.ts similarity index 100% rename from src/store/features/index.ts rename to src/store/features/_setup.ts diff --git a/src/store/features/beatmap.slice.ts b/src/store/features/beatmap.slice.ts index 3c221e97..903e0bb8 100644 --- a/src/store/features/beatmap.slice.ts +++ b/src/store/features/beatmap.slice.ts @@ -1,10 +1,9 @@ -import { type AsyncThunkPayloadCreator, isAnyOf } from "@reduxjs/toolkit"; +import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, type GetThunkAPI, isAnyOf } from "@reduxjs/toolkit"; import type { NoteDirection } from "bsmap"; import { cycleToNextTool, cycleToPrevTool, finishManagingNoteSelection, startManagingNoteSelection, updateAllSelectedObstacles, updateObstacle } from "$/store/actions"; -import { createSlice } from "$/store/helpers"; import { selectGridSize } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; +import type { AppThunkApiConfig } from "$/store/types"; import { type IGrid, type IGridPresets, type ObjectSelectionMode, ObjectTool, type SongId, View } from "$/types"; const NOTE_TOOLS = Object.values(ObjectTool); @@ -17,13 +16,7 @@ const initialState = { gridPresets: {} as IGridPresets, }; -const fetchGridSize: AsyncThunkPayloadCreator<{ presetSlot: string; grid: IGrid }, { songId: SongId; presetSlot: string }> = (args, api) => { - const state = api.getState() as RootState; - const grid = selectGridSize(state, args.songId ?? null); - return api.fulfillWithValue({ ...args, grid }); -}; - -const slice = createSlice({ +const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })({ name: "beatmap", initialState: initialState, selectors: { @@ -36,6 +29,12 @@ const slice = createSlice({ selectGridPresetById: (state, id: string) => state.gridPresets[id], }, reducers: (api) => { + const fetchGridSize: AsyncThunkPayloadCreator<{ presetSlot: string; grid: IGrid }, { songId: SongId; presetSlot: string }> = (args, api: GetThunkAPI) => { + const state = api.getState(); + const grid = selectGridSize(state, args.songId ?? null); + return api.fulfillWithValue({ ...args, grid }); + }; + return { updateTool: api.reducer((state, action) => { return { ...state, selectedTool: action.payload }; diff --git a/src/store/features/bookmarks.slice.ts b/src/store/features/bookmarks.slice.ts index 04604bb4..aa4e4866 100644 --- a/src/store/features/bookmarks.slice.ts +++ b/src/store/features/bookmarks.slice.ts @@ -1,34 +1,34 @@ -import { type AsyncThunkPayloadCreator, createEntityAdapter, type EntityId, isAnyOf } from "@reduxjs/toolkit"; +import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, createEntityAdapter, type EntityId, type GetThunkAPI, isAnyOf } from "@reduxjs/toolkit"; import { sortObjectFn } from "bsmap"; import { resolveBookmarkId, resolveColorForBookmark } from "$/helpers/bookmarks.helpers"; import { addSong, leaveEditor, loadBeatmapEntities, startLoadingMap } from "$/store/actions"; -import { createSlice } from "$/store/helpers"; import { selectCursorPositionInBeats } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; +import type { AppThunkApiConfig } from "$/store/types"; import type { App, RequiredKeys, SongId, View } from "$/types"; const adapter = createEntityAdapter({ selectId: resolveBookmarkId, sortComparer: sortObjectFn, }); -const { selectAll } = adapter.getSelectors(); -const deriveDataFromState: AsyncThunkPayloadCreator, { songId: SongId; view: View; name: string }> = (args, api) => { - const state = api.getState() as RootState; - // we want to use the cursor position to figure out when to create the bookmark for - const beatNum = selectCursorPositionInBeats(state, args.songId); - if (beatNum === null) return api.rejectWithValue("Invalid beat number."); - return api.fulfillWithValue({ ...args, time: beatNum, color: resolveColorForBookmark(args.name) }); -}; +const { selectAll } = adapter.getSelectors(); -const slice = createSlice({ +const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })({ name: "bookmarks", initialState: adapter.getInitialState(), selectors: { selectAll: selectAll, }, reducers: (api) => { + const deriveDataFromState: AsyncThunkPayloadCreator, { songId: SongId; view: View; name: string }> = (args, api: GetThunkAPI) => { + const state = api.getState(); + // we want to use the cursor position to figure out when to create the bookmark for + const beatNum = selectCursorPositionInBeats(state, args.songId); + if (beatNum === null) return api.rejectWithValue("Invalid beat number."); + return api.fulfillWithValue({ ...args, time: beatNum, color: resolveColorForBookmark(args.name) }); + }; + return { addOne: api.asyncThunk(deriveDataFromState, { fulfilled: (state, action) => { diff --git a/src/store/features/clipboard.slice.ts b/src/store/features/clipboard.slice.ts index 02f3cf1b..45a208eb 100644 --- a/src/store/features/clipboard.slice.ts +++ b/src/store/features/clipboard.slice.ts @@ -1,9 +1,8 @@ -import type { AsyncThunkPayloadCreator, CaseReducer, PayloadAction } from "@reduxjs/toolkit"; +import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, type CaseReducer, type GetThunkAPI, type PayloadAction } from "@reduxjs/toolkit"; import { sortObjectFn } from "bsmap"; -import { createSlice } from "$/store/helpers"; import { selectSelectedBeatmapEntities } from "$/store/selectors"; -import type { RootState } from "$/store/setup"; +import type { AppThunkApiConfig } from "$/store/types"; import type { App, View } from "$/types"; const initialState = { @@ -11,29 +10,7 @@ const initialState = { data: {} as Partial>, }; -const fetchClipboardData: AsyncThunkPayloadCreator = (args: { view: View }, api) => { - const state = api.getState() as RootState; - const selection = selectSelectedBeatmapEntities(state, args.view); - return api.fulfillWithValue({ ...args, data: selection }); -}; - -const processSelection: CaseReducer> = (state, action) => { - const { data } = action.payload; - if (!data) return state; - return { - ...state, - data: { - // We want to sort the data so that it goes from earliest beat to latest beat. - notes: data.notes?.sort(sortObjectFn), - bombs: data.bombs?.sort(sortObjectFn), - obstacles: data.obstacles?.sort(sortObjectFn), - basicEvents: data.basicEvents?.sort(sortObjectFn), - boostEvents: data.boostEvents?.sort(sortObjectFn), - }, - }; -}; - -const slice = createSlice({ +const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })({ name: "clipboard", initialState: initialState, selectors: { @@ -52,6 +29,27 @@ const slice = createSlice({ }, }, reducers: (api) => { + const fetchClipboardData: AsyncThunkPayloadCreator = (args: { view: View }, api: GetThunkAPI) => { + const state = api.getState(); + const selection = selectSelectedBeatmapEntities(state, args.view); + return api.fulfillWithValue({ ...args, data: selection }); + }; + const processSelection: CaseReducer> = (state, action) => { + const { data } = action.payload; + if (!data) return state; + return { + ...state, + data: { + // We want to sort the data so that it goes from earliest beat to latest beat. + notes: data.notes?.sort(sortObjectFn), + bombs: data.bombs?.sort(sortObjectFn), + obstacles: data.obstacles?.sort(sortObjectFn), + basicEvents: data.basicEvents?.sort(sortObjectFn), + boostEvents: data.boostEvents?.sort(sortObjectFn), + }, + }; + }; + return { cutSelection: api.asyncThunk(fetchClipboardData, { fulfilled: processSelection, diff --git a/src/store/features/events.slice.ts b/src/store/features/events.slice.ts index bf3afe84..8cdb3502 100644 --- a/src/store/features/events.slice.ts +++ b/src/store/features/events.slice.ts @@ -5,7 +5,8 @@ import { createHistoryAdapter, type HistoryState } from "history-adapter/redux"; import { isBasicEvent, isBoostEvent, isTrackGroupable, resolveEventId, resolveGroupTrackIds, resolveTrackIdForEvent } from "$/helpers/events.helpers"; import { nudgeItem } from "$/helpers/item.helpers"; import { cutSelection, deselectAllEntities, drawEventSelectionBox, leaveEditor, loadBeatmapEntities, nudgeSelection, pasteSelection, selectAllEntities, selectAllEntitiesInRange, startLoadingMap } from "$/store/actions"; -import { createEditorObjectAdapter, selectNextSnapshot, selectPrevSnapshot } from "$/store/helpers"; +import { createEditorObjectAdapter } from "$/store/helpers/editor.helpers"; +import { selectNextSnapshot, selectPrevSnapshot } from "$/store/helpers/selectors"; import { type App, View } from "$/types"; const basicEvents = createEntityAdapter, EntityId>({ selectId: resolveEventId, sortComparer: sortObjectFn }); diff --git a/src/store/features/navigation.slice.ts b/src/store/features/navigation.slice.ts index 282062fa..a3b575c4 100644 --- a/src/store/features/navigation.slice.ts +++ b/src/store/features/navigation.slice.ts @@ -1,7 +1,6 @@ -import { isAnyOf } from "@reduxjs/toolkit"; +import { asyncThunkCreator, buildCreateSlice, isAnyOf } from "@reduxjs/toolkit"; import { leaveEditor, reloadVisualizer, scrollThroughSong, selectAllEntitiesInRange, updateSong } from "$/store/actions"; -import { createSlice } from "$/store/helpers"; import type { SongId } from "$/types"; import { clamp } from "$/utils"; @@ -19,7 +18,7 @@ const initialState = { playbackRate: 1, }; -const slice = createSlice({ +const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })({ name: "navigation", initialState: initialState, selectors: { diff --git a/src/store/features/objects.slice.ts b/src/store/features/objects.slice.ts index 4da9e472..acd2e5f5 100644 --- a/src/store/features/objects.slice.ts +++ b/src/store/features/objects.slice.ts @@ -6,7 +6,8 @@ import { mirrorBaseNoteProperties, mirrorGridObjectProperties, nudgeItem } from import { resolveNoteId } from "$/helpers/notes.helpers"; import { resolveObstacleId } from "$/helpers/obstacles.helpers"; import { cutSelection, deselectAllEntities, deselectAllEntitiesOfType, leaveEditor, loadBeatmapEntities, mirrorSelection, nudgeSelection, pasteSelection, selectAllEntities, selectAllEntitiesInRange, startLoadingMap } from "$/store/actions"; -import { createEditorObjectAdapter, selectNextSnapshot, selectPrevSnapshot } from "$/store/helpers"; +import { createEditorObjectAdapter } from "$/store/helpers/editor.helpers"; +import { selectNextSnapshot, selectPrevSnapshot } from "$/store/helpers/selectors"; import { type App, ObjectType, View } from "$/types"; const notes = createEntityAdapter, EntityId>({ selectId: resolveNoteId, sortComparer: sortObjectFn }); diff --git a/src/store/features/songs.slice.ts b/src/store/features/songs.slice.ts index 367020fc..e79e11a8 100644 --- a/src/store/features/songs.slice.ts +++ b/src/store/features/songs.slice.ts @@ -1,4 +1,4 @@ -import { type AsyncThunkPayloadCreator, createEntityAdapter, createSelector, type EntityId, isAnyOf } from "@reduxjs/toolkit"; +import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, createEntityAdapter, createSelector, type EntityId, isAnyOf } from "@reduxjs/toolkit"; import { distinct } from "@std/collections/distinct"; import { EnvironmentName, getBasicTracksForEnvironment } from "bsmap"; import { eventTypeRename } from "bsmap/extensions/renamer"; @@ -7,7 +7,6 @@ import { createAppBeatmap, createAppSong, getColorScheme, getEnvironment, resolv import { importMapArchiveToFilestore } from "$/services/packaging.service"; import { getAppToaster } from "$/setup"; import { finishLoadingMap, loadGridPreset, startLoadingMap } from "$/store/actions"; -import { createSlice } from "$/store/helpers"; import type { App, BeatmapId, IColorScheme, IGrid, SongId } from "$/types"; import { deepAssign } from "$/utils"; @@ -15,9 +14,10 @@ const adapter = createEntityAdapter({ selectId: resolveSongId, sortComparer: (a, b) => (b.lastOpenedAt ?? 0) - (a.lastOpenedAt ?? 0), }); + const { selectEntities, selectAll, selectIds, selectById } = adapter.getSelectors(); -const slice = createSlice({ +const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })({ name: "songs", initialState: adapter.getInitialState(), selectors: { diff --git a/src/store/features/timeline.slice.ts b/src/store/features/timeline.slice.ts index f868eb11..72612f7d 100644 --- a/src/store/features/timeline.slice.ts +++ b/src/store/features/timeline.slice.ts @@ -1,7 +1,7 @@ +import { createSlice } from "@reduxjs/toolkit"; import type { IBPMTimeScale } from "bsmap"; -import { createSlice } from "$/store/helpers"; -import { leaveEditor } from "../actions"; +import { leaveEditor } from "$/store/actions"; const initialState = { timescale: [] as IBPMTimeScale[], diff --git a/src/store/helpers.ts b/src/store/helpers/editor.helpers.ts similarity index 57% rename from src/store/helpers.ts rename to src/store/helpers/editor.helpers.ts index d4002830..27e88f49 100644 --- a/src/store/helpers.ts +++ b/src/store/helpers/editor.helpers.ts @@ -1,34 +1,7 @@ -import { asyncThunkCreator, buildCreateSlice, createDraftSafeSelector, type EntityAdapter, type EntityId, type EntityState } from "@reduxjs/toolkit"; -import { pick } from "@std/collections/pick"; -import type { HistoryState } from "history-adapter/redux"; +import { createDraftSafeSelector, type EntityAdapter, type EntityId, type EntityState } from "@reduxjs/toolkit"; import type { App } from "$/types"; -export const createSlice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } }); - -/** @deprecated */ -export type Snapshot = ReturnType; - -/** @deprecated */ -// biome-ignore lint/suspicious/noExplicitAny: only used during migrations -export function selectSnapshot(state: T) { - return { - user: state.user, - editor: state.editor, - songs: { - byId: state.songs.entities, - }, - navigation: pick(state.navigation, ["snapTo", "beatDepth", "volume", "playNoteTick"]), - }; -} - -export function selectPrevSnapshot(state: HistoryState) { - return state.past[state.past.length - 1]; -} -export function selectNextSnapshot(state: HistoryState) { - return state.future[state.future.length - 1]; -} - export function createEditorObjectAdapter(adapter: EntityAdapter) { const { selectAll } = adapter.getSelectors(); diff --git a/src/store/helpers/selectors.ts b/src/store/helpers/selectors.ts new file mode 100644 index 00000000..8f9ba2d1 --- /dev/null +++ b/src/store/helpers/selectors.ts @@ -0,0 +1,8 @@ +import type { HistoryState } from "history-adapter/redux"; + +export function selectPrevSnapshot(state: HistoryState) { + return state.past[state.past.length - 1]; +} +export function selectNextSnapshot(state: HistoryState) { + return state.future[state.future.length - 1]; +} diff --git a/src/store/hooks.ts b/src/store/hooks.ts index 93e56027..3b2447cf 100644 --- a/src/store/hooks.ts +++ b/src/store/hooks.ts @@ -1,6 +1,6 @@ import { useDispatch, useSelector } from "react-redux"; -import type { AppDispatch, RootState } from "./setup"; +import type { AppDispatch, RootState } from "./types"; export const useAppSelector = useSelector.withTypes(); export const useAppDispatch = useDispatch.withTypes(); diff --git a/src/store/middleware/index.ts b/src/store/middleware/_setup.ts similarity index 66% rename from src/store/middleware/index.ts rename to src/store/middleware/_setup.ts index 7e3a252a..03215e29 100644 --- a/src/store/middleware/index.ts +++ b/src/store/middleware/_setup.ts @@ -11,7 +11,7 @@ import createHistoryMiddleware from "./history.middleware"; import createPackagingMiddleware from "./packaging.middleware"; import createPlaybackMiddleware from "./playback.middleware"; -export function createAllSharedMiddleware() { +export function createAppMiddleware() { const stateSyncMiddleware = createStateSyncMiddleware({ predicate: (action) => { return isHydrationAction(action); @@ -25,23 +25,9 @@ export function createAllSharedMiddleware() { const playbackMiddleware = createPlaybackMiddleware({ songSample }); const fileMiddleware = createFileMiddleware(); const downloadMiddleware = createPackagingMiddleware(); - const backupMiddleware = createBackupMiddleware(); const demoMiddleware = createDemoMiddleware(); const historyMiddleware = createHistoryMiddleware(); + const backupMiddleware = createBackupMiddleware(); - return [ - // For unknown reasons, things crash when `stateSyncMiddleware` is further down. - stateSyncMiddleware as Middleware, - audioMiddleware, - playbackMiddleware, - fileMiddleware, - downloadMiddleware, - demoMiddleware, - historyMiddleware, - // We have two middlewares related to persistence: - // - Backup middleware saves the editor entities as beatmap files, also in indexeddb. - // - Storage middleware persists the current redux state to indexeddb (injected during setup) - // It's important that this stuff happens last, after all the other middlewares have fully affected the state. - backupMiddleware, - ]; + return [stateSyncMiddleware as Middleware, audioMiddleware, playbackMiddleware, fileMiddleware, downloadMiddleware, demoMiddleware, historyMiddleware, backupMiddleware]; } diff --git a/src/store/middleware/audio.middleware.ts b/src/store/middleware/audio.middleware.ts index 544a98e5..6cb5b0ac 100644 --- a/src/store/middleware/audio.middleware.ts +++ b/src/store/middleware/audio.middleware.ts @@ -7,7 +7,7 @@ import type { AudioSample } from "$/services/audio.service"; import { getAppBeatmapFilestore } from "$/setup"; import { decrementPlaybackRate, finishLoadingMap, incrementPlaybackRate, pausePlayback, startPlayback, stopPlayback, tick, updateCursorPosition, updatePlaybackRate, updateSong, updateSongVolume, updateTickType, updateTickVolume } from "$/store/actions"; import { selectAllColorNotes, selectAudioLatencyInBeats, selectCursorPosition, selectPlaybackRate, selectSongVolume, selectTickVolume } from "$/store/selectors"; -import type { AppDispatch, AppExtraArgs, RootState } from "$/store/setup"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import { type SongId, View } from "$/types"; function getTickSchedule(state: RootState, songId: SongId): number[] { diff --git a/src/store/middleware/backup.middleware.ts b/src/store/middleware/backup.middleware.ts index cfd41b9d..b6f3c9cc 100644 --- a/src/store/middleware/backup.middleware.ts +++ b/src/store/middleware/backup.middleware.ts @@ -3,7 +3,7 @@ import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/ import { createSaveHandler } from "$/services/backup.service"; import { leaveEditor, saveBeatmapContents, updateBeatmap, updateSong } from "$/store/actions"; import { selectSelectedBeatmap } from "$/store/selectors"; -import type { AppDispatch, RootState } from "$/store/setup"; +import type { AppDispatch, RootState } from "$/store/types"; import type { App, BeatmapId, SongId } from "$/types"; export default function createBackupMiddleware() { diff --git a/src/store/middleware/demo.middleware.ts b/src/store/middleware/demo.middleware.ts index 0e757ff1..2b3b3a09 100644 --- a/src/store/middleware/demo.middleware.ts +++ b/src/store/middleware/demo.middleware.ts @@ -4,7 +4,7 @@ import { demoFileUrl } from "$/assets"; import { getRouter } from "$/router"; import { getAppToaster } from "$/setup"; import { addSongFromFile, loadDemoMap } from "$/store/actions"; -import type { AppDispatch, AppExtraArgs, RootState } from "$/store/setup"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; /** This middleware exists only to load (and possibly manage) the demo song that comes with this app. */ export default function createDemoMiddleware() { diff --git a/src/store/middleware/file.middleware.ts b/src/store/middleware/file.middleware.ts index b90f91a5..e976bc6b 100644 --- a/src/store/middleware/file.middleware.ts +++ b/src/store/middleware/file.middleware.ts @@ -7,7 +7,7 @@ import { BeatmapFilestore } from "$/services/file.service"; import { getAppBeatmapFilestore } from "$/setup"; import { addBeatmap, addColorScheme, addSong, copyBeatmap, finishLoadingMap, loadBeatmapEntities, rehydrate, reloadVisualizer, removeBeatmap, removeColorScheme, removeSong, startLoadingMap, updateBeatmap, updateColorScheme, updateCustomColors, updateGridSize, updateModuleEnabled, updateSong } from "$/store/actions"; import { selectBeatmapIdsWithLightshowId, selectBpm, selectDuration, selectEditorOffsetInBeats, selectLightshowIdForBeatmap, selectSelectedBeatmap, selectSongById } from "$/store/selectors"; -import type { AppDispatch, RootState } from "$/store/setup"; +import type { AppDispatch, RootState } from "$/store/types"; import type { SongId } from "$/types"; import { deepAssign } from "$/utils"; diff --git a/src/store/middleware/history.middleware.ts b/src/store/middleware/history.middleware.ts index 7f7eeb0d..46a3a369 100644 --- a/src/store/middleware/history.middleware.ts +++ b/src/store/middleware/history.middleware.ts @@ -23,7 +23,7 @@ import { selectPastColorNotes, selectPastObstacles, } from "$/store/selectors"; -import type { AppDispatch, AppExtraArgs, RootState } from "$/store/setup"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import type { App, SongId } from "$/types"; import { difference } from "$/utils"; diff --git a/src/store/middleware/packaging.middleware.ts b/src/store/middleware/packaging.middleware.ts index b4b26f7b..39b2d60e 100644 --- a/src/store/middleware/packaging.middleware.ts +++ b/src/store/middleware/packaging.middleware.ts @@ -5,7 +5,7 @@ import { exportMapArchiveFromFilestore } from "$/services/packaging.service"; import { getAppToaster } from "$/setup"; import { downloadMapFiles } from "$/store/actions"; import { selectSongById } from "$/store/selectors"; -import type { AppDispatch, RootState } from "$/store/setup"; +import type { AppDispatch, RootState } from "$/store/types"; export default function createPackagingMiddleware() { const instance = createListenerMiddleware(); diff --git a/src/store/middleware/playback.middleware.ts b/src/store/middleware/playback.middleware.ts index b983c370..b48827c9 100644 --- a/src/store/middleware/playback.middleware.ts +++ b/src/store/middleware/playback.middleware.ts @@ -7,7 +7,7 @@ import type { AudioSample } from "$/services/audio.service"; import { getAppBeatmapFilestore } from "$/setup"; import { finishLoadingMap, jumpToBeat, jumpToEnd, jumpToStart, jumpToTime, pausePlayback, scrollThroughSong, seekBackwards, seekForwards, startPlayback, stopPlayback, tick, togglePlayback, updateCursorPosition, updateSong, updateTimescale } from "$/store/actions"; import { selectBeatForTime, selectBpm, selectCursorPosition, selectDuration, selectEventsEditorBeatsPerZoomLevel, selectEventsEditorWindowLock, selectPlaying, selectSelectedBeatmap, selectSnap, selectTimeForBeat } from "$/store/selectors"; -import type { AppDispatch, AppExtraArgs, RootState } from "$/store/setup"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import { type SongId, View } from "$/types"; import { floorToNearest } from "$/utils"; diff --git a/src/store/selectors.ts b/src/store/selectors.ts index 058bda3d..5fa16b6d 100644 --- a/src/store/selectors.ts +++ b/src/store/selectors.ts @@ -21,7 +21,7 @@ import songs from "./features/songs.slice"; import timeline from "./features/timeline.slice"; import user from "./features/user.slice"; import visualizer from "./features/visualizer.slice"; -import type { RootState } from "./setup"; +import type { RootState } from "./types"; export const { selectInitialized, selectLoading, selectProcessingImport } = global.getSelectors(global.selectSlice); diff --git a/src/store/types.ts b/src/store/types.ts new file mode 100644 index 00000000..4b833b25 --- /dev/null +++ b/src/store/types.ts @@ -0,0 +1,17 @@ +import type { ThunkDispatch, UnknownAction } from "@reduxjs/toolkit"; + +import type { getRouter } from "$/router"; +import type { default as reducer } from "./features/_setup"; + +export interface AppExtraArgs { + getRouter: typeof getRouter; +} + +export type RootState = ReturnType; +export type AppDispatch = ThunkDispatch; + +export interface AppThunkApiConfig { + state: RootState; + dispatch: AppDispatch; + extra: AppExtraArgs; +} diff --git a/src/store/utils.ts b/src/store/utils/thunk.utils.ts similarity index 100% rename from src/store/utils.ts rename to src/store/utils/thunk.utils.ts From 377728c7b4a5eeb162349d1b7d2797c7b130ead5 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Thu, 6 Aug 2026 13:33:59 -0500 Subject: [PATCH 13/27] improve type inference for thunk utils --- src/store/utils/thunk.utils.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/store/utils/thunk.utils.ts b/src/store/utils/thunk.utils.ts index 513516c4..53f7c0cb 100644 --- a/src/store/utils/thunk.utils.ts +++ b/src/store/utils/thunk.utils.ts @@ -1,4 +1,4 @@ -import type { ActionCreatorWithPayload, Dispatch, GetState, SerializedError, ThunkAction, ThunkDispatch, UnknownAction } from "@reduxjs/toolkit"; +import { type ActionCreatorWithPreparedPayload, type Dispatch, type GetState, isAction, type PayloadAction, type SerializedError, type ThunkAction, type ThunkDispatch, type UnknownAction } from "@reduxjs/toolkit"; import { cycle } from "$/utils"; @@ -11,7 +11,7 @@ type GetExtra = ThunkApiConfig extends { extra: infer Extra } ? type GetMeta = ThunkApiConfig extends { meta: infer Meta } ? Meta : unknown; type GetSerializedErrorType = ThunkApiConfig extends { serializedErrorType: infer TError } ? TError : SerializedError; -type GetArgs = [undefined] extends [Arg] ? [_?: Arg] : [arg: Arg]; +type GetArgs = [undefined] extends [Arg] ? [] : [Arg]; export interface GetShallowThunkAPI { dispatch: GetDispatch; @@ -19,17 +19,22 @@ export interface GetShallowThunkAPI { extra: GetExtra; } -export interface ThunkActionCreator extends ActionCreatorWithPayload { +export interface ThunkActionCreator, M = GetMeta> extends ActionCreatorWithPreparedPayload<[Arg], Returned, string, S, M> { (...args: GetArgs): ThunkAction, GetExtra, UnknownAction>; + match: (action: unknown) => action is PayloadAction; } -export interface ThunkOptions { +export interface ThunkOptions, M = GetMeta> { condition?: (arg: Arg, api: Pick, "getState" | "extra">) => boolean | undefined; - getMeta?: (arg: Arg, api: Pick, "getState" | "extra">) => GetMeta; - serializeError?: (x: unknown) => GetSerializedErrorType; + getMeta?: (arg: Arg, api: Pick, "getState" | "extra">) => M; + serializeError?: (x: unknown) => S; } -export function createThunk(type: string, payloadCreator: (arg: Arg, api: GetShallowThunkAPI) => Returned, options?: ThunkOptions): ThunkActionCreator { +export function createThunk, M = GetMeta>( + type: string, + payloadCreator: (arg: Arg, api: GetShallowThunkAPI) => Returned, + options?: ThunkOptions, +): ThunkActionCreator { const actionCreator = (...args: GetArgs) => { const arg = args[0] as Arg; @@ -57,10 +62,10 @@ export function createThunk(type: string, payload return Object.assign(actionCreator, { type: type, toString: () => type, - match: (action: UnknownAction): action is ReturnType> => { - return action?.type === type; + match: (action: unknown): action is PayloadAction => { + return isAction(action) && action.type === type; }, - }) as ThunkActionCreator; + }) as ThunkActionCreator; } export function createIncrementByIndexPayloadActionCreator(iterable: Iterable, options: { select: (state: TState) => TValue; update: (value: TValue) => UnknownAction }) { From ec938396308953457fe10b301f696028508b1a02 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Thu, 6 Aug 2026 20:11:30 -0500 Subject: [PATCH 14/27] move route selectors into store helpers --- src/router.ts | 28 +-------------------- src/store/helpers/route.helpers.ts | 25 ++++++++++++++++++ src/store/middleware/audio.middleware.ts | 3 ++- src/store/middleware/history.middleware.ts | 3 ++- src/store/middleware/playback.middleware.ts | 3 ++- 5 files changed, 32 insertions(+), 30 deletions(-) create mode 100644 src/store/helpers/route.helpers.ts diff --git a/src/router.ts b/src/router.ts index a037221c..7cb79480 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,4 +1,4 @@ -import { createRouter, useParams, type MakeRouteMatch, type Register, type RouteIds } from "@tanstack/react-router"; +import { createRouter } from "@tanstack/react-router"; import ErrorBoundary from "./components/app/templates/error-boundary"; import PendingBoundary from "./components/app/templates/pending-boundary"; @@ -18,29 +18,3 @@ declare module "@tanstack/react-router" { router: ReturnType; } } - -export function createRouteSelector, T>(options: { from: TFrom; selector: (route: MakeRouteMatch) => T }) { - return (router: Register["router"]) => { - const match = router.state.matches.find((m) => m.routeId === options.from); - - if (!match) { - throw new Error(`Tried to access state from unmatched route: ${options.from}`); - } - return options.selector(match as unknown as MakeRouteMatch); - }; -} - -export const selectActiveSongId = createRouteSelector({ - from: "/_/edit/$sid/$bid/_", - selector: ({ params }) => params.sid, -}); -export const selectActiveBeatmapId = createRouteSelector({ - from: "/_/edit/$sid/$bid/_", - selector: ({ params }) => params.bid, -}); -export const selectActiveView = createRouteSelector({ - from: "/_/edit/$sid/$bid/_", - selector: ({ context }) => context.view, -}); - -useParams; diff --git a/src/store/helpers/route.helpers.ts b/src/store/helpers/route.helpers.ts new file mode 100644 index 00000000..25951bd9 --- /dev/null +++ b/src/store/helpers/route.helpers.ts @@ -0,0 +1,25 @@ +import type { MakeRouteMatch, Register, RouteIds } from "@tanstack/react-router"; + +export function createRouteSelector, T>(options: { from: TFrom; selector: (route: MakeRouteMatch) => T }) { + return (router: Register["router"]) => { + const match = router.state.matches.find((m) => m.routeId === options.from); + + if (!match) { + throw new Error(`Tried to access state from unmatched route: ${options.from}`); + } + return options.selector(match as unknown as MakeRouteMatch); + }; +} + +export const selectActiveSongId = createRouteSelector({ + from: "/_/edit/$sid/$bid/_", + selector: ({ params }) => params.sid, +}); +export const selectActiveBeatmapId = createRouteSelector({ + from: "/_/edit/$sid/$bid/_", + selector: ({ params }) => params.bid, +}); +export const selectActiveView = createRouteSelector({ + from: "/_/edit/$sid/$bid/_", + selector: ({ context }) => context.view, +}); diff --git a/src/store/middleware/audio.middleware.ts b/src/store/middleware/audio.middleware.ts index 6cb5b0ac..963b0880 100644 --- a/src/store/middleware/audio.middleware.ts +++ b/src/store/middleware/audio.middleware.ts @@ -2,10 +2,11 @@ import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/ import { NOTE_TICK_TYPES } from "$/constants"; import { convertFileToArrayBuffer } from "$/helpers/file.helpers"; -import { getRouter, selectActiveView } from "$/router"; +import { getRouter } from "$/router"; import type { AudioSample } from "$/services/audio.service"; import { getAppBeatmapFilestore } from "$/setup"; import { decrementPlaybackRate, finishLoadingMap, incrementPlaybackRate, pausePlayback, startPlayback, stopPlayback, tick, updateCursorPosition, updatePlaybackRate, updateSong, updateSongVolume, updateTickType, updateTickVolume } from "$/store/actions"; +import { selectActiveView } from "$/store/helpers/route.helpers"; import { selectAllColorNotes, selectAudioLatencyInBeats, selectCursorPosition, selectPlaybackRate, selectSongVolume, selectTickVolume } from "$/store/selectors"; import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import { type SongId, View } from "$/types"; diff --git a/src/store/middleware/history.middleware.ts b/src/store/middleware/history.middleware.ts index 46a3a369..1d46959c 100644 --- a/src/store/middleware/history.middleware.ts +++ b/src/store/middleware/history.middleware.ts @@ -4,7 +4,7 @@ import { sortObjectFn } from "bsmap"; import { resolveEventId } from "$/helpers/events.helpers"; import { resolveNoteId } from "$/helpers/notes.helpers"; import { resolveObstacleId } from "$/helpers/obstacles.helpers"; -import { getRouter, selectActiveSongId } from "$/router"; +import { getRouter } from "$/router"; import { clearEventHistory, clearObjectHistory, jumpToBeat, leaveEditor, redoEvents, redoObjects, undoEvents, undoObjects } from "$/store/actions"; import { selectAllBasicEvents, @@ -26,6 +26,7 @@ import { import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import type { App, SongId } from "$/types"; import { difference } from "$/utils"; +import { selectActiveSongId } from "../helpers/route.helpers"; function jumpToEarliestObject(api: ListenerEffectAPI, songId: SongId, args: { [K in "notes" | "bombs" | "obstacles"]: { before: App.IBeatmapEntities[K]; after: App.IBeatmapEntities[K] } }) { const relevantNotes = difference(args.notes.before, args.notes.after, resolveNoteId); diff --git a/src/store/middleware/playback.middleware.ts b/src/store/middleware/playback.middleware.ts index b48827c9..83e2eb3d 100644 --- a/src/store/middleware/playback.middleware.ts +++ b/src/store/middleware/playback.middleware.ts @@ -2,7 +2,7 @@ import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/ import { TimeProcessor } from "bsmap"; import { createBpmEventsFromAudioData } from "$/helpers/audio.helpers"; -import { getRouter, selectActiveView } from "$/router"; +import { getRouter } from "$/router"; import type { AudioSample } from "$/services/audio.service"; import { getAppBeatmapFilestore } from "$/setup"; import { finishLoadingMap, jumpToBeat, jumpToEnd, jumpToStart, jumpToTime, pausePlayback, scrollThroughSong, seekBackwards, seekForwards, startPlayback, stopPlayback, tick, togglePlayback, updateCursorPosition, updateSong, updateTimescale } from "$/store/actions"; @@ -10,6 +10,7 @@ import { selectBeatForTime, selectBpm, selectCursorPosition, selectDuration, sel import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import { type SongId, View } from "$/types"; import { floorToNearest } from "$/utils"; +import { selectActiveView } from "../helpers/route.helpers"; /** Manages all concerns related to audio playback and timescales. */ export default function createPlaybackMiddleware({ songSample }: { songSample: AudioSample }) { From 029d6910e7fa593a10bc608d0b09df53fbebd47e Mon Sep 17 00:00:00 2001 From: officialMECH Date: Thu, 6 Aug 2026 20:45:21 -0500 Subject: [PATCH 15/27] improve dependency injection patterns for middleware/thunks --- src/index.tsx | 6 +- src/services/backup.service.ts | 60 ------------- src/store/_setup.ts | 13 ++- src/store/features/songs.slice.ts | 9 +- src/store/middleware/_setup.ts | 21 +++-- src/store/middleware/audio.middleware.ts | 19 +++-- src/store/middleware/backup.middleware.ts | 88 ++++++++++++++++---- src/store/middleware/demo.middleware.ts | 22 ++--- src/store/middleware/file.middleware.ts | 43 +++++++--- src/store/middleware/history.middleware.ts | 19 ++--- src/store/middleware/packaging.middleware.ts | 12 +-- src/store/middleware/playback.middleware.ts | 19 +++-- src/store/types.ts | 8 +- 13 files changed, 182 insertions(+), 157 deletions(-) delete mode 100644 src/services/backup.service.ts diff --git a/src/index.tsx b/src/index.tsx index 4c2e95e1..b5a6344a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,9 +10,9 @@ import { getAppBeatmapFilestore, getAppStore, getAppToaster, setupAppStore, setu import "./index.css"; -setupRouter(); +const router = setupRouter(); setupAudioContext(); -setupAppStore(); +setupAppStore({ extraArgument: { getRouter: getRouter, getFilestore: getAppBeatmapFilestore, getToaster: getAppToaster } }); const root = document.getElementById("root"); @@ -26,7 +26,7 @@ createRoot(root).render( - + , diff --git a/src/services/backup.service.ts b/src/services/backup.service.ts deleted file mode 100644 index 5955d4ea..00000000 --- a/src/services/backup.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { serializeBeatmapContents, serializeInfoContents } from "$/helpers/packaging.helpers"; -import { getAppBeatmapFilestore, getAppToaster } from "$/setup"; -import { selectBeatmapEntities, selectBeatmapIds, selectBeatmapIdsWithLightshowId, selectDuration, selectEditorOffsetInBeats, selectLightshowIdForBeatmap, selectSelectedBeatmap, selectSongById } from "$/store/selectors"; -import type { RootState } from "$/store/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, -// (stuff like `songName_Info.dat` and `songName_Expert.dat`). -// Whenever redux saves the song list, we should also save all the stuff in the editor-entities reducer by storing them in info files. - -// (If this feels overly complicated, and you're wondering why I don't just store _everything_ in redux, -// 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 function createSaveHandler() { - const filestore = getAppBeatmapFilestore(); - const toaster = getAppToaster(); - - return async function save(state: RootState, 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 infoContents = serializeInfoContents(selectSongById(state, songId), { - songDuration: selectDuration(state), - }); - await filestore.updateInfoContents(songId, infoContents); - - // Note that we can also download files from the homescreen, so there will be no selected difficulty in this case. - if (beatmapId === selectSelectedBeatmap(state, songId)) { - const activeEntities = entities ?? selectBeatmapEntities(state); - - const { difficulty, lightshow, customData } = serializeBeatmapContents(activeEntities, { - version: await filestore.loadImplicitVersion(songId, beatmapId), - editorOffsetInBeats: selectEditorOffsetInBeats(state, songId), - }); - await filestore.updateBeatmapContents(songId, beatmapId, { difficulty, lightshow, customData }); - - // we want to copy custom data across all beatmaps - const beatmapIds = selectBeatmapIds(state, songId); - - for (const targetBeatmapId of beatmapIds) { - await filestore.updateBeatmapContents(songId, targetBeatmapId, { - difficulty: { customData: difficulty.customData }, - lightshow: { customData: lightshow.customData }, - customData, - }); - } - - // we want to copy lightshow data across beatmaps that share the same lightshow id - const sharedBeatmapIds = selectBeatmapIdsWithLightshowId(state, songId, selectLightshowIdForBeatmap(state, songId, beatmapId)); - - for (const targetBeatmapId of sharedBeatmapIds) { - await filestore.updateBeatmapContents(songId, targetBeatmapId, { lightshow }); - } - } - - const id = `${beatmapId ? `${songId}/${beatmapId}` : songId}`; - - return toaster?.success({ id: `save/${id}`, description: `Contents for "${id}" has been saved.` }); - }; -} diff --git a/src/store/_setup.ts b/src/store/_setup.ts index 7b6f368a..8efbaf67 100644 --- a/src/store/_setup.ts +++ b/src/store/_setup.ts @@ -6,14 +6,13 @@ import { init, tick, updateEventsEditorCursor } from "./actions"; import { createAppEnhancers } from "./enhancers/_setup"; import { default as reducer } from "./features/_setup"; import { createAppMiddleware } from "./middleware/_setup"; +import type { AppExtraArgs } from "./types"; -// biome-ignore-start assist/source/organizeImports: circular dependencies - -import { getRouter } from "$/router"; - -// biome-ignore-end assist/source/organizeImports: circular dependencies +interface Options { + extraArgument: AppExtraArgs; +} -export async function createAppStore() { +export async function createAppStore({ extraArgument }: Options) { const devTools: DevToolsEnhancerOptions = { name: "Beatmapper", actionsDenylist: [tick.type, updateEventsEditorCursor.type], @@ -23,7 +22,7 @@ export async function createAppStore() { reducer: reducer, devTools: import.meta.env.VITE_ENABLE_DEVTOOLS ? devTools : undefined, middleware: (getDefaultMiddleware) => { - return getDefaultMiddleware({ thunk: { extraArgument: { getRouter } } }).concat(createAppMiddleware()); + return getDefaultMiddleware({ thunk: { extraArgument } }).concat(createAppMiddleware({ extraArgument })); }, enhancers: (getDefaultEnhancers) => { return getDefaultEnhancers().concat(createAppEnhancers()); diff --git a/src/store/features/songs.slice.ts b/src/store/features/songs.slice.ts index e79e11a8..97d14429 100644 --- a/src/store/features/songs.slice.ts +++ b/src/store/features/songs.slice.ts @@ -1,12 +1,12 @@ -import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, createEntityAdapter, createSelector, type EntityId, isAnyOf } from "@reduxjs/toolkit"; +import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, createEntityAdapter, createSelector, type EntityId, type GetThunkAPI, isAnyOf } from "@reduxjs/toolkit"; import { distinct } from "@std/collections/distinct"; import { EnvironmentName, getBasicTracksForEnvironment } from "bsmap"; import { eventTypeRename } from "bsmap/extensions/renamer"; import { createAppBeatmap, createAppSong, getColorScheme, getEnvironment, resolveSongId } from "$/helpers/song.helpers"; import { importMapArchiveToFilestore } from "$/services/packaging.service"; -import { getAppToaster } from "$/setup"; import { finishLoadingMap, loadGridPreset, startLoadingMap } from "$/store/actions"; +import type { AppThunkApiConfig } from "$/store/types"; import type { App, BeatmapId, IColorScheme, IGrid, SongId } from "$/types"; import { deepAssign } from "$/utils"; @@ -103,14 +103,13 @@ const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })( }), }, reducers: (api) => { - const fetchContentsFromFile: AsyncThunkPayloadCreator<{ songId: SongId; songData: App.ISong }, { file: File | Blob; options: Parameters[1] }> = async (args, api) => { - const toaster = getAppToaster(); - + const fetchContentsFromFile: AsyncThunkPayloadCreator<{ songId: SongId; songData: App.ISong }, { file: File | Blob; options: Parameters[1] }> = async (args, api: GetThunkAPI) => { try { const archive = await args.file.arrayBuffer(); const songData = await importMapArchiveToFilestore(new Uint8Array(archive), args.options); return api.fulfillWithValue({ songId: songData.id, songData: { ...songData, demo: args.options.readonly } }); } catch (error) { + const toaster = api.extra.getToaster(); toaster?.error({ description: `Could not import map: ${error instanceof Error ? error.message : "See console for more info."}` }); console.error(error); return api.rejectWithValue(error); diff --git a/src/store/middleware/_setup.ts b/src/store/middleware/_setup.ts index 03215e29..978fbbee 100644 --- a/src/store/middleware/_setup.ts +++ b/src/store/middleware/_setup.ts @@ -3,6 +3,7 @@ import { createStateSyncMiddleware } from "redux-state-sync"; import { AudioSample } from "$/services/audio.service"; import { isHydrationAction } from "$/store/enhancers/storage.enhancer"; +import type { AppExtraArgs } from "$/store/types"; import createAudioMiddleware from "./audio.middleware"; import createBackupMiddleware from "./backup.middleware"; import createDemoMiddleware from "./demo.middleware"; @@ -11,7 +12,11 @@ import createHistoryMiddleware from "./history.middleware"; import createPackagingMiddleware from "./packaging.middleware"; import createPlaybackMiddleware from "./playback.middleware"; -export function createAppMiddleware() { +interface Options { + extraArgument: AppExtraArgs; +} + +export function createAppMiddleware({ extraArgument: extra }: Options) { const stateSyncMiddleware = createStateSyncMiddleware({ predicate: (action) => { return isHydrationAction(action); @@ -21,13 +26,13 @@ export function createAppMiddleware() { const songSample = new AudioSample({ volume: 1, playbackRate: 1 }); const tickSample = new AudioSample({ volume: 1, playbackRate: 1 }); - const audioMiddleware = createAudioMiddleware({ songSample, tickSample }); - const playbackMiddleware = createPlaybackMiddleware({ songSample }); - const fileMiddleware = createFileMiddleware(); - const downloadMiddleware = createPackagingMiddleware(); - const demoMiddleware = createDemoMiddleware(); - const historyMiddleware = createHistoryMiddleware(); - const backupMiddleware = createBackupMiddleware(); + const audioMiddleware = createAudioMiddleware({ songSample, tickSample, extra }); + const playbackMiddleware = createPlaybackMiddleware({ songSample, extra }); + const fileMiddleware = createFileMiddleware({ extra }); + const downloadMiddleware = createPackagingMiddleware({ extra }); + const demoMiddleware = createDemoMiddleware({ extra }); + const historyMiddleware = createHistoryMiddleware({ extra }); + const backupMiddleware = createBackupMiddleware({ extra }); return [stateSyncMiddleware as Middleware, audioMiddleware, playbackMiddleware, fileMiddleware, downloadMiddleware, demoMiddleware, historyMiddleware, backupMiddleware]; } diff --git a/src/store/middleware/audio.middleware.ts b/src/store/middleware/audio.middleware.ts index 963b0880..4793a618 100644 --- a/src/store/middleware/audio.middleware.ts +++ b/src/store/middleware/audio.middleware.ts @@ -2,9 +2,7 @@ import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/ import { NOTE_TICK_TYPES } from "$/constants"; import { convertFileToArrayBuffer } from "$/helpers/file.helpers"; -import { getRouter } from "$/router"; import type { AudioSample } from "$/services/audio.service"; -import { getAppBeatmapFilestore } from "$/setup"; import { decrementPlaybackRate, finishLoadingMap, incrementPlaybackRate, pausePlayback, startPlayback, stopPlayback, tick, updateCursorPosition, updatePlaybackRate, updateSong, updateSongVolume, updateTickType, updateTickVolume } from "$/store/actions"; import { selectActiveView } from "$/store/helpers/route.helpers"; import { selectAllColorNotes, selectAudioLatencyInBeats, selectCursorPosition, selectPlaybackRate, selectSongVolume, selectTickVolume } from "$/store/selectors"; @@ -17,13 +15,15 @@ function getTickSchedule(state: RootState, songId: SongId): number[] { return notes.map((note) => note.time - delayInBeats).sort((a, b) => a - b); } -/** Manages all concerns related to audio samples. */ -export default function createAudioMiddleware({ songSample, tickSample }: { songSample: AudioSample; tickSample: AudioSample }) { - const instance = createListenerMiddleware({ - extra: { getRouter }, - }); +interface Options { + songSample: AudioSample; + tickSample: AudioSample; + extra: Pick; +} - const filestore = getAppBeatmapFilestore(); +/** Manages all concerns related to audio samples. */ +export default function createAudioMiddleware({ songSample, tickSample, extra }: Options) { + const instance = createListenerMiddleware({ extra }); tickSample.load(NOTE_TICK_TYPES[0]); @@ -37,10 +37,11 @@ export default function createAudioMiddleware({ songSample, tickSample }: { song }); instance.startListening({ matcher: isAnyOf(finishLoadingMap, updateSong), - effect: async (action: PayloadAction<{ songId: SongId; songFile?: File }>, _) => { + effect: async (action: PayloadAction<{ songId: SongId; songFile?: File }>, api) => { const { songId, songFile } = action.payload; if (finishLoadingMap.match(action) || songFile) { + const filestore = api.extra.getFilestore(); const updatedSongFile = songFile ?? (await filestore.loadSongFile(songId)); const arrayBuffer = await convertFileToArrayBuffer(updatedSongFile); await songSample.loadFromArrayBuffer(arrayBuffer); diff --git a/src/store/middleware/backup.middleware.ts b/src/store/middleware/backup.middleware.ts index b6f3c9cc..c1a8eac3 100644 --- a/src/store/middleware/backup.middleware.ts +++ b/src/store/middleware/backup.middleware.ts @@ -1,47 +1,103 @@ -import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/toolkit"; +import { createListenerMiddleware, isAnyOf, type ListenerEffectAPI, type PayloadAction } from "@reduxjs/toolkit"; -import { createSaveHandler } from "$/services/backup.service"; +import { serializeBeatmapContents, serializeInfoContents } from "$/helpers/packaging.helpers"; import { leaveEditor, saveBeatmapContents, updateBeatmap, updateSong } from "$/store/actions"; -import { selectSelectedBeatmap } from "$/store/selectors"; -import type { AppDispatch, RootState } from "$/store/types"; +import { selectBeatmapEntities, selectBeatmapIds, selectBeatmapIdsWithLightshowId, selectDuration, selectEditorOffsetInBeats, selectLightshowIdForBeatmap, selectSelectedBeatmap, selectSongById } from "$/store/selectors"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import type { App, BeatmapId, SongId } from "$/types"; -export default function createBackupMiddleware() { - const instance = createListenerMiddleware(); +// 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, +// (stuff like `songName_Info.dat` and `songName_Expert.dat`). +// Whenever redux saves the song list, we should also save all the stuff in the editor-entities reducer by storing them in info files. - const save = createSaveHandler(); +// (If this feels overly complicated, and you're wondering why I don't just store _everything_ in redux, +// 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.) + +async function save(api: ListenerEffectAPI>, songId: SongId, beatmapId: BeatmapId | null, entities?: Partial) { + const state = api.getState(); + const filestore = api.extra.getFilestore(); + + // If we have an actively-loaded song, we want to first persist that song so that we download the very latest stuff. + const infoContents = serializeInfoContents(selectSongById(state, songId), { + songDuration: selectDuration(state), + }); + + await filestore.updateInfoContents(songId, infoContents); + + // Note that we can also download files from the homescreen, so there will be no selected difficulty in this case. + if (beatmapId === selectSelectedBeatmap(state, songId)) { + const activeEntities = entities ?? selectBeatmapEntities(state); + + const { difficulty, lightshow, customData } = serializeBeatmapContents(activeEntities, { + version: await filestore.loadImplicitVersion(songId, beatmapId), + editorOffsetInBeats: selectEditorOffsetInBeats(state, songId), + }); + + await filestore.updateBeatmapContents(songId, beatmapId, { difficulty, lightshow, customData }); + + // we want to copy custom data across all beatmaps + const beatmapIds = selectBeatmapIds(state, songId); + + for (const targetBeatmapId of beatmapIds) { + await filestore.updateBeatmapContents(songId, targetBeatmapId, { + difficulty: { customData: difficulty.customData }, + lightshow: { customData: lightshow.customData }, + customData, + }); + } + + // we want to copy lightshow data across beatmaps that share the same lightshow id + const sharedBeatmapIds = selectBeatmapIdsWithLightshowId(state, songId, selectLightshowIdForBeatmap(state, songId, beatmapId)); + + for (const targetBeatmapId of sharedBeatmapIds) { + await filestore.updateBeatmapContents(songId, targetBeatmapId, { lightshow }); + } + } + + const toaster = api.extra.getToaster(); + + if (toaster) { + const id = `${beatmapId ? `${songId}/${beatmapId}` : songId}`; + return toaster?.success({ id: `save/${id}`, description: `Contents for "${id}" has been saved.` }); + } +} + +interface Options { + extra: Pick; +} + +export default function createBackupMiddleware({ extra }: Options) { + const instance = createListenerMiddleware({ extra }); instance.startListening({ matcher: isAnyOf(saveBeatmapContents), effect: async (action: PayloadAction<{ songId: SongId }>, api) => { const { songId } = action.payload; - const state = api.getState(); - const beatmapId = selectSelectedBeatmap(state, songId); - await save(state, songId, beatmapId); + const beatmapId = selectSelectedBeatmap(api.getState(), songId); + await save(api, 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 save(state, songId, beatmapId, entities); + await save(api, songId, beatmapId, entities); }, }); instance.startListening({ matcher: isAnyOf(updateSong), effect: async (action: PayloadAction<{ songId: SongId }>, api) => { const { songId } = action.payload; - const state = api.getState(); - await save(state, songId, null); + await save(api, songId, null); }, }); instance.startListening({ matcher: isAnyOf(updateBeatmap), effect: async (action: PayloadAction<{ songId: SongId; beatmapId: BeatmapId }>, api) => { const { songId, beatmapId } = action.payload; - const state = api.getState(); - await save(state, songId, beatmapId); + await save(api, songId, beatmapId); }, }); diff --git a/src/store/middleware/demo.middleware.ts b/src/store/middleware/demo.middleware.ts index 2b3b3a09..a48d947d 100644 --- a/src/store/middleware/demo.middleware.ts +++ b/src/store/middleware/demo.middleware.ts @@ -1,30 +1,30 @@ import { createListenerMiddleware } from "@reduxjs/toolkit"; import { demoFileUrl } from "$/assets"; -import { getRouter } from "$/router"; -import { getAppToaster } from "$/setup"; import { addSongFromFile, loadDemoMap } from "$/store/actions"; import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; -/** This middleware exists only to load (and possibly manage) the demo song that comes with this app. */ -export default function createDemoMiddleware() { - const instance = createListenerMiddleware({ - extra: { getRouter }, - }); +interface Options { + extra: Pick; +} - const toaster = getAppToaster(); +/** This middleware exists only to load (and possibly manage) the demo song that comes with this app. */ +export default function createDemoMiddleware({ extra }: Options) { + const instance = createListenerMiddleware({ extra }); instance.startListening({ actionCreator: loadDemoMap, effect: async (_, api) => { - const router = api.extra.getRouter(); - try { const blob = await fetch(demoFileUrl).then((response) => response.blob()); - const { songId, songData } = await api.dispatch(addSongFromFile({ file: blob, options: { readonly: true } })).unwrap(); + const { songData } = await api.dispatch(addSongFromFile({ file: blob, options: { readonly: true } })).unwrap(); + + const router = api.extra.getRouter(); + const songId = songData.id; const beatmapId = songData.selectedDifficulty ?? Object.keys(songData.difficultiesById)[0]; router.navigate({ to: "/edit/$sid/$bid/notes", params: { sid: songId.toString(), bid: beatmapId.toString() } }); } catch (error) { + const toaster = api.extra.getToaster(); toaster?.error({ description: `Could not import map: ${error instanceof Error ? error.message : "See console for more info."}` }); return console.error(error); } diff --git a/src/store/middleware/file.middleware.ts b/src/store/middleware/file.middleware.ts index e976bc6b..92b18322 100644 --- a/src/store/middleware/file.middleware.ts +++ b/src/store/middleware/file.middleware.ts @@ -4,18 +4,19 @@ import { createBeatmap } from "bsmap"; import { createAudioDataContentsFromFile, deriveWaveformDataFromFile } from "$/helpers/audio.helpers"; import { deserializeBeatmapContents, serializeInfoContents } from "$/helpers/packaging.helpers"; import { BeatmapFilestore } from "$/services/file.service"; -import { getAppBeatmapFilestore } from "$/setup"; import { addBeatmap, addColorScheme, addSong, copyBeatmap, finishLoadingMap, loadBeatmapEntities, rehydrate, reloadVisualizer, removeBeatmap, removeColorScheme, removeSong, startLoadingMap, updateBeatmap, updateColorScheme, updateCustomColors, updateGridSize, updateModuleEnabled, updateSong } from "$/store/actions"; import { selectBeatmapIdsWithLightshowId, selectBpm, selectDuration, selectEditorOffsetInBeats, selectLightshowIdForBeatmap, selectSelectedBeatmap, selectSongById } from "$/store/selectors"; -import type { AppDispatch, RootState } from "$/store/types"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import type { SongId } from "$/types"; import { deepAssign } from "$/utils"; -/** This middleware manages file storage concerns. */ -export default function createFileMiddleware() { - const instance = createListenerMiddleware(); +interface Options { + extra: Pick; +} - const filestore = getAppBeatmapFilestore(); +/** This middleware manages file storage concerns. */ +export default function createFileMiddleware({ extra }: Options) { + const instance = createListenerMiddleware({ extra }); instance.startListening({ actionCreator: rehydrate, @@ -28,8 +29,10 @@ export default function createFileMiddleware() { actionCreator: startLoadingMap, effect: async (action, api) => { const { songId, beatmapId } = action.payload; + const state = api.getState(); + const filestore = api.extra.getFilestore(); // fetch the metadata for this beatmap from our local store const beatmap = await filestore.loadBeatmapContents(songId, beatmapId).then((data) => createBeatmap(data)); // pull the lightshow data from any beatmap with a matching lightshow id @@ -54,6 +57,7 @@ export default function createFileMiddleware() { const { songId, songFile } = action.payload; if (finishLoadingMap.match(action) || songFile) { + const filestore = api.extra.getFilestore(); const activeSongFile = songFile ?? (await filestore.loadSongFile(songId)); await deriveWaveformDataFromFile(activeSongFile).then((waveformData) => { @@ -73,6 +77,8 @@ export default function createFileMiddleware() { }); const audioDataContents = await createAudioDataContentsFromFile(songFile, { bpm: selectBpm(state, songId) }); + const filestore = api.extra.getFilestore(); + await Promise.all([ filestore.saveSongFile(songId, songFile), filestore.saveCoverArtFile(songId, coverArtFile), @@ -92,8 +98,11 @@ export default function createFileMiddleware() { }); instance.startListening({ actionCreator: removeSong, - effect: async (action) => { + effect: async (action, api) => { const { songId, beatmapIds } = action.payload; + + const filestore = api.extra.getFilestore(); + await filestore.removeAllFilesForSong(songId, beatmapIds); }, }); @@ -101,9 +110,12 @@ export default function createFileMiddleware() { actionCreator: addBeatmap, effect: async (action, api) => { const { songId, beatmapId } = action.payload; - const state = api.getState(); + const state = api.getState(); + // pull the lightshow id from the source const lightshowId = selectLightshowIdForBeatmap(state, songId, beatmapId); + + const filestore = api.extra.getFilestore(); // grab the implicit version from the currently selected beatmap, so we can keep all related files on the same version const version = await filestore.loadImplicitVersion(songId, selectSelectedBeatmap(state, songId)); // save it to our destination file @@ -124,10 +136,12 @@ export default function createFileMiddleware() { const { songId, sourceBeatmapId, targetBeatmapId } = action.payload; const state = api.getState(); - // grab the implicit version and all related collections from the source data - const { version, difficulty, lightshow, customData } = await filestore.loadBeatmapContents(songId, sourceBeatmapId); // pull the lightshow id from the source (since we're deriving that data anyway) const lightshowId = selectLightshowIdForBeatmap(state, songId, sourceBeatmapId); + + const filestore = api.extra.getFilestore(); + // grab the implicit version and all related collections from the source data + const { version, difficulty, lightshow, customData } = await filestore.loadBeatmapContents(songId, sourceBeatmapId); // save it to our destination await filestore.saveBeatmapContents( songId, @@ -147,12 +161,15 @@ export default function createFileMiddleware() { actionCreator: updateBeatmap, effect: async (action, api) => { const { songId, beatmapId, changes } = action.payload; + const state = api.getState(); if ("lightshowId" in changes) { const lightshowId = selectLightshowIdForBeatmap(state, songId, beatmapId); // reference a beatmap that has the same lightshow id as the one provided via form data const derivedBeatmapId = selectBeatmapIdsWithLightshowId(state, songId, lightshowId)[0]; + + const filestore = api.extra.getFilestore(); // extract the lightshow data from the derived beatmap file const { lightshow } = await filestore.loadBeatmapContents(songId, derivedBeatmapId); // update the beatmap with the new lightshow data @@ -166,8 +183,10 @@ export default function createFileMiddleware() { }); instance.startListening({ actionCreator: removeBeatmap, - effect: async (action) => { + effect: async (action, api) => { const { songId, beatmapId } = action.payload; + + const filestore = api.extra.getFilestore(); // Our reducer will handle the redux state part, but we also need to delete the corresponding beatmap from the filesystem. await filestore.removeFile(BeatmapFilestore.resolveFilename(songId, "beatmap", { id: beatmapId })); }, @@ -182,6 +201,8 @@ export default function createFileMiddleware() { const infoContents = serializeInfoContents(selectSongById(state, songId), { songDuration: selectDuration(state), }); + + const filestore = api.extra.getFilestore(); // Back up our latest data! await filestore.updateInfoContents(songId, infoContents); }, diff --git a/src/store/middleware/history.middleware.ts b/src/store/middleware/history.middleware.ts index 1d46959c..a87bae1b 100644 --- a/src/store/middleware/history.middleware.ts +++ b/src/store/middleware/history.middleware.ts @@ -4,8 +4,8 @@ import { sortObjectFn } from "bsmap"; import { resolveEventId } from "$/helpers/events.helpers"; import { resolveNoteId } from "$/helpers/notes.helpers"; import { resolveObstacleId } from "$/helpers/obstacles.helpers"; -import { getRouter } from "$/router"; import { clearEventHistory, clearObjectHistory, jumpToBeat, leaveEditor, redoEvents, redoObjects, undoEvents, undoObjects } from "$/store/actions"; +import { selectActiveSongId } from "$/store/helpers/route.helpers"; import { selectAllBasicEvents, selectAllBombNotes, @@ -26,7 +26,6 @@ import { import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import type { App, SongId } from "$/types"; import { difference } from "$/utils"; -import { selectActiveSongId } from "../helpers/route.helpers"; function jumpToEarliestObject(api: ListenerEffectAPI, songId: SongId, args: { [K in "notes" | "bombs" | "obstacles"]: { before: App.IBeatmapEntities[K]; after: App.IBeatmapEntities[K] } }) { const relevantNotes = difference(args.notes.before, args.notes.after, resolveNoteId); @@ -49,15 +48,13 @@ function jumpToEarliestEvent(api: ListenerEffectAPI, son api.dispatch(jumpToBeat({ songId, value: earliestBeat, pauseTrack: true, animateJump: true })); } -/** - * I use redux-undo to manage undo/redo stuff, but this comes with one limitation: I want to scroll the user to the right place, when undoing/redoing. - * - * This middleware listens for undo events, and handles updating the cursor position in response to these actions. - */ -export default function createHistoryMiddleware() { - const instance = createListenerMiddleware({ - extra: { getRouter }, - }); +interface Options { + extra: Pick; +} + +/** This middleware listens for undo events, and handles updating the cursor position in response to these actions. */ +export default function createHistoryMiddleware({ extra }: Options) { + const instance = createListenerMiddleware({ extra }); instance.startListening({ actionCreator: leaveEditor, diff --git a/src/store/middleware/packaging.middleware.ts b/src/store/middleware/packaging.middleware.ts index 39b2d60e..eddac040 100644 --- a/src/store/middleware/packaging.middleware.ts +++ b/src/store/middleware/packaging.middleware.ts @@ -2,15 +2,16 @@ import { createListenerMiddleware } from "@reduxjs/toolkit"; import { saveAs } from "file-saver"; import { exportMapArchiveFromFilestore } from "$/services/packaging.service"; -import { getAppToaster } from "$/setup"; import { downloadMapFiles } from "$/store/actions"; import { selectSongById } from "$/store/selectors"; -import type { AppDispatch, RootState } from "$/store/types"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; -export default function createPackagingMiddleware() { - const instance = createListenerMiddleware(); +interface Options { + extra: Pick; +} - const toaster = getAppToaster(); +export default function createPackagingMiddleware({ extra }: Options) { + const instance = createListenerMiddleware({ extra }); instance.startListening({ actionCreator: downloadMapFiles, @@ -22,6 +23,7 @@ export default function createPackagingMiddleware() { try { saveAs(await exportMapArchiveFromFilestore(song, options)); } catch (error) { + const toaster = api.extra.getToaster(); toaster?.error({ description: `Could not export map: ${error instanceof Error ? error.message : "See console for more info."}` }); return console.error(error); } diff --git a/src/store/middleware/playback.middleware.ts b/src/store/middleware/playback.middleware.ts index 83e2eb3d..65a65ab6 100644 --- a/src/store/middleware/playback.middleware.ts +++ b/src/store/middleware/playback.middleware.ts @@ -2,23 +2,22 @@ import { createListenerMiddleware, isAnyOf, type PayloadAction } from "@reduxjs/ import { TimeProcessor } from "bsmap"; import { createBpmEventsFromAudioData } from "$/helpers/audio.helpers"; -import { getRouter } from "$/router"; import type { AudioSample } from "$/services/audio.service"; -import { getAppBeatmapFilestore } from "$/setup"; import { finishLoadingMap, jumpToBeat, jumpToEnd, jumpToStart, jumpToTime, pausePlayback, scrollThroughSong, seekBackwards, seekForwards, startPlayback, stopPlayback, tick, togglePlayback, updateCursorPosition, updateSong, updateTimescale } from "$/store/actions"; +import { selectActiveView } from "$/store/helpers/route.helpers"; import { selectBeatForTime, selectBpm, selectCursorPosition, selectDuration, selectEventsEditorBeatsPerZoomLevel, selectEventsEditorWindowLock, selectPlaying, selectSelectedBeatmap, selectSnap, selectTimeForBeat } from "$/store/selectors"; import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import { type SongId, View } from "$/types"; import { floorToNearest } from "$/utils"; -import { selectActiveView } from "../helpers/route.helpers"; -/** Manages all concerns related to audio playback and timescales. */ -export default function createPlaybackMiddleware({ songSample }: { songSample: AudioSample }) { - const instance = createListenerMiddleware({ - extra: { getRouter }, - }); +interface Options { + songSample: AudioSample; + extra: Pick; +} - const filestore = getAppBeatmapFilestore(); +/** Manages all concerns related to audio playback and timescales. */ +export default function createPlaybackMiddleware({ songSample, extra }: Options) { + const instance = createListenerMiddleware({ extra }); let animationFrameId: number; @@ -32,6 +31,8 @@ export default function createPlaybackMiddleware({ songSample }: { songSample: A api.dispatch(stopPlayback({ songId })); } + const filestore = api.extra.getFilestore(); + await Promise.all([filestore.loadAudioDataContents(songId), filestore.loadBeatmapContents(songId, selectSelectedBeatmap(state, songId))]).then(([audioData, { difficulty }]) => { const { timescale } = new TimeProcessor(selectBpm(state, songId), [...createBpmEventsFromAudioData(audioData), ...difficulty.bpmEvents], 0); api.dispatch(updateTimescale({ timescale: timescale })); diff --git a/src/store/types.ts b/src/store/types.ts index 4b833b25..336c3d9d 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,10 +1,14 @@ +import type { CreateToasterReturn } from "@ark-ui/react/toast"; import type { ThunkDispatch, UnknownAction } from "@reduxjs/toolkit"; +import type { AnyRouter } from "@tanstack/react-router"; -import type { getRouter } from "$/router"; +import type { BeatmapFilestore } from "$/services/file.service"; import type { default as reducer } from "./features/_setup"; export interface AppExtraArgs { - getRouter: typeof getRouter; + getRouter: () => AnyRouter; + getFilestore: () => BeatmapFilestore; + getToaster: () => CreateToasterReturn | null; } export type RootState = ReturnType; From 33230f43d861f4b2bd7a11943b5bb61cbb492e1e Mon Sep 17 00:00:00 2001 From: officialMECH Date: Sat, 8 Aug 2026 13:58:52 -0500 Subject: [PATCH 16/27] rework singleton apis to decouple setup modules --- src/{setup.ts => _setup.ts} | 30 ++++----- src/components/app/forms/create-beatmap.tsx | 7 +- src/components/app/forms/create-map.tsx | 6 +- src/components/app/forms/export-map.tsx | 6 +- src/components/app/forms/import-map.tsx | 6 +- src/components/app/forms/update-beatmap.tsx | 10 +-- src/components/app/forms/update-song.tsx | 11 ++-- src/components/app/hooks/local-file.hooks.ts | 7 +- .../app/templates/shortcuts/default.tsx | 12 ++-- .../app/templates/tables/songs/actions.tsx | 8 +-- src/components/context.ts | 10 +-- src/components/ui/compositions/audio.tsx | 5 +- .../ui/compositions/file-upload.tsx | 8 +-- src/helpers/audio.helpers.ts | 13 ++-- src/index.tsx | 66 ++++++++++++------- src/router.ts | 20 ------ src/routes/converter.tsx | 11 ++-- src/routes/editor/layout.tsx | 29 ++++---- src/routes/editor/scene/preview.tsx | 5 +- src/routes/root.tsx | 15 ++++- src/services/audio.service.ts | 6 +- src/services/packaging.service.ts | 16 ++--- src/store/_setup.ts | 3 - src/store/features/songs.slice.ts | 4 +- src/store/middleware/_setup.ts | 6 +- src/store/middleware/audio.middleware.ts | 19 +++--- src/store/middleware/file.middleware.ts | 6 +- src/store/middleware/packaging.middleware.ts | 5 +- src/store/selectors.ts | 6 -- src/store/types.ts | 5 +- src/utils/function.utils.ts | 39 +++++++---- 31 files changed, 199 insertions(+), 201 deletions(-) rename src/{setup.ts => _setup.ts} (71%) delete mode 100644 src/router.ts diff --git a/src/setup.ts b/src/_setup.ts similarity index 71% rename from src/setup.ts rename to src/_setup.ts index 348b00d5..101e56ba 100644 --- a/src/setup.ts +++ b/src/_setup.ts @@ -2,19 +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 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) { @@ -74,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 { getAppStore, setupAppStore } from "./store/_setup"; +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 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..aa3c60e7 100644 --- a/src/components/app/forms/update-song.tsx +++ b/src/components/app/forms/update-song.tsx @@ -5,7 +5,7 @@ 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 { useBeatmapFilestore, 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"; @@ -39,7 +39,8 @@ const SCHEMA = object({ function UpdateSongForm() { const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); - const { filestore, toaster } = useSetupContext(); + const filestore = useBeatmapFilestore(); + const toaster = useToaster(); const dispatch = useAppDispatch(); const song = useAppSelector((state) => selectSongById(state, sid)); @@ -56,12 +57,12 @@ 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!" }); }, }); 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!" }); }, }); @@ -102,7 +103,7 @@ function UpdateSongForm() { 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/templates/shortcuts/default.tsx b/src/components/app/templates/shortcuts/default.tsx index 76a91950..2c51f0a4 100644 --- a/src/components/app/templates/shortcuts/default.tsx +++ b/src/components/app/templates/shortcuts/default.tsx @@ -3,7 +3,7 @@ import { useParams, useRouteContext } from "@tanstack/react-router"; import { useCallback, useRef } from "react"; import { createAddBookmarkPrompt, createJumpToBeatPrompt, createQuickSelectPrompt } from "$/components/app/constants"; -import { useSetupContext } from "$/components/context"; +import { useToaster } from "$/components/context"; import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; import { usePrompt, usePrompter } from "$/components/ui/compositions"; import { SNAPPING_INCREMENTS } from "$/constants"; @@ -48,7 +48,7 @@ function DefaultEditorShortcuts() { const { sid, bid } = useParams({ from: "/_/edit/$sid/$bid/_" }); const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); - const { toaster } = useSetupContext(); + const toaster = useToaster(); const dispatch = useAppDispatch(); const isLoading = useAppSelector(selectLoading); @@ -215,10 +215,10 @@ function DefaultEditorShortcuts() { case "KeyZ": { if (!metaKeyPressed) return; if (view === View.BEATMAP) { - return dispatch(ev.shiftKey ? redoObjects({ songId: sid }) : undoObjects({ songId: sid })); + return dispatch((ev.shiftKey ? redoObjects : undoObjects)()); } if (view === View.LIGHTSHOW) { - return dispatch(ev.shiftKey ? redoEvents({ songId: sid }) : undoEvents({ songId: sid })); + return dispatch((ev.shiftKey ? redoEvents : undoEvents)()); } return; } @@ -231,7 +231,7 @@ function DefaultEditorShortcuts() { if (!metaKeyPressed) return; ev.preventDefault(); if (import.meta.env.PROD && isDemo) { - return toaster?.create({ + return toaster.create({ id: "demo-download-blocker", type: "info", description: "Unfortunately, the demo map is not available for download.", @@ -249,7 +249,7 @@ function DefaultEditorShortcuts() { } } }, - [isLoading, view, dispatch, toaster, sid, bid, isDemo, handleScroll, isPromptActive, triggerQuickSelect, triggerJumpToBeat, triggerAddBookmark], + [dispatch, toaster, sid, bid, view, isLoading, isDemo, handleScroll, isPromptActive, triggerQuickSelect, triggerJumpToBeat, triggerAddBookmark], ); const handleKeyUp = useCallback( diff --git a/src/components/app/templates/tables/songs/actions.tsx b/src/components/app/templates/tables/songs/actions.tsx index 7fbf0b66..95b433f0 100644 --- a/src/components/app/templates/tables/songs/actions.tsx +++ b/src/components/app/templates/tables/songs/actions.tsx @@ -4,7 +4,7 @@ import type { MenuSelectionDetails } from "@ark-ui/react/menu"; import { toPascalCase } from "@std/text/to-pascal-case"; import { Fragment, useCallback } from "react"; -import { useSetupContext } from "$/components/context"; +import { useToaster } from "$/components/context"; import { AlertDialogProvider, Button, Menu } from "$/components/ui/compositions"; import { downloadMapFiles, removeSong } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; @@ -16,11 +16,11 @@ interface Props { sid: SongId; } function SongsDataTableActions({ sid }: Props) { - const { toaster } = useSetupContext(); + const toaster = useToaster(); + const dispatch = useAppDispatch(); const isDemo = useAppSelector((state) => selectDemo(state, sid)); const beatmapIds = useAppSelector((state) => selectBeatmapIds(state, sid)); - const dispatch = useAppDispatch(); const { collection: ACTION_LIST_COLLECTION } = useListCollection({ initialItems: ["download", "delete"], @@ -44,7 +44,7 @@ function SongsDataTableActions({ sid }: Props) { return dispatch(downloadMapFiles({ songId: sid, version: null })); } default: { - return toaster?.create({ + return toaster.create({ id: `song-action.${details.value}`, description: "This feature does not exist yet. Sorry! Coming soon.", }); diff --git a/src/components/context.ts b/src/components/context.ts index b7d1cc32..f0d2c6d2 100644 --- a/src/components/context.ts +++ b/src/components/context.ts @@ -1,8 +1,8 @@ +import type { CreateToasterReturn } from "@ark-ui/react/toast"; import { createContext } from "@ark-ui/react/utils"; -import { getAppBeatmapFilestore, getAppToaster, setupAppBeatmapFilestore, setupAppToaster } from "$/setup"; +import type { BeatmapFilestore } from "$/services/file.service"; -setupAppBeatmapFilestore(); -setupAppToaster(); - -export const [SetupProvider, useSetupContext] = createContext({ defaultValue: { filestore: getAppBeatmapFilestore(), toaster: getAppToaster() } }); +export const [BeatmapFilestoreProvider, useBeatmapFilestore] = createContext(); +export const [ToasterProvider, useToaster] = createContext(); +export const [AudioContextProvider, useAudioContext] = createContext(); diff --git a/src/components/ui/compositions/audio.tsx b/src/components/ui/compositions/audio.tsx index ccbcef6f..37b74329 100644 --- a/src/components/ui/compositions/audio.tsx +++ b/src/components/ui/compositions/audio.tsx @@ -1,6 +1,7 @@ import { PlayIcon, SquareIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useAudioContext } from "$/components/context"; import { Button } from "$/components/ui/compositions"; import { AudioSample } from "$/services/audio.service"; import { styled } from "$:styled-system/jsx"; @@ -14,11 +15,13 @@ interface Props { } export function AudioPreview({ file, startTime = 0, duration, volume }: Props) { + const audioContext = useAudioContext(); + const [isLoaded, setIsLoaded] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [progress, setProgress] = useState(0); - const audio = useMemo(() => new AudioSample({ volume }), [volume]); + const audio = useMemo(() => new AudioSample(audioContext, { volume }), [audioContext, volume]); const requestRef = useRef(null); const getEffectiveDuration = () => { diff --git a/src/components/ui/compositions/file-upload.tsx b/src/components/ui/compositions/file-upload.tsx index 0c51060b..b1c523a5 100644 --- a/src/components/ui/compositions/file-upload.tsx +++ b/src/components/ui/compositions/file-upload.tsx @@ -4,7 +4,7 @@ import type { FileMimeType } from "@zag-js/file-utils"; import { FileArchiveIcon, FileAudioIcon, FileIcon, FileImageIcon, FileTextIcon, type LucideProps, TrashIcon } from "lucide-react"; import { type ComponentProps, Fragment, forwardRef, useEffect, useMemo } from "react"; -import { useSetupContext } from "$/components/context"; +import { useToaster } from "$/components/context"; import { For, Show } from "$/components/ui/atoms"; import { useFieldData } from "$/components/ui/hooks/form.hooks"; import { type ComposableFn, useComposable } from "$/components/ui/hooks/use-composable"; @@ -52,10 +52,10 @@ interface ListComposableProps { } function List({ accept, deletable, children }: FileUploadProps & ListComposableProps) { - const { toaster } = useSetupContext(); - const api = useFileUploadContext(); + const toaster = useToaster(); + const ItemDeleteTrigger = useRender( Builder.ItemDeleteTrigger, toPolymorphic(Button, (Element, delegated) => ), @@ -76,7 +76,7 @@ function List({ accept, deletable, children }: FileUploadProps & ListComposableP break; } } - return toaster?.error({ id: errors[0], description: message }); + return toaster.error({ id: errors[0], description: message }); } }, [toaster, api.rejectedFiles, accept]); diff --git a/src/helpers/audio.helpers.ts b/src/helpers/audio.helpers.ts index 2aed30a2..c266a010 100644 --- a/src/helpers/audio.helpers.ts +++ b/src/helpers/audio.helpers.ts @@ -2,21 +2,16 @@ import { distinct } from "@std/collections/distinct"; import { createAudioData, createBPMEvent, type IWrapAudioData, type IWrapAudioDataBPM, type IWrapBPMEvent, type IWrapDifficulty, sortObjectFn } from "bsmap"; import { default as WaveformData } from "waveform-data"; -import { getAudioContext } from "$/setup"; import { convertFileToArrayBuffer } from "./file.helpers"; -export async function deriveAudioDataFromFile(file: Blob | MediaSource) { - const audioContext = getAudioContext(); - +export async function deriveAudioDataFromFile(file: Blob | MediaSource, audioContext: AudioContext) { const arrayBuffer = await convertFileToArrayBuffer(file); return await audioContext.decodeAudioData(arrayBuffer).then((audioBuffer) => { return { duration: audioBuffer.duration, frequency: audioBuffer.sampleRate, sampleCount: audioBuffer.length }; }); } -export async function deriveWaveformDataFromFile(file: Blob | MediaSource) { - const audioContext = getAudioContext(); - +export async function deriveWaveformDataFromFile(file: Blob | MediaSource, audioContext: AudioContext) { const arrayBuffer = await convertFileToArrayBuffer(file); const audioBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0)); @@ -32,8 +27,8 @@ export async function deriveWaveformDataFromFile(file: Blob | MediaSource) { ); } -export async function createAudioDataContentsFromFile(songFile: File, options: { bpm: number; version?: number }): Promise { - const { duration, frequency, sampleCount } = await deriveAudioDataFromFile(songFile); +export async function createAudioDataContentsFromFile(songFile: File, audioContext: AudioContext, options: { bpm: number; version?: number }): Promise { + const { duration, frequency, sampleCount } = await deriveAudioDataFromFile(songFile, audioContext); // map will not load properly in-game if there isn't at least one bpm change defined. we call this peak stupid. const region: IWrapAudioDataBPM = { diff --git a/src/index.tsx b/src/index.tsx index b5a6344a..28cd8edd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,18 +1,42 @@ import { registerSW } from "virtual:pwa-register"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { RouterProvider } from "@tanstack/react-router"; +import { createRouter, RouterProvider } from "@tanstack/react-router"; +import { setupLogger } from "bsmap"; import { createRoot } from "react-dom/client"; import { Provider } from "react-redux"; -import { SetupProvider } from "./components/context"; -import { getRouter, setupRouter } from "./router"; -import { getAppBeatmapFilestore, getAppStore, getAppToaster, setupAppStore, setupAudioContext } from "./setup"; +import { AppAudioContext, AppFilestore, AppStore, AppToaster, createAppFileStorageDriver } from "./_setup"; +import ErrorBoundary from "./components/app/templates/error-boundary"; +import PendingBoundary from "./components/app/templates/pending-boundary"; +import { AudioContextProvider, BeatmapFilestoreProvider, ToasterProvider } from "./components/context"; +import { routeTree } from "./routeTree.gen"; import "./index.css"; -const router = setupRouter(); -setupAudioContext(); -setupAppStore({ extraArgument: { getRouter: getRouter, getFilestore: getAppBeatmapFilestore, getToaster: getAppToaster } }); +if (import.meta.env.DEV) { + setupLogger(); +} + +const router = createRouter({ + routeTree: routeTree, + defaultPendingComponent: PendingBoundary, + defaultErrorComponent: ErrorBoundary, +}); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +await AppStore.setup({ + extraArgument: { + getRouter: () => router, + getFilestore: AppFilestore.setup({ driver: createAppFileStorageDriver({ name: "entries" }) }), + getToaster: AppToaster.setup({ placement: "bottom-end", overlap: true, max: 8 }), + getAudioContext: AppAudioContext.setup({}), + }, +}); const root = document.getElementById("root"); @@ -20,23 +44,23 @@ if (!root) { throw new Error("No root element."); } -const store = await getAppStore(); - createRoot(root).render( - - - - - - - , + + + + + + + + + + + , ); const updateSW = registerSW({ onNeedRefresh() { - const toaster = getAppToaster(); - - return toaster?.create({ + return AppToaster.instance.create({ id: "pwa-update", type: "loading", title: "New Update Available", @@ -51,9 +75,7 @@ const updateSW = registerSW({ }); }, onOfflineReady() { - const toaster = getAppToaster(); - - return toaster?.create({ + return AppToaster.instance.create({ id: "offline-ready", type: "info", description: "Offline mode is ready! You can now use Beatmapper without an internet connection.", diff --git a/src/router.ts b/src/router.ts deleted file mode 100644 index 7cb79480..00000000 --- a/src/router.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createRouter } from "@tanstack/react-router"; - -import ErrorBoundary from "./components/app/templates/error-boundary"; -import PendingBoundary from "./components/app/templates/pending-boundary"; -import { routeTree } from "./routeTree.gen"; -import { createLazySingleton } from "./utils"; - -export const { get: getRouter, setup: setupRouter } = createLazySingleton(() => { - return createRouter({ - routeTree: routeTree, - defaultPendingComponent: PendingBoundary, - defaultErrorComponent: ErrorBoundary, - }); -}); - -declare module "@tanstack/react-router" { - interface Register { - router: ReturnType; - } -} diff --git a/src/routes/converter.tsx b/src/routes/converter.tsx index e3f1fec8..3bfce229 100644 --- a/src/routes/converter.tsx +++ b/src/routes/converter.tsx @@ -5,7 +5,7 @@ import { Fragment, useState } from "react"; import { ExportMapForm, ImportMapForm } from "$/components/app/forms"; import { Page } from "$/components/app/layouts"; -import { useSetupContext } from "$/components/context"; +import { useAudioContext, useToaster } from "$/components/context"; import { Heading } from "$/components/ui/compositions"; import { type ExportMapArchiveOptions, exportMapArchive, importMapArchive, type MapArchiveContents } from "$/services/packaging.service"; import { Stack } from "$:styled-system/jsx"; @@ -18,7 +18,8 @@ export const Route = createFileRoute("/convert")({ }); function RouteComponent() { - const { toaster } = useSetupContext(); + const toaster = useToaster(); + const audioContext = useAudioContext(); const [allContents, setContents] = useState([]); @@ -27,12 +28,12 @@ function RouteComponent() { setContents([]); for (const file of files) { const archive = await file.arrayBuffer(); - const content = await importMapArchive(new Uint8Array(archive), {}); + const content = await importMapArchive(new Uint8Array(archive), audioContext, {}); setContents((contents) => contents.concat(content)); } }, onError: (error) => { - toaster?.error({ description: `Could not import map: ${error instanceof Error ? error.message : "See console for more info."}` }); + toaster.error({ description: `Could not import map: ${error instanceof Error ? error.message : "See console for more info."}` }); return console.error(error); }, }); @@ -47,7 +48,7 @@ function RouteComponent() { } }, onError: (error) => { - toaster?.error({ description: `Could not export map: ${error instanceof Error ? error.message : "See console for more info."}` }); + toaster.error({ description: `Could not export map: ${error instanceof Error ? error.message : "See console for more info."}` }); return console.error(error); }, }); diff --git a/src/routes/editor/layout.tsx b/src/routes/editor/layout.tsx index 19d4da23..975c4b87 100644 --- a/src/routes/editor/layout.tsx +++ b/src/routes/editor/layout.tsx @@ -4,10 +4,10 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; import type { MDXComponents } from "mdx/types"; import { forwardRef } from "react"; +import { AppStore } from "$/_setup"; import { EditorSidebar } from "$/components/app/templates/editor"; import { MDX } from "$/components/ui/atoms"; import { AnchorLink, List, Prompter, Shortcut, Toaster } from "$/components/ui/compositions"; -import { getAppStore } from "$/setup"; import { leaveEditor, startLoadingMap, stopPlayback, updateAnnouncements, updateCursorPosition } from "$/store/actions"; import { selectAnnouncements, selectBeatmapEntities, selectEditorOffset } from "$/store/selectors"; import type { View } from "$/types"; @@ -30,25 +30,22 @@ const EDITOR_PROMPT_COMPONENTS: MDXComponents = { let lastParams: { sid: string; bid: string } | null = null; async function syncEditorLifecycle(cause: "enter" | "stay" | "leave", params: { sid: string; bid: string }) { - const store = await getAppStore(); - const onEnter = async () => { - const state = store.getState(); + const state = AppStore.instance.getState(); - await Promise.resolve(store.dispatch(startLoadingMap({ songId: params.sid, beatmapId: params.bid }))); + await Promise.resolve(AppStore.instance.dispatch(startLoadingMap({ songId: params.sid, beatmapId: params.bid }))); if (cause !== "stay") { - store.dispatch(updateCursorPosition({ value: selectEditorOffset(state, params.sid) })); + AppStore.instance.dispatch(updateCursorPosition({ value: selectEditorOffset(state, params.sid) })); } lastParams = params; }; const onLeave = async () => { - const store = await getAppStore(); - const state = store.getState(); + const state = AppStore.instance.getState(); if (lastParams) { - store.dispatch(leaveEditor({ songId: lastParams.sid, beatmapId: lastParams.bid, entities: selectBeatmapEntities(state) })); + AppStore.instance.dispatch(leaveEditor({ songId: lastParams.sid, beatmapId: lastParams.bid, entities: selectBeatmapEntities(state) })); } }; @@ -77,13 +74,11 @@ export const Route = createFileRoute("/_/edit/$sid/$bid/_")({ }; }, loader: async ({ context }) => { - const store = await getAppStore(); - const state = store.getState(); - const seenPrompts = selectAnnouncements(state); + const announcements = selectAnnouncements(AppStore.instance.getState()); return { view: toPascalCase(context.view), - unseenPrompt: prompts.find((prompt) => !seenPrompts.includes(prompt.id)), + unseenPrompt: prompts.find((prompt) => !announcements.includes(prompt.id)), }; }, head: ({ params, loaderData }) => { @@ -96,8 +91,7 @@ export const Route = createFileRoute("/_/edit/$sid/$bid/_")({ const { unseenPrompt } = loaderData; if (unseenPrompt) { - const store = await getAppStore(); - const announcements = selectAnnouncements(store.getState()); + const announcements = selectAnnouncements(AppStore.instance.getState()); EDITOR_TOASTER.create({ id: unseenPrompt.id, @@ -107,7 +101,7 @@ export const Route = createFileRoute("/_/edit/$sid/$bid/_")({ closable: true, onStatusChange: async (details) => { if (details.status === "dismissing") { - store.dispatch(updateAnnouncements(announcements.concat(unseenPrompt.id))); + AppStore.instance.dispatch(updateAnnouncements(announcements.concat(unseenPrompt.id))); } }, }); @@ -120,8 +114,7 @@ export const Route = createFileRoute("/_/edit/$sid/$bid/_")({ onLeave: async ({ params }) => { syncEditorLifecycle("leave", params); - const store = await getAppStore(); - store.dispatch(stopPlayback({ songId: params.sid })); + AppStore.instance.dispatch(stopPlayback({ songId: params.sid })); }, }); diff --git a/src/routes/editor/scene/preview.tsx b/src/routes/editor/scene/preview.tsx index 10e53550..e25ae907 100644 --- a/src/routes/editor/scene/preview.tsx +++ b/src/routes/editor/scene/preview.tsx @@ -2,19 +2,18 @@ import { createFileRoute } from "@tanstack/react-router"; import { NoteJumpSpeed } from "bsmap"; import { Fragment } from "react/jsx-runtime"; +import { AppStore } from "$/_setup"; import { ReduxForwardingCanvas } from "$/components/scene/atoms"; import { SURFACE_WIDTH } from "$/components/scene/constants"; import DefaultEnvironment from "$/components/scene/templates/environment"; import MapVisualization from "$/components/scene/templates/visualization"; -import { getAppStore } from "$/setup"; import { useAppSelector } from "$/store/hooks"; import { selectBpm, selectJumpOffset, selectJumpSpeed, selectTimeProcessor } from "$/store/selectors"; export const Route = createFileRoute("/_/edit/$sid/$bid/_/_scene/preview")({ component: RouteComponent, loader: async ({ params }) => { - const store = await getAppStore(); - const state = store.getState(); + const state = AppStore.instance.getState(); const bpm = selectBpm(state, params.sid); const jumpSpeed = selectJumpSpeed(state, params.sid, params.bid); diff --git a/src/routes/root.tsx b/src/routes/root.tsx index 5b73b42e..b6ad6c69 100644 --- a/src/routes/root.tsx +++ b/src/routes/root.tsx @@ -2,9 +2,12 @@ import { createRootRoute, HeadContent, Outlet } from "@tanstack/react-router"; import { getYear, isToday, setYear } from "date-fns"; import { Fragment } from "react"; -import { useSetupContext } from "$/components/context"; +import PendingBoundary from "$/components/app/templates/pending-boundary"; +import { useToaster } from "$/components/context"; import Devtools from "$/components/devtools"; import { Toaster } from "$/components/ui/compositions"; +import { useAppSelector } from "$/store/hooks"; +import { selectInitialized } from "$/store/selectors"; export const Route = createRootRoute({ component: RootComponent, @@ -21,13 +24,19 @@ export const Route = createRootRoute({ }); function RootComponent() { - const { toaster } = useSetupContext(); + const toaster = useToaster(); + + const isInitialized = useAppSelector(selectInitialized); + + if (!isInitialized) { + return ; + } return ( - {toaster && } + ); diff --git a/src/services/audio.service.ts b/src/services/audio.service.ts index c1cf51aa..e178b6d8 100644 --- a/src/services/audio.service.ts +++ b/src/services/audio.service.ts @@ -1,5 +1,3 @@ -import { getAudioContext } from "$/setup"; - export interface AudioSampleOptions { url?: string; volume?: number; @@ -21,11 +19,11 @@ export class AudioSample { source!: AudioBufferSourceNode; buffer!: AudioBuffer; - constructor({ volume = 1, playbackRate = 1 }: AudioSampleOptions) { + constructor(audioContext: AudioContext, { volume = 1, playbackRate = 1 }: AudioSampleOptions) { this.gain = volume; this.playbackRate = playbackRate; - this.context = getAudioContext(); + this.context = audioContext; // Audio contexts have an always-incrementing `currentTime` ticker. // When we start the file, we might be 20 seconds into that process, so we'll store the currentTime position that the audio started playing. diff --git a/src/services/packaging.service.ts b/src/services/packaging.service.ts index 1933ff14..b9911bb0 100644 --- a/src/services/packaging.service.ts +++ b/src/services/packaging.service.ts @@ -28,9 +28,9 @@ import { createAudioDataContentsFromFile, createBpmDataFromDifficulty, createBpm import { createPlaceholderImageFile } from "$/helpers/file.helpers"; import { deserializeInfoContents, resolveBeatmapIdFromFilename } from "$/helpers/packaging.helpers"; import { createSongId, resolveSongId } from "$/helpers/song.helpers"; -import { getAppBeatmapFilestore } from "$/setup"; import type { App, SongId } from "$/types"; import { deepAssign, ensureArray, yieldValue } from "$/utils"; +import type { BeatmapFilestore } from "./file.service"; const decoder = new TextDecoder("utf-8"); const encoder = new TextEncoder(); @@ -72,7 +72,7 @@ export interface ImportMapArchiveOptions { loadOptions?: Omit, "preprocess" | "postprocess">; } -export async function importMapArchive(archive: Uint8Array, { loadOptions }: ImportMapArchiveOptions): Promise { +export async function importMapArchive(archive: Uint8Array, audioContext: AudioContext, { loadOptions }: ImportMapArchiveOptions): Promise { const unzipped = await new Promise((resolve, reject) => unzip(archive, (err, data) => { if (err) return reject(err); @@ -132,15 +132,15 @@ export async function importMapArchive(archive: Uint8Array, { loadOptions }: Imp return loadAudioData(JSON.parse(decoder.decode(data)), null, loadOptions); }) .catch(async () => { - const { frequency, bpmData, ...rest } = await createAudioDataContentsFromFile(songFile, { version: info.version, bpm: info.audio.bpm }); + const { frequency, bpmData, ...rest } = await createAudioDataContentsFromFile(songFile, audioContext, { version: info.version, bpm: info.audio.bpm }); return createAudioData({ ...rest, frequency, bpmData: createBpmDataFromDifficulty(beatmaps[0].difficulty, frequency, bpmData[0].endBeat) }); }); return { info, audioData, beatmaps, songFile, coverArtFile }; } -export async function importMapArchiveToFilestore(archive: Uint8Array, { currentSongIds = [], readonly, ...options }: ImportMapArchiveOptions & { currentSongIds?: SongId[] } & Parameters[1]): Promise { - const { songFile, coverArtFile, info, audioData, beatmaps } = await importMapArchive(archive, options); +export async function importMapArchiveToFilestore(archive: Uint8Array, audioContext: AudioContext, filestore: BeatmapFilestore, { currentSongIds = [], readonly, ...options }: ImportMapArchiveOptions & { currentSongIds?: SongId[] } & Parameters[1]): Promise { + const { songFile, coverArtFile, info, audioData, beatmaps } = await importMapArchive(archive, audioContext, options); const song = deserializeInfoContents(info, { readonly }); @@ -151,8 +151,6 @@ export async function importMapArchiveToFilestore(archive: Uint8Array, { current return acc; }, {}); - const filestore = getAppBeatmapFilestore(); - await Promise.all([ filestore.saveSongFile(songId, songFile), filestore.saveCoverArtFile(songId, coverArtFile), @@ -261,9 +259,7 @@ export async function exportMapArchive({ songFile, coverArtFile, info, audioData return new File([buffer as BlobPart], `${toPascalCase(info.song.title.replaceAll(/[^a-zA-Z0-9 ]+/g, ""))}.zip`); } -export async function exportMapArchiveFromFilestore(song: App.ISong, options: ExportMapArchiveOptions) { - const filestore = getAppBeatmapFilestore(); - +export async function exportMapArchiveFromFilestore(song: App.ISong, filestore: BeatmapFilestore, options: ExportMapArchiveOptions) { const songId = resolveSongId(song); const [songFile, coverArtFile, info, audioData, ...beatmaps] = await Promise.all([ diff --git a/src/store/_setup.ts b/src/store/_setup.ts index 8efbaf67..33df35f7 100644 --- a/src/store/_setup.ts +++ b/src/store/_setup.ts @@ -1,7 +1,6 @@ import { configureStore, type DevToolsEnhancerOptions } from "@reduxjs/toolkit"; import { initStateWithPrevTab } from "redux-state-sync"; -import { createLazySingleton } from "$/utils"; import { init, tick, updateEventsEditorCursor } from "./actions"; import { createAppEnhancers } from "./enhancers/_setup"; import { default as reducer } from "./features/_setup"; @@ -37,5 +36,3 @@ export async function createAppStore({ extraArgument }: Options) { return store; } - -export const { get: getAppStore, setup: setupAppStore } = createLazySingleton(createAppStore); diff --git a/src/store/features/songs.slice.ts b/src/store/features/songs.slice.ts index 97d14429..aabfa06b 100644 --- a/src/store/features/songs.slice.ts +++ b/src/store/features/songs.slice.ts @@ -103,10 +103,10 @@ const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })( }), }, reducers: (api) => { - const fetchContentsFromFile: AsyncThunkPayloadCreator<{ songId: SongId; songData: App.ISong }, { file: File | Blob; options: Parameters[1] }> = async (args, api: GetThunkAPI) => { + const fetchContentsFromFile: AsyncThunkPayloadCreator<{ songId: SongId; songData: App.ISong }, { file: File | Blob; options: Parameters[3] }> = async (args, api: GetThunkAPI) => { try { const archive = await args.file.arrayBuffer(); - const songData = await importMapArchiveToFilestore(new Uint8Array(archive), args.options); + const songData = await importMapArchiveToFilestore(new Uint8Array(archive), api.extra.getAudioContext(), api.extra.getFilestore(), args.options); return api.fulfillWithValue({ songId: songData.id, songData: { ...songData, demo: args.options.readonly } }); } catch (error) { const toaster = api.extra.getToaster(); diff --git a/src/store/middleware/_setup.ts b/src/store/middleware/_setup.ts index 978fbbee..f804b597 100644 --- a/src/store/middleware/_setup.ts +++ b/src/store/middleware/_setup.ts @@ -23,8 +23,10 @@ export function createAppMiddleware({ extraArgument: extra }: Options) { }, }); - const songSample = new AudioSample({ volume: 1, playbackRate: 1 }); - const tickSample = new AudioSample({ volume: 1, playbackRate: 1 }); + const audioContext = extra.getAudioContext(); + + const songSample = new AudioSample(audioContext, { volume: 1, playbackRate: 1 }); + const tickSample = new AudioSample(audioContext, { volume: 1, playbackRate: 1 }); const audioMiddleware = createAudioMiddleware({ songSample, tickSample, extra }); const playbackMiddleware = createPlaybackMiddleware({ songSample, extra }); diff --git a/src/store/middleware/audio.middleware.ts b/src/store/middleware/audio.middleware.ts index 4793a618..33280fd6 100644 --- a/src/store/middleware/audio.middleware.ts +++ b/src/store/middleware/audio.middleware.ts @@ -5,20 +5,14 @@ import { convertFileToArrayBuffer } from "$/helpers/file.helpers"; import type { AudioSample } from "$/services/audio.service"; import { decrementPlaybackRate, finishLoadingMap, incrementPlaybackRate, pausePlayback, startPlayback, stopPlayback, tick, updateCursorPosition, updatePlaybackRate, updateSong, updateSongVolume, updateTickType, updateTickVolume } from "$/store/actions"; import { selectActiveView } from "$/store/helpers/route.helpers"; -import { selectAllColorNotes, selectAudioLatencyInBeats, selectCursorPosition, selectPlaybackRate, selectSongVolume, selectTickVolume } from "$/store/selectors"; +import { selectAllColorNotes, selectCursorPosition, selectPlaybackRate, selectSongVolume, selectTickVolume, selectTimeProcessor } from "$/store/selectors"; import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; import { type SongId, View } from "$/types"; -function getTickSchedule(state: RootState, songId: SongId): number[] { - const notes = selectAllColorNotes(state); - const delayInBeats = selectAudioLatencyInBeats(state, songId); - return notes.map((note) => note.time - delayInBeats).sort((a, b) => a - b); -} - interface Options { songSample: AudioSample; tickSample: AudioSample; - extra: Pick; + extra: Pick; } /** Manages all concerns related to audio samples. */ @@ -51,7 +45,14 @@ export default function createAudioMiddleware({ songSample, tickSample, extra }: instance.startListening({ matcher: isAnyOf(finishLoadingMap, startPlayback), effect: (action: PayloadAction<{ songId: SongId }>, api) => { - tickSchedule = getTickSchedule(api.getState(), action.payload.songId); + const state = api.getState(); + const { baseLatency } = api.extra.getAudioContext(); + + const notes = selectAllColorNotes(state); + const timeProcessor = selectTimeProcessor(state, action.payload.songId); + const delayInBeats = timeProcessor.toBeatTime(baseLatency); + + tickSchedule = notes.map((note) => note.time - delayInBeats).sort((a, b) => a - b); }, }); instance.startListening({ diff --git a/src/store/middleware/file.middleware.ts b/src/store/middleware/file.middleware.ts index 92b18322..4605331d 100644 --- a/src/store/middleware/file.middleware.ts +++ b/src/store/middleware/file.middleware.ts @@ -11,7 +11,7 @@ import type { SongId } from "$/types"; import { deepAssign } from "$/utils"; interface Options { - extra: Pick; + extra: Pick; } /** This middleware manages file storage concerns. */ @@ -60,7 +60,7 @@ export default function createFileMiddleware({ extra }: Options) { const filestore = api.extra.getFilestore(); const activeSongFile = songFile ?? (await filestore.loadSongFile(songId)); - await deriveWaveformDataFromFile(activeSongFile).then((waveformData) => { + await deriveWaveformDataFromFile(activeSongFile, api.extra.getAudioContext()).then((waveformData) => { return api.dispatch(reloadVisualizer({ duration: waveformData.duration, waveformData: waveformData.toJSON() })); }); } @@ -75,7 +75,7 @@ export default function createFileMiddleware({ extra }: Options) { const infoContents = serializeInfoContents(selectSongById(state, songId), { songDuration: selectDuration(state), }); - const audioDataContents = await createAudioDataContentsFromFile(songFile, { bpm: selectBpm(state, songId) }); + const audioDataContents = await createAudioDataContentsFromFile(songFile, api.extra.getAudioContext(), { bpm: selectBpm(state, songId) }); const filestore = api.extra.getFilestore(); diff --git a/src/store/middleware/packaging.middleware.ts b/src/store/middleware/packaging.middleware.ts index eddac040..e2e26673 100644 --- a/src/store/middleware/packaging.middleware.ts +++ b/src/store/middleware/packaging.middleware.ts @@ -7,7 +7,7 @@ import { selectSongById } from "$/store/selectors"; import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; interface Options { - extra: Pick; + extra: Pick; } export default function createPackagingMiddleware({ extra }: Options) { @@ -21,7 +21,8 @@ export default function createPackagingMiddleware({ extra }: Options) { const song = selectSongById(state, songId); try { - saveAs(await exportMapArchiveFromFilestore(song, options)); + const filestore = api.extra.getFilestore(); + saveAs(await exportMapArchiveFromFilestore(song, filestore, options)); } catch (error) { const toaster = api.extra.getToaster(); toaster?.error({ description: `Could not export map: ${error instanceof Error ? error.message : "See console for more info."}` }); diff --git a/src/store/selectors.ts b/src/store/selectors.ts index 5fa16b6d..50a587d1 100644 --- a/src/store/selectors.ts +++ b/src/store/selectors.ts @@ -6,7 +6,6 @@ import { DEFAULT_GRID } from "$/constants"; import { calculateVisibleRange } from "$/helpers/editor.helpers"; import { isLightEffectActive, resolveBasicEventColor, resolveBasicEventEffect } from "$/helpers/events.helpers"; import { getGridSize } from "$/helpers/song.helpers"; -import { getAudioContext } from "$/setup"; import { type App, type BeatmapId, type ILightState, NotePlacementMode, ObjectTool, ObstaclePlacementMode, type SongId, View } from "$/types"; import { floorToNearest } from "$/utils"; import beatmap from "./features/beatmap.slice"; @@ -99,11 +98,6 @@ export const selectBpmScale = createSelector([selectTimeProcessor, selectBpm, se export const { selectNew, selectAnnouncements, selectUsername, selectRenderScale, selectBloomEnabled, selectObstaclePlacementMode: selectUserObstaclePlacementMode, selectPacerWait } = user.getSelectors(user.selectSlice); -export const selectAudioLatencyInBeats = createSelector([selectTimeProcessor], (timeProcessor) => { - const { baseLatency } = getAudioContext(); - return timeProcessor.toBeatTime(baseLatency); -}); - export const selectSurfaceDepth = createSelector(selectRenderScale, (renderScale) => { return Math.max(renderScale * 75, 25); }); diff --git a/src/store/types.ts b/src/store/types.ts index 336c3d9d..a8ff90ee 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,14 +1,15 @@ import type { CreateToasterReturn } from "@ark-ui/react/toast"; import type { ThunkDispatch, UnknownAction } from "@reduxjs/toolkit"; -import type { AnyRouter } from "@tanstack/react-router"; +import type { Register } from "@tanstack/react-router"; import type { BeatmapFilestore } from "$/services/file.service"; import type { default as reducer } from "./features/_setup"; export interface AppExtraArgs { - getRouter: () => AnyRouter; + getRouter: () => Register["router"]; getFilestore: () => BeatmapFilestore; getToaster: () => CreateToasterReturn | null; + getAudioContext: () => AudioContext; } export type RootState = ReturnType; diff --git a/src/utils/function.utils.ts b/src/utils/function.utils.ts index 35f2429c..1c6e78ac 100644 --- a/src/utils/function.utils.ts +++ b/src/utils/function.utils.ts @@ -1,20 +1,35 @@ -export function createLazySingleton(factory: (...args: TArgs) => T) { - let instance: T | null = null; +export function createSingleton(factory: (...args: TArgs) => T) { + let instance: Awaited | null = null; + type InferGetter = T extends Promise ? Promise<() => Awaited> : () => Awaited; + + function get(): Awaited { + if (instance === null) { + throw new Error("Cannot access instance. Ensure setup() is called at the top of the call stack."); + } + return instance; + } return { - setup(...args: TArgs): T { - if (instance === null) { - instance = factory(...args); - } - return instance; + get instance(): Awaited { + return get(); }, - get(): T { - if (instance === null) { - throw new Error("Cannot access instance. Ensure setup() is called at the top of the call stack."); + setup(...args: TArgs): InferGetter { + if (instance !== null) { + throw new Error("Service has already been set up. Call destroy() first if re-initialization is required."); } - return instance; + const result = factory(...args); + + if (result instanceof Promise) { + return result.then((resolvedInstance) => { + instance = resolvedInstance; + return get; + }) as InferGetter; + } + + instance = result as Awaited; + return get as InferGetter; }, - destroy() { + destroy(): void { instance = null; }, }; From ddf5949115a440b28e7da48cc511a2652e55af6a Mon Sep 17 00:00:00 2001 From: officialMECH Date: Mon, 10 Aug 2026 15:41:21 -0500 Subject: [PATCH 17/27] improve type inference for thunk arguments --- src/store/features/songs.slice.ts | 2 +- src/store/types.ts | 4 ++-- src/store/utils/thunk.utils.ts | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/store/features/songs.slice.ts b/src/store/features/songs.slice.ts index aabfa06b..9e3abbf7 100644 --- a/src/store/features/songs.slice.ts +++ b/src/store/features/songs.slice.ts @@ -103,7 +103,7 @@ const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })( }), }, reducers: (api) => { - const fetchContentsFromFile: AsyncThunkPayloadCreator<{ songId: SongId; songData: App.ISong }, { file: File | Blob; options: Parameters[3] }> = async (args, api: GetThunkAPI) => { + const fetchContentsFromFile: AsyncThunkPayloadCreator<{ songId: SongId; songData: App.ISong }, { file: File | Blob; options: Parameters[3] }> = async (args, api: GetThunkAPI>) => { try { const archive = await args.file.arrayBuffer(); const songData = await importMapArchiveToFilestore(new Uint8Array(archive), api.extra.getAudioContext(), api.extra.getFilestore(), args.options); diff --git a/src/store/types.ts b/src/store/types.ts index a8ff90ee..939e9a0f 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -15,8 +15,8 @@ export interface AppExtraArgs { export type RootState = ReturnType; export type AppDispatch = ThunkDispatch; -export interface AppThunkApiConfig { +export interface AppThunkApiConfig { state: RootState; dispatch: AppDispatch; - extra: AppExtraArgs; + extra: Pick; } diff --git a/src/store/utils/thunk.utils.ts b/src/store/utils/thunk.utils.ts index 53f7c0cb..f142d951 100644 --- a/src/store/utils/thunk.utils.ts +++ b/src/store/utils/thunk.utils.ts @@ -19,18 +19,18 @@ export interface GetShallowThunkAPI { extra: GetExtra; } -export interface ThunkActionCreator, M = GetMeta> extends ActionCreatorWithPreparedPayload<[Arg], Returned, string, S, M> { +export interface ThunkActionCreator, M extends { arg: Arg } = GetMeta & { arg: Arg }> extends ActionCreatorWithPreparedPayload<[Arg], Returned, string, S, M> { (...args: GetArgs): ThunkAction, GetExtra, UnknownAction>; match: (action: unknown) => action is PayloadAction; } -export interface ThunkOptions, M = GetMeta> { +export interface ThunkOptions, M extends { arg: Arg } = GetMeta & { arg: Arg }> { condition?: (arg: Arg, api: Pick, "getState" | "extra">) => boolean | undefined; - getMeta?: (arg: Arg, api: Pick, "getState" | "extra">) => M; + getMeta?: (arg: Arg, api: Pick, "getState" | "extra">) => Omit; serializeError?: (x: unknown) => S; } -export function createThunk, M = GetMeta>( +export function createThunk, M extends { arg: Arg } = GetMeta & { arg: Arg }>( type: string, payloadCreator: (arg: Arg, api: GetShallowThunkAPI) => Returned, options?: ThunkOptions, @@ -45,7 +45,7 @@ export function createThunk Date: Mon, 10 Aug 2026 19:02:13 -0500 Subject: [PATCH 18/27] add shared utility for meta property action matching --- src/store/actions.ts | 2 +- src/store/enhancers/storage.enhancer.ts | 10 +++++----- src/store/middleware/_setup.ts | 8 ++++---- src/store/utils/guards.utils.ts | 8 ++++++++ 4 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 src/store/utils/guards.utils.ts diff --git a/src/store/actions.ts b/src/store/actions.ts index 77d49f29..2ca167ff 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -45,7 +45,7 @@ import visualizer from "./features/visualizer.slice"; export const { init } = global.actions; export const rehydrate = createAction("rehydrate", (args: { songId: SongId; beatmapId: BeatmapId }) => { - return { payload: { ...args }, meta: { hydrate: true } }; + return { payload: { ...args }, meta: { hydrate: true, sync: true } }; }); export const { updateNew, updateAnnouncements, updateUsername, updateRenderScale, updateBloomEnabled, updateObstaclePlacementMode, updatePacerWait } = user.actions; diff --git a/src/store/enhancers/storage.enhancer.ts b/src/store/enhancers/storage.enhancer.ts index 69c2df40..5952b923 100644 --- a/src/store/enhancers/storage.enhancer.ts +++ b/src/store/enhancers/storage.enhancer.ts @@ -1,13 +1,13 @@ -import { isFluxStandardAction, type PayloadAction, type Store, type StoreEnhancer, type UnknownAction } from "@reduxjs/toolkit"; +import type { Store, StoreEnhancer, UnknownAction } from "@reduxjs/toolkit"; import { distinct } from "@std/collections/distinct"; import { createStorage, type Driver, type StorageValue } from "unstorage"; +import { withFluxStandardMeta } from "$/store/utils/guards.utils"; import type { MaybeDefined } from "$/types/vendor"; -export function isHydrationAction

(action: unknown): action is PayloadAction { - if (!isFluxStandardAction(action)) return false; - return "meta" in action && typeof action.meta === "object" && action.meta !== null && "hydrate" in action.meta && typeof action.meta.hydrate === "boolean" && (action.meta as Record).hydrate === true; -} +const isHydrationAction = withFluxStandardMeta((meta) => { + return "hydrate" in meta && typeof meta.hydrate === "boolean" && meta.hydrate === true; +}); export interface StorageStrategy { getKeys: (prevState: TState, nextState: TState) => string[]; diff --git a/src/store/middleware/_setup.ts b/src/store/middleware/_setup.ts index f804b597..5f1bde3a 100644 --- a/src/store/middleware/_setup.ts +++ b/src/store/middleware/_setup.ts @@ -2,8 +2,8 @@ import type { Middleware } from "@reduxjs/toolkit"; import { createStateSyncMiddleware } from "redux-state-sync"; import { AudioSample } from "$/services/audio.service"; -import { isHydrationAction } from "$/store/enhancers/storage.enhancer"; import type { AppExtraArgs } from "$/store/types"; +import { withFluxStandardMeta } from "$/store/utils/guards.utils"; import createAudioMiddleware from "./audio.middleware"; import createBackupMiddleware from "./backup.middleware"; import createDemoMiddleware from "./demo.middleware"; @@ -18,9 +18,9 @@ interface Options { export function createAppMiddleware({ extraArgument: extra }: Options) { const stateSyncMiddleware = createStateSyncMiddleware({ - predicate: (action) => { - return isHydrationAction(action); - }, + predicate: withFluxStandardMeta<{ sync?: boolean }>((meta) => { + return "sync" in meta && typeof meta.sync === "boolean" && meta.sync === true; + }), }); const audioContext = extra.getAudioContext(); diff --git a/src/store/utils/guards.utils.ts b/src/store/utils/guards.utils.ts new file mode 100644 index 00000000..8a323101 --- /dev/null +++ b/src/store/utils/guards.utils.ts @@ -0,0 +1,8 @@ +import { type Action, isFluxStandardAction } from "@reduxjs/toolkit"; + +export function withFluxStandardMeta(condition: (meta: object) => boolean) { + return (action: unknown): action is TAction & { meta: TMeta } => { + if (!isFluxStandardAction(action)) return false; + return "meta" in action && typeof action.meta === "object" && action.meta !== null && action.meta !== undefined && condition(action.meta); + }; +} From e135504477feb5a7beaa47be9b1e24ef697d967c Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 11 Aug 2026 13:15:43 -0500 Subject: [PATCH 19/27] make filestore source of truth for songs hydration step --- src/services/file.service.ts | 6 ++++++ src/store/_setup.ts | 3 ++- src/store/actions.ts | 1 + src/store/enhancers/_setup.ts | 7 +++++-- src/store/features/songs.slice.ts | 18 +++++++++++++++++- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/services/file.service.ts b/src/services/file.service.ts index 76748bb9..2e86d55c 100644 --- a/src/services/file.service.ts +++ b/src/services/file.service.ts @@ -55,6 +55,12 @@ export class BeatmapFilestore extends Filestore { } } + async getAllSongIds() { + return await this.storage.keys().then((keys) => { + return keys.map((filename) => filename.split(".")[0]); + }); + } + async loadSongFile(songId: SongId) { const filename = BeatmapFilestore.resolveFilename(songId, "song", {}); return this.loadFile(filename); diff --git a/src/store/_setup.ts b/src/store/_setup.ts index 33df35f7..73d07ec3 100644 --- a/src/store/_setup.ts +++ b/src/store/_setup.ts @@ -1,7 +1,7 @@ import { configureStore, type DevToolsEnhancerOptions } from "@reduxjs/toolkit"; import { initStateWithPrevTab } from "redux-state-sync"; -import { init, tick, updateEventsEditorCursor } from "./actions"; +import { hydrateSongs, init, tick, updateEventsEditorCursor } from "./actions"; import { createAppEnhancers } from "./enhancers/_setup"; import { default as reducer } from "./features/_setup"; import { createAppMiddleware } from "./middleware/_setup"; @@ -28,6 +28,7 @@ export async function createAppStore({ extraArgument }: Options) { }, }); + await store.dispatch(hydrateSongs()); await store.hydrate().then(() => { store.dispatch(init()); }); diff --git a/src/store/actions.ts b/src/store/actions.ts index 2ca167ff..070f568e 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -76,6 +76,7 @@ export const leaveEditor = createAction("leaveEditor", (args: { songId: SongId; export const { hydrate: hydrateSongs, + upsertMany: upsertSongs, addOne: addSong, addOneFromFile: addSongFromFile, updateOne: updateSong, diff --git a/src/store/enhancers/_setup.ts b/src/store/enhancers/_setup.ts index d2e3ab76..a1b4c5fc 100644 --- a/src/store/enhancers/_setup.ts +++ b/src/store/enhancers/_setup.ts @@ -9,7 +9,6 @@ import { patchEnvironmentName } from "$/helpers/packaging.helpers"; import { createDriver, type LegacyStorageSchema } from "$/services/storage.service"; import { hydrateGridPresets, - hydrateSongs, updateAnnouncements, updateBloomEnabled, updateEventsEditorColor, @@ -35,6 +34,7 @@ import { updateTickVolume, updateTrackScale, updateUsername, + upsertSongs, } from "$/store/actions"; import { selectAllGridPresetIds, @@ -147,6 +147,9 @@ const createAppEntityStorageDriver = createDriver= 5) { + await idb.removeStore("songs", tx); + } }, }); @@ -268,7 +271,7 @@ export function createAppEnhancers() { createEntityStorageStrategy({ selectIds: (state) => selectSongIds(state).map((x) => x.toString()), selectById: selectSongById, - hydrateEntities: hydrateSongs, + hydrateEntities: upsertSongs, }), ); const gridStorageEnhancer = createStorageEnhancer( diff --git a/src/store/features/songs.slice.ts b/src/store/features/songs.slice.ts index 9e3abbf7..549b024e 100644 --- a/src/store/features/songs.slice.ts +++ b/src/store/features/songs.slice.ts @@ -3,6 +3,7 @@ import { distinct } from "@std/collections/distinct"; import { EnvironmentName, getBasicTracksForEnvironment } from "bsmap"; import { eventTypeRename } from "bsmap/extensions/renamer"; +import { deserializeInfoContents } from "$/helpers/packaging.helpers"; import { createAppBeatmap, createAppSong, getColorScheme, getEnvironment, resolveSongId } from "$/helpers/song.helpers"; import { importMapArchiveToFilestore } from "$/services/packaging.service"; import { finishLoadingMap, loadGridPreset, startLoadingMap } from "$/store/actions"; @@ -115,9 +116,24 @@ const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })( return api.rejectWithValue(error); } }; + const hydrate: AsyncThunkPayloadCreator = async (_, api: GetThunkAPI>) => { + const filestore = api.extra.getFilestore(); + const ids = await filestore.getAllSongIds(); + return Promise.all( + ids.map(async (id) => { + const info = await filestore.loadInfoContents(id); + return createAppSong(deserializeInfoContents(info, {})); + }), + ); + }; return { - hydrate: api.reducer>((state, action) => { + hydrate: api.asyncThunk(hydrate, { + fulfilled: (state, action) => { + return adapter.upsertMany(state, action.payload); + }, + }), + upsertMany: api.reducer>((state, action) => { return adapter.upsertMany(state, action.payload); }), addOne: api.reducer<{ songId: SongId; beatmapId: BeatmapId; songFile: File; coverArtFile: File; songData: Parameters[0]; beatmapData: Parameters[0] }>((state, action) => { From 45de027de822c0675c9e2e83b3a9803cb731fa52 Mon Sep 17 00:00:00 2001 From: officialMECH Date: Wed, 12 Aug 2026 11:17:00 -0500 Subject: [PATCH 20/27] fix regression in property resolution for object placement logic --- src/components/scene/templates/placement-grid.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/scene/templates/placement-grid.tsx b/src/components/scene/templates/placement-grid.tsx index 9fbde33c..0c061dea 100644 --- a/src/components/scene/templates/placement-grid.tsx +++ b/src/components/scene/templates/placement-grid.tsx @@ -47,7 +47,10 @@ function EditorPlacementGrid({ onCellPointerDown, onCellWheel, ...rest }: Assign switch (selectedTool) { case ObjectTool.LEFT_NOTE: case ObjectTool.RIGHT_NOTE: { - const note = createColorNoteFromMouseEvent(ctx, notePlacementMode, grid, { direction: Math.round(ctx.direction ?? selectedDirection) }); + const note = createColorNoteFromMouseEvent(ctx, notePlacementMode, grid, { + color: Object.values(ObjectTool).indexOf(selectedTool) as 0 | 1, + direction: Math.round(ctx.direction ?? selectedDirection), + }); if (note) return dispatch(addColorNote({ ...note, time })); break; } @@ -57,7 +60,9 @@ function EditorPlacementGrid({ onCellPointerDown, onCellWheel, ...rest }: Assign break; } case ObjectTool.OBSTACLE: { - const obstacle = createObstacleFromMouseEvent(ctx, obstaclePlacementMode, grid, { duration: defaultObstacleDuration }); + const obstacle = createObstacleFromMouseEvent(ctx, obstaclePlacementMode, grid, { + duration: defaultObstacleDuration, + }); if (obstacle) return dispatch(addObstacle({ ...obstacle, time })); break; } From 3cf8baf596cfea9f4a3c1e1603789221e770f08a Mon Sep 17 00:00:00 2001 From: officialMECH Date: Mon, 17 Aug 2026 12:59:34 -0500 Subject: [PATCH 21/27] move cross-slice selectors out of slice definitions --- .../app/templates/shortcuts/default.tsx | 6 ++- src/store/actions.ts | 23 +++++++++- src/store/features/beatmap.slice.ts | 20 ++------ src/store/features/bookmarks.slice.ts | 30 ++++-------- src/store/features/clipboard.slice.ts | 46 +++++++------------ src/store/features/navigation.slice.ts | 4 +- 6 files changed, 57 insertions(+), 72 deletions(-) diff --git a/src/components/app/templates/shortcuts/default.tsx b/src/components/app/templates/shortcuts/default.tsx index 2c51f0a4..f4eb99da 100644 --- a/src/components/app/templates/shortcuts/default.tsx +++ b/src/components/app/templates/shortcuts/default.tsx @@ -7,6 +7,7 @@ import { useToaster } from "$/components/context"; import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; import { usePrompt, usePrompter } from "$/components/ui/compositions"; import { SNAPPING_INCREMENTS } from "$/constants"; +import { resolveColorForBookmark } from "$/helpers/bookmarks.helpers"; import { addBookmark, copySelection, @@ -40,7 +41,7 @@ import { updateSnap, } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectDemo, selectLoading, selectPacerWait } from "$/store/selectors"; +import { selectCursorPositionInBeats, selectDemo, selectLoading, selectPacerWait } from "$/store/selectors"; import { View } from "$/types"; import { isMetaKeyPressed } from "$/utils"; @@ -53,6 +54,7 @@ function DefaultEditorShortcuts() { const dispatch = useAppDispatch(); const isLoading = useAppSelector(selectLoading); const isDemo = useAppSelector((state) => selectDemo(state, sid)); + const cursorPositionInBeats = useAppSelector((state) => selectCursorPositionInBeats(state, sid)); const wait = useAppSelector(selectPacerWait); const { trigger: triggerQuickSelect } = usePrompt( @@ -80,7 +82,7 @@ function DefaultEditorShortcuts() { const { trigger: triggerAddBookmark } = usePrompt( createAddBookmarkPrompt({ render: ({ form }) => {(ctx) => }, - onSubmit: ({ value }) => dispatch(addBookmark({ songId: sid, view, name: value.name })), + onSubmit: ({ value }) => dispatch(addBookmark({ time: cursorPositionInBeats, name: value.name, color: resolveColorForBookmark(value.name) })), }), ); diff --git a/src/store/actions.ts b/src/store/actions.ts index 070f568e..78f4cf29 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -17,7 +17,9 @@ import { selectEventEditorStartAndEndBeat, selectEventsEditorCursor, selectEventsEditorZoomLevel, + selectGridSize, selectPlaybackRate, + selectSelectedBeatmapEntities, selectSnap, selectSongVolume, selectTickVolume, @@ -166,11 +168,16 @@ export const decrementTickVolume = createThunk("decrementTickVolume", (_, api: G export const { reloadVisualizer, updateZoom: zoomVisualizer } = visualizer.actions; -export const { updateTool: updateNotesEditorTool, updateDirection: updateNotesEditorDirection, updateDefaultObstacleDuration: updateNotesEditorDefaultObstacleDuration, hydrateGridPresets, upsertGridPreset: saveGridPreset, removeGridPreset } = beatmap.actions; +export const { updateTool: updateNotesEditorTool, updateDirection: updateNotesEditorDirection, updateDefaultObstacleDuration: updateNotesEditorDefaultObstacleDuration, hydrateGridPresets, upsertGridPreset, removeGridPreset } = beatmap.actions; export const loadGridPreset = createAction("loadGridPreset", (args: { songId: SongId; grid: IGrid }) => { return { payload: { ...args } }; }); +export const saveGridPreset = createThunk("saveGridPreset", (args: { songId: SongId; presetSlot: string }, api: GetShallowThunkAPI) => { + const state = api.getState(); + const grid = selectGridSize(state, args.songId ?? null); + return api.dispatch(upsertGridPreset({ ...args, grid })); +}); export const { updateTool: updateEventsEditorTool, @@ -281,8 +288,20 @@ export const nudgeSelection = createAsyncThunk("nudgeSelection", (args: { direct return api.fulfillWithValue({ ...args, amount: snapTo }); }); -export const { cutSelection, copySelection } = clipboard.actions; +export const { setData: setClipboardData } = clipboard.actions; +export const cutSelection = createAsyncThunk("cutSelection", (args: { view: View }, api: GetThunkAPI) => { + const state = api.getState(); + const selection = selectSelectedBeatmapEntities(state, args.view); + api.dispatch(setClipboardData({ ...args, data: selection })); + return { ...args }; +}); +export const copySelection = createAsyncThunk("copySelection", (args: { view: View }, api: GetThunkAPI) => { + const state = api.getState(); + const selection = selectSelectedBeatmapEntities(state, args.view); + api.dispatch(setClipboardData({ ...args, data: selection })); + return { ...args }; +}); export const pasteSelection = createAsyncThunk("pasteSelection", (args: { songId: SongId; view: View }, api: GetThunkAPI) => { const state = api.getState(); const data = selectClipboardData(state); diff --git a/src/store/features/beatmap.slice.ts b/src/store/features/beatmap.slice.ts index 903e0bb8..b66ff123 100644 --- a/src/store/features/beatmap.slice.ts +++ b/src/store/features/beatmap.slice.ts @@ -1,9 +1,7 @@ -import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, type GetThunkAPI, isAnyOf } from "@reduxjs/toolkit"; +import { createSlice, isAnyOf } from "@reduxjs/toolkit"; import type { NoteDirection } from "bsmap"; import { cycleToNextTool, cycleToPrevTool, finishManagingNoteSelection, startManagingNoteSelection, updateAllSelectedObstacles, updateObstacle } from "$/store/actions"; -import { selectGridSize } from "$/store/selectors"; -import type { AppThunkApiConfig } from "$/store/types"; import { type IGrid, type IGridPresets, type ObjectSelectionMode, ObjectTool, type SongId, View } from "$/types"; const NOTE_TOOLS = Object.values(ObjectTool); @@ -16,7 +14,7 @@ const initialState = { gridPresets: {} as IGridPresets, }; -const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })({ +const slice = createSlice({ name: "beatmap", initialState: initialState, selectors: { @@ -29,12 +27,6 @@ const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })( selectGridPresetById: (state, id: string) => state.gridPresets[id], }, reducers: (api) => { - const fetchGridSize: AsyncThunkPayloadCreator<{ presetSlot: string; grid: IGrid }, { songId: SongId; presetSlot: string }> = (args, api: GetThunkAPI) => { - const state = api.getState(); - const grid = selectGridSize(state, args.songId ?? null); - return api.fulfillWithValue({ ...args, grid }); - }; - return { updateTool: api.reducer((state, action) => { return { ...state, selectedTool: action.payload }; @@ -48,11 +40,9 @@ const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })( hydrateGridPresets: api.reducer>((state, action) => { return { ...state, gridPresets: action.payload }; }), - upsertGridPreset: api.asyncThunk(fetchGridSize, { - fulfilled: (state, action) => { - const { presetSlot: key, grid: value } = action.payload; - return { ...state, gridPresets: { ...state.gridPresets, [key]: value } }; - }, + upsertGridPreset: api.reducer<{ presetSlot: string; grid: IGrid }>((state, action) => { + const { presetSlot: key, grid: value } = action.payload; + return { ...state, gridPresets: { ...state.gridPresets, [key]: value } }; }), removeGridPreset: api.reducer<{ songId: SongId; presetSlot: string }>((state, action) => { const { presetSlot } = action.payload; diff --git a/src/store/features/bookmarks.slice.ts b/src/store/features/bookmarks.slice.ts index aa4e4866..9c026511 100644 --- a/src/store/features/bookmarks.slice.ts +++ b/src/store/features/bookmarks.slice.ts @@ -1,11 +1,9 @@ -import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, createEntityAdapter, type EntityId, type GetThunkAPI, isAnyOf } from "@reduxjs/toolkit"; +import { createEntityAdapter, createSlice, type EntityId, isAnyOf } from "@reduxjs/toolkit"; import { sortObjectFn } from "bsmap"; -import { resolveBookmarkId, resolveColorForBookmark } from "$/helpers/bookmarks.helpers"; -import { addSong, leaveEditor, loadBeatmapEntities, startLoadingMap } from "$/store/actions"; -import { selectCursorPositionInBeats } from "$/store/selectors"; -import type { AppThunkApiConfig } from "$/store/types"; -import type { App, RequiredKeys, SongId, View } from "$/types"; +import { resolveBookmarkId } from "$/helpers/bookmarks.helpers"; +import { leaveEditor, loadBeatmapEntities, startLoadingMap } from "$/store/actions"; +import type { App } from "$/types"; const adapter = createEntityAdapter({ selectId: resolveBookmarkId, @@ -14,27 +12,17 @@ const adapter = createEntityAdapter({ const { selectAll } = adapter.getSelectors(); -const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })({ +const slice = createSlice({ name: "bookmarks", initialState: adapter.getInitialState(), selectors: { selectAll: selectAll, }, reducers: (api) => { - const deriveDataFromState: AsyncThunkPayloadCreator, { songId: SongId; view: View; name: string }> = (args, api: GetThunkAPI) => { - const state = api.getState(); - // we want to use the cursor position to figure out when to create the bookmark for - const beatNum = selectCursorPositionInBeats(state, args.songId); - if (beatNum === null) return api.rejectWithValue("Invalid beat number."); - return api.fulfillWithValue({ ...args, time: beatNum, color: resolveColorForBookmark(args.name) }); - }; - return { - addOne: api.asyncThunk(deriveDataFromState, { - fulfilled: (state, action) => { - const { time, name, color } = action.payload; - return adapter.addOne(state, { time: time, name, color }); - }, + addOne: api.reducer((state, action) => { + const { time, name, color } = action.payload; + return adapter.addOne(state, { time: time, name, color }); }), removeOne: api.reducer<{ beatNum: number }>((state, action) => { const { beatNum } = action.payload; @@ -47,7 +35,7 @@ const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })( const { bookmarks } = action.payload; return adapter.setAll(state, bookmarks ?? []); }); - builder.addMatcher(isAnyOf(addSong, startLoadingMap, leaveEditor), () => adapter.getInitialState()); + builder.addMatcher(isAnyOf(startLoadingMap, leaveEditor), () => adapter.getInitialState()); builder.addDefaultCase((state) => state); }, }); diff --git a/src/store/features/clipboard.slice.ts b/src/store/features/clipboard.slice.ts index 45a208eb..d4fda411 100644 --- a/src/store/features/clipboard.slice.ts +++ b/src/store/features/clipboard.slice.ts @@ -1,8 +1,6 @@ -import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, type CaseReducer, type GetThunkAPI, type PayloadAction } from "@reduxjs/toolkit"; +import { createSlice } from "@reduxjs/toolkit"; import { sortObjectFn } from "bsmap"; -import { selectSelectedBeatmapEntities } from "$/store/selectors"; -import type { AppThunkApiConfig } from "$/store/types"; import type { App, View } from "$/types"; const initialState = { @@ -10,7 +8,7 @@ const initialState = { data: {} as Partial>, }; -const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })({ +const slice = createSlice({ name: "clipboard", initialState: initialState, selectors: { @@ -29,33 +27,21 @@ const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })( }, }, reducers: (api) => { - const fetchClipboardData: AsyncThunkPayloadCreator = (args: { view: View }, api: GetThunkAPI) => { - const state = api.getState(); - const selection = selectSelectedBeatmapEntities(state, args.view); - return api.fulfillWithValue({ ...args, data: selection }); - }; - const processSelection: CaseReducer> = (state, action) => { - const { data } = action.payload; - if (!data) return state; - return { - ...state, - data: { - // We want to sort the data so that it goes from earliest beat to latest beat. - notes: data.notes?.sort(sortObjectFn), - bombs: data.bombs?.sort(sortObjectFn), - obstacles: data.obstacles?.sort(sortObjectFn), - basicEvents: data.basicEvents?.sort(sortObjectFn), - boostEvents: data.boostEvents?.sort(sortObjectFn), - }, - }; - }; - return { - cutSelection: api.asyncThunk(fetchClipboardData, { - fulfilled: processSelection, - }), - copySelection: api.asyncThunk(fetchClipboardData, { - fulfilled: processSelection, + setData: api.reducer((state, action) => { + const { data } = action.payload; + if (!data) return state; + return { + ...state, + data: { + // We want to sort the data so that it goes from earliest beat to latest beat. + notes: data.notes?.sort(sortObjectFn), + bombs: data.bombs?.sort(sortObjectFn), + obstacles: data.obstacles?.sort(sortObjectFn), + basicEvents: data.basicEvents?.sort(sortObjectFn), + boostEvents: data.boostEvents?.sort(sortObjectFn), + }, + }; }), }; }, diff --git a/src/store/features/navigation.slice.ts b/src/store/features/navigation.slice.ts index a3b575c4..e55ebe58 100644 --- a/src/store/features/navigation.slice.ts +++ b/src/store/features/navigation.slice.ts @@ -1,4 +1,4 @@ -import { asyncThunkCreator, buildCreateSlice, isAnyOf } from "@reduxjs/toolkit"; +import { createSlice, isAnyOf } from "@reduxjs/toolkit"; import { leaveEditor, reloadVisualizer, scrollThroughSong, selectAllEntitiesInRange, updateSong } from "$/store/actions"; import type { SongId } from "$/types"; @@ -18,7 +18,7 @@ const initialState = { playbackRate: 1, }; -const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })({ +const slice = createSlice({ name: "navigation", initialState: initialState, selectors: { From 71e79d5bee6d3af71c1894f8e75eb26ca77c72ac Mon Sep 17 00:00:00 2001 From: officialMECH Date: Mon, 17 Aug 2026 18:12:46 -0500 Subject: [PATCH 22/27] reconcile stateful thunk payloads using listener middleware --- .../editor/action-panel-groups/default.tsx | 15 +- .../action-panel-groups/grid-presets.tsx | 2 +- .../editor/action-panel-groups/grid.tsx | 2 +- .../editor/action-panel-groups/selection.tsx | 16 +- src/components/app/templates/events/grid.tsx | 4 +- .../app/templates/shortcuts/default.tsx | 14 +- .../app/templates/shortcuts/events.tsx | 8 +- .../app/templates/shortcuts/notes.tsx | 15 +- src/store/actions.ts | 205 ++++-------------- src/store/features/beatmap.slice.ts | 4 +- src/store/features/clipboard.slice.ts | 3 +- src/store/features/events.slice.ts | 62 ++---- src/store/features/objects.slice.ts | 99 +++------ src/store/features/songs.slice.ts | 12 +- src/store/middleware/_setup.ts | 6 +- src/store/middleware/editor.middleware.ts | 88 ++++++++ src/store/middleware/entities.middleware.ts | 158 ++++++++++++++ src/store/utils/thunk.utils.ts | 12 +- 18 files changed, 392 insertions(+), 333 deletions(-) create mode 100644 src/store/middleware/editor.middleware.ts create mode 100644 src/store/middleware/entities.middleware.ts 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..71821f22 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,7 +33,7 @@ function DefaultActionPanelGroup({ handleGridConfigClick }: Props) { if (typeof endBeat !== "number") { endBeat = Number.POSITIVE_INFINITY; } - dispatch(selectAllEntitiesInRange({ songId: sid, view: view, startBeat, endBeat })); + dispatch(selectAllEntitiesInRange({ startBeat, endBeat })); dispatch(jumpToBeat({ songId: sid, value: startBeat, pauseTrack: true })); }, }), @@ -49,21 +48,21 @@ function DefaultActionPanelGroup({ handleGridConfigClick }: Props) { 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..8be3da40 100644 --- a/src/components/app/templates/editor/action-panel-groups/selection.tsx +++ b/src/components/app/templates/editor/action-panel-groups/selection.tsx @@ -1,4 +1,4 @@ -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"; @@ -7,7 +7,7 @@ import { Show } from "$/components/ui/atoms"; import { Button, Tooltip } from "$/components/ui/compositions"; import { deselectAllEntities, deselectAllEntitiesOfType, 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,12 +30,10 @@ 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 ( @@ -44,30 +42,30 @@ function SelectionActionPanelGroup() { {(items) => dispatch(deselectAllEntitiesOfType({ itemType: ObjectType.BOMB }))} />} {(items) => dispatch(deselectAllEntitiesOfType({ itemType: ObjectType.OBSTACLE }))} />} - "Mirror selection horizontally"}> - "Mirror selection vertically"}> - "Nudge selection forwards"}> - "Nudge selection backwards"}> - diff --git a/src/components/app/templates/events/grid.tsx b/src/components/app/templates/events/grid.tsx index 83c7ed21..44db8f4c 100644 --- a/src/components/app/templates/events/grid.tsx +++ b/src/components/app/templates/events/grid.tsx @@ -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) => selectEventEditorStartAndEndBeat(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)); @@ -85,7 +85,7 @@ function EventGridEditor({ ...rest }: Omit 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 })); }, }); diff --git a/src/components/app/templates/shortcuts/default.tsx b/src/components/app/templates/shortcuts/default.tsx index f4eb99da..ff90d7d7 100644 --- a/src/components/app/templates/shortcuts/default.tsx +++ b/src/components/app/templates/shortcuts/default.tsx @@ -68,7 +68,7 @@ function DefaultEditorShortcuts() { if (typeof endBeat !== "number") { endBeat = Number.POSITIVE_INFINITY; } - dispatch(selectAllEntitiesInRange({ songId: sid, view: view, startBeat, endBeat })); + dispatch(selectAllEntitiesInRange({ startBeat, endBeat })); dispatch(jumpToBeat({ songId: sid, value: startBeat, pauseTrack: true })); }, }), @@ -102,7 +102,7 @@ function DefaultEditorShortcuts() { return dispatch(direction === "forwards" ? decrementSnap() : incrementSnap()); } if (ev.altKey) { - return dispatch(nudgeSelection({ view, direction })); + return dispatch(nudgeSelection({ direction })); } if (ev.shiftKey) return; @@ -146,11 +146,11 @@ function DefaultEditorShortcuts() { return dispatch(togglePlayback({ songId: sid })); } case "Escape": { - return dispatch(deselectAllEntities({ view })); + return dispatch(deselectAllEntities()); } case "Tab": { ev.preventDefault(); - return dispatch(ev.shiftKey ? cycleToPrevTool({ view }) : cycleToNextTool({ view })); + return dispatch(ev.shiftKey ? cycleToPrevTool() : cycleToNextTool()); } case "ArrowUp": case "ArrowRight": { @@ -195,15 +195,15 @@ function DefaultEditorShortcuts() { } case "KeyX": { if (!metaKeyPressed) return; - return dispatch(cutSelection({ view })); + return dispatch(cutSelection()); } case "KeyC": { if (!metaKeyPressed) return; - return dispatch(copySelection({ view })); + return dispatch(copySelection()); } case "KeyV": { if (!metaKeyPressed) return; - return dispatch(pasteSelection({ songId: sid, view })); + return dispatch(pasteSelection()); } case "KeyJ": { ev.preventDefault(); diff --git a/src/components/app/templates/shortcuts/events.tsx b/src/components/app/templates/shortcuts/events.tsx index 389f9991..687e4be6 100644 --- a/src/components/app/templates/shortcuts/events.tsx +++ b/src/components/app/templates/shortcuts/events.tsx @@ -1,4 +1,3 @@ -import { useParams, useRouteContext } from "@tanstack/react-router"; import { useCallback } from "react"; import { useGlobalEventListener } from "$/components/hooks/use-global-event-listener"; @@ -10,9 +9,6 @@ import { EventColor, EventEditMode, EventTool } from "$/types"; import { isMetaKeyPressed } from "$/utils"; function EventsEditorShortcuts() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); - const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); - const dispatch = useAppDispatch(); const isLoading = useAppSelector(selectLoading); @@ -40,7 +36,7 @@ function EventsEditorShortcuts() { case "KeyA": { if (metaKeyPressed) { ev.preventDefault(); - return dispatch(toggleSelectAllEntities({ songId: sid, view })); + return dispatch(toggleSelectAllEntities()); } return dispatch(updateEventsEditorEditMode(EventEditMode.PLACE)); } @@ -86,7 +82,7 @@ function EventsEditorShortcuts() { } } }, - [isLoading, isPromptActive, dispatch, sid, view], + [isLoading, isPromptActive, dispatch], ); useGlobalEventListener("keydown", handleKeyDown); diff --git a/src/components/app/templates/shortcuts/notes.tsx b/src/components/app/templates/shortcuts/notes.tsx index eb3645b0..73f4f733 100644 --- a/src/components/app/templates/shortcuts/notes.tsx +++ b/src/components/app/templates/shortcuts/notes.tsx @@ -1,4 +1,3 @@ -import { useParams, useRouteContext } from "@tanstack/react-router"; import { NoteDirection } from "bsmap"; import { useCallback, useRef } from "react"; @@ -6,17 +5,13 @@ import { useGlobalEventListener } from "$/components/hooks/use-global-event-list import { usePrompter } from "$/components/ui/compositions"; import { mirrorSelection, toggleSelectAllEntities, updateNotesEditorDirection, updateNotesEditorTool } from "$/store/actions"; import { useAppDispatch, useAppSelector } from "$/store/hooks"; -import { selectGridSize, selectLoading } from "$/store/selectors"; +import { selectLoading } from "$/store/selectors"; import { ObjectTool } from "$/types"; import { isMetaKeyPressed } from "$/utils"; function NotesEditorShortcuts() { - const { sid } = useParams({ from: "/_/edit/$sid/$bid/_" }); - const { view } = useRouteContext({ from: "/_/edit/$sid/$bid/_" }); - const dispatch = useAppDispatch(); const isLoading = useAppSelector(selectLoading); - const grid = useAppSelector((state) => selectGridSize(state, sid)); const { isPromptActive } = usePrompter(); @@ -61,12 +56,12 @@ function NotesEditorShortcuts() { return dispatch(updateNotesEditorTool(ObjectTool.RIGHT_NOTE)); } case "KeyH": { - return dispatch(mirrorSelection({ axis: "horizontal", grid })); + return dispatch(mirrorSelection({ axis: "horizontal" })); } case "KeyV": { // If the user is pasting with Meta+V, ignore. if (metaKeyPressed) return; - return dispatch(mirrorSelection({ axis: "vertical", grid })); + return dispatch(mirrorSelection({ axis: "vertical" })); } case "KeyW": { if (ev.shiftKey) return; @@ -83,7 +78,7 @@ function NotesEditorShortcuts() { if (ev.shiftKey) return; if (metaKeyPressed) { ev.preventDefault(); - return dispatch(toggleSelectAllEntities({ songId: sid, view })); + return dispatch(toggleSelectAllEntities()); } keysDepressed.current.a = true; if (keysDepressed.current.w) { @@ -153,7 +148,7 @@ function NotesEditorShortcuts() { } } }, - [isLoading, isPromptActive, dispatch, sid, view, grid], + [isLoading, isPromptActive, dispatch], ); const handleKeyUp = useCallback( diff --git a/src/store/actions.ts b/src/store/actions.ts index 78f4cf29..0a81c361 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,31 +1,11 @@ -import { createAction, createAsyncThunk, type GetThunkAPI } from "@reduxjs/toolkit"; +import { createAction } from "@reduxjs/toolkit"; import type { ITrackDefinitions } from "bsmap"; -import { SNAPPING_INCREMENT_VALUES, ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN } from "$/constants"; import type { ExportMapArchiveOptions } from "$/services/packaging.service"; -import { type App, type BeatmapId, type IGrid, type ISelectionBoxInBeats, type ObjectSelectionMode, type ObjectType, type SongId, View } from "$/types"; -import { - selectAllBasicEvents, - selectAllBombNotes, - selectAllColorNotes, - selectAllObstacles, - selectAnySelectedEvents, - selectAnySelectedObjects, - selectClipboardData, - selectCursorPositionInBeats, - selectEarliestBeat, - selectEventEditorStartAndEndBeat, - selectEventsEditorCursor, - selectEventsEditorZoomLevel, - selectGridSize, - selectPlaybackRate, - selectSelectedBeatmapEntities, - selectSnap, - selectSongVolume, - selectTickVolume, -} from "./selectors"; +import type { App, BeatmapId, ISelectionBoxInBeats, ObjectSelectionMode, ObjectType, SongId } from "$/types"; +import { selectActiveView } from "./helpers/route.helpers"; import type { AppThunkApiConfig } from "./types"; -import { createIncrementByIndexPayloadActionCreator, createIncrementByValuePayloadActionCreator, createThunk, type GetShallowThunkAPI } from "./utils/thunk.utils"; +import { createThunk, type GetShallowThunkAPI } from "./utils/thunk.utils"; // biome-ignore-start assist/source/organizeImports: circular dependencies @@ -122,61 +102,27 @@ export const { updateSnap, } = navigation.actions; -const createIncrementSnap = createIncrementByIndexPayloadActionCreator(SNAPPING_INCREMENT_VALUES, { - select: selectSnap, - update: updateSnap, -}); -export const incrementSnap = createThunk("incrementSnap", (_, api: GetShallowThunkAPI) => { - return createIncrementSnap({ delta: 1 }, api); -}); -export const decrementSnap = createThunk("decrementSnap", (_, api: GetShallowThunkAPI) => { - return createIncrementSnap({ delta: -1 }, api); -}); +export const incrementSnap = createAction("incrementSnap"); +export const decrementSnap = createAction("decrementSnap"); -const createIncrementPlaybackRate = createIncrementByValuePayloadActionCreator([0, 2], { - select: selectPlaybackRate, - update: updatePlaybackRate, -}); -export const incrementPlaybackRate = createThunk("incrementPlaybackRate", (_, api: GetShallowThunkAPI) => { - return createIncrementPlaybackRate({ delta: 0.25 }, api); -}); -export const decrementPlaybackRate = createThunk("decrementPlaybackRate", (_, api: GetShallowThunkAPI) => { - return createIncrementPlaybackRate({ delta: -0.25 }, api); -}); +export const incrementPlaybackRate = createAction("incrementPlaybackRate"); +export const decrementPlaybackRate = createAction("decrementPlaybackRate"); -const createIncrementSongVolume = createIncrementByValuePayloadActionCreator([0, 1], { - select: selectSongVolume, - update: updateSongVolume, -}); -export const incrementSongVolume = createThunk("incrementSongVolume", (_, api: GetShallowThunkAPI) => { - return createIncrementSongVolume({ delta: 0.125 }, api); -}); -export const decrementSongVolume = createThunk("decrementSongVolume", (_, api: GetShallowThunkAPI) => { - return createIncrementSongVolume({ delta: -0.125 }, api); -}); +export const incrementSongVolume = createAction("incrementSongVolume"); +export const decrementSongVolume = createAction("decrementSongVolume"); -const createIncrementTickVolume = createIncrementByValuePayloadActionCreator([0, 1], { - select: selectTickVolume, - update: updateTickVolume, -}); -export const incrementTickVolume = createThunk("incrementTickVolume", (_, api: GetShallowThunkAPI) => { - return createIncrementTickVolume({ delta: 0.125 }, api); -}); -export const decrementTickVolume = createThunk("decrementTickVolume", (_, api: GetShallowThunkAPI) => { - return createIncrementTickVolume({ delta: -0.125 }, api); -}); +export const incrementTickVolume = createAction("incrementTickVolume"); +export const decrementTickVolume = createAction("decrementTickVolume"); export const { reloadVisualizer, updateZoom: zoomVisualizer } = visualizer.actions; export const { updateTool: updateNotesEditorTool, updateDirection: updateNotesEditorDirection, updateDefaultObstacleDuration: updateNotesEditorDefaultObstacleDuration, hydrateGridPresets, upsertGridPreset, removeGridPreset } = beatmap.actions; -export const loadGridPreset = createAction("loadGridPreset", (args: { songId: SongId; grid: IGrid }) => { +export const loadGridPreset = createAction("loadGridPreset", (args: { slot: string }) => { return { payload: { ...args } }; }); -export const saveGridPreset = createThunk("saveGridPreset", (args: { songId: SongId; presetSlot: string }, api: GetShallowThunkAPI) => { - const state = api.getState(); - const grid = selectGridSize(state, args.songId ?? null); - return api.dispatch(upsertGridPreset({ ...args, grid })); +export const saveGridPreset = createAction("saveGridPreset", (args: { slot: string }) => { + return { payload: { ...args } }; }); export const { @@ -192,33 +138,21 @@ export const { updateMirrorLock: updateEventsEditorMirrorLock, } = lightshow.actions; -const createIncrementZoomLevel = createIncrementByValuePayloadActionCreator([ZOOM_LEVEL_MIN, ZOOM_LEVEL_MAX], { - select: selectEventsEditorZoomLevel, - update: updateEventsEditorZoomLevel, -}); -export const incrementEventsEditorZoomLevel = createThunk("incrementZoomLevel", (_, api: GetShallowThunkAPI) => { - return createIncrementZoomLevel({ delta: 1 }, api); -}); -export const decrementEventsEditorZoomLevel = createThunk("decrementZoomLevel", (_, api: GetShallowThunkAPI) => { - return createIncrementZoomLevel({ delta: -1 }, api); -}); +export const incrementEventsEditorZoomLevel = createAction("incrementZoomLevel"); +export const decrementEventsEditorZoomLevel = createAction("decrementZoomLevel"); -export const drawEventSelectionBox = createAsyncThunk("drawEventSelectionBox", (args: { songId: SongId; tracks: ITrackDefinitions; selectionBoxInBeats: ISelectionBoxInBeats }, api: GetThunkAPI) => { - const state = api.getState(); - const { startBeat, endBeat } = selectEventEditorStartAndEndBeat(state, args.songId); - const metadata = { window: { startBeat, endBeat } }; - return api.fulfillWithValue({ ...args, metadata }); -}); - -export const cycleToNextTool = createAction("cycleToNextTool", (args: { view: View }) => { +export const drawEventSelectionBox = createAction("drawEventSelectionBox", (args: { window: { startBeat: number; endBeat: number }; tracks: ITrackDefinitions; selectionBoxInBeats: ISelectionBoxInBeats }) => { return { payload: { ...args } }; }); -export const cycleToPrevTool = createAction("cycleToPrevTool", (args: { view: View }) => { - return { payload: { ...args } }; +export const cycleToNextTool = createThunk("cycleToNextTool", (_, api: GetShallowThunkAPI>) => { + return { view: selectActiveView(api.extra.getRouter()) }; +}); +export const cycleToPrevTool = createThunk("cycleToPrevTool", (_, api: GetShallowThunkAPI>) => { + return { view: selectActiveView(api.extra.getRouter()) }; }); -export const { undo: undoObjects, redo: redoObjects, clearHistory: clearObjectHistory, removeAllSelectedObjects } = objects.actions; +export const { undo: undoObjects, redo: redoObjects, clearHistory: clearObjectHistory, upsertObjects, mirrorAllSelectedObjects, nudgeAllSelectedObjects, removeAllSelectedObjects } = objects.actions; export const { addColorNote, updateColorNote, selectColorNote, deselectColorNote, removeColorNote } = objects.actions; export const { addBombNote, selectBombNote, deselectBombNote, removeBombNote } = objects.actions; export const { addObstacle, updateObstacle, selectObstacle, deselectObstacle, updateAllSelectedObstacles, removeObstacle } = objects.actions; @@ -229,96 +163,37 @@ export const startManagingNoteSelection = createAction("startManagingNoteSelecti export const finishManagingNoteSelection = createAction("finishManagingNoteSelection"); -export const { undo: undoEvents, redo: redoEvents, clearHistory: clearEventHistory, removeAllSelectedEvents } = events.actions; +export const { undo: undoEvents, redo: redoEvents, clearHistory: clearEventHistory, upsertEvents, nudgeAllSelectedEvents, removeAllSelectedEvents } = events.actions; export const { addBasicEvent, updateBasicEvent, selectBasicEvent, deselectBasicEvent, removeBasicEvent } = events.actions; export const { addBoostEvent, updateBoostEvent, selectBoostEvent, deselectBoostEvent, removeBoostEvent } = events.actions; -export const selectAllEntities = createAsyncThunk("selectAllEntities", (args: { songId: SongId; view: View }, api: GetThunkAPI) => { - const state = api.getState(); - // For the events view, we don't actually want to select EVERY note. We only want to select what is visible in the current frame. - let metadata = null; - if (args.view === View.LIGHTSHOW) { - const { startBeat, endBeat } = selectEventEditorStartAndEndBeat(state, args.songId); - metadata = { startBeat, endBeat }; - } - return api.fulfillWithValue({ ...args, metadata }); +export const selectAllEntities = createThunk("selectAllEntities", (_, api: GetShallowThunkAPI>) => { + return { view: selectActiveView(api.extra.getRouter()) }; }); - -export const deselectAllEntities = createAction("deselectAllEntities", (args: { view: View }) => { - return { payload: { ...args } }; +export const selectAllEntitiesInRange = createThunk("selectAllEntitiesInRange", (args: { startBeat: number; endBeat: number }, api: GetShallowThunkAPI>) => { + return { ...args, view: selectActiveView(api.extra.getRouter()) }; }); -export const toggleSelectAllEntities = createAsyncThunk("toggleSelectAllEntities", (args: { songId: SongId; view: View }, api: GetThunkAPI) => { - const state = api.getState(); - - let anythingSelected = false; - - if (args.view === View.BEATMAP) { - const notes = selectAllColorNotes(state); - const bombs = selectAllBombNotes(state); - const obstacles = selectAllObstacles(state); - anythingSelected = [...notes, ...bombs, ...obstacles].some((x) => !!x.selected); - } else if (args.view === View.LIGHTSHOW) { - const basicEvents = selectAllBasicEvents(state); - anythingSelected = [...basicEvents].some((x) => x.selected); - } - - if (anythingSelected) { - api.dispatch(deselectAllEntities({ view: args.view })); - } else { - api.dispatch(selectAllEntities({ songId: args.songId, view: args.view })); - } +export const deselectAllEntities = createThunk("deselectAllEntities", (_, api: GetShallowThunkAPI>) => { + return { view: selectActiveView(api.extra.getRouter()) }; }); - -export const deselectAllEntitiesOfType = createAction("deselectAllEntitiesOfType", (args: { itemType: ObjectType }) => { - return { payload: { ...args } }; +export const deselectAllEntitiesOfType = createThunk("deselectAllEntitiesOfType", (args: { itemType: ObjectType }, api: GetShallowThunkAPI>) => { + return { ...args, view: selectActiveView(api.extra.getRouter()) }; }); -export const selectAllEntitiesInRange = createAction("selectAllEntitiesInRange", (args: { songId: SongId; view: View; startBeat: number; endBeat: number }) => { - return { payload: { ...args } }; -}); +export const toggleSelectAllEntities = createAction("toggleSelectAllEntities"); -export const mirrorSelection = createAction("mirrorSelection", (args: { axis: "horizontal" | "vertical"; grid?: IGrid }) => { +export const mirrorSelection = createAction("mirrorSelection", (args: { axis: "horizontal" | "vertical" }) => { return { payload: { ...args } }; }); - -export const nudgeSelection = createAsyncThunk("nudgeSelection", (args: { direction: "forwards" | "backwards"; view: View }, api: GetThunkAPI) => { - const state = api.getState(); - const snapTo = selectSnap(state); - return api.fulfillWithValue({ ...args, amount: snapTo }); +export const nudgeSelection = createAction("nudgeSelection", (args: { direction: "forwards" | "backwards" }) => { + return { payload: { ...args } }; }); export const { setData: setClipboardData } = clipboard.actions; -export const cutSelection = createAsyncThunk("cutSelection", (args: { view: View }, api: GetThunkAPI) => { - const state = api.getState(); - const selection = selectSelectedBeatmapEntities(state, args.view); - api.dispatch(setClipboardData({ ...args, data: selection })); - return { ...args }; -}); -export const copySelection = createAsyncThunk("copySelection", (args: { view: View }, api: GetThunkAPI) => { - const state = api.getState(); - const selection = selectSelectedBeatmapEntities(state, args.view); - api.dispatch(setClipboardData({ ...args, data: selection })); - return { ...args }; -}); -export const pasteSelection = createAsyncThunk("pasteSelection", (args: { songId: SongId; view: View }, api: GetThunkAPI) => { - const state = api.getState(); - const data = selectClipboardData(state); - // If there's nothing copied, do nothing - if (!data) return api.rejectWithValue("Clipboard is empty."); - // when we're pasting, we need to deselect all currently selected entities - if (selectAnySelectedObjects(state) || selectAnySelectedEvents(state)) { - api.dispatch(deselectAllEntities({ view: args.view })); - } - // When pasting in notes view, we want to paste at the cursor position, where the song is currently playing. - // For the events view, we want to paste it where the mouse cursor is, the selected beat. - const pasteAtBeat = args.view === View.BEATMAP ? selectCursorPositionInBeats(state, args.songId) : selectEventsEditorCursor(state); - if (pasteAtBeat === null) return api.rejectWithValue("Invalid beat number."); - const earliestBeat = selectEarliestBeat(state); - const deltaBetweenPeriods = pasteAtBeat - earliestBeat; - // Every entity that has an ID (obstacles, events) needs a unique ID, we shouldn't blindly copy it over. - return api.fulfillWithValue({ ...args, data: data, deltaBetweenPeriods }); -}); +export const cutSelection = createAction("cutSelection"); +export const copySelection = createAction("copySelection"); +export const pasteSelection = createAction("pasteSelection"); export const { addOne: addBookmark, removeOne: removeBookmark } = bookmarks.actions; diff --git a/src/store/features/beatmap.slice.ts b/src/store/features/beatmap.slice.ts index b66ff123..59644f83 100644 --- a/src/store/features/beatmap.slice.ts +++ b/src/store/features/beatmap.slice.ts @@ -40,8 +40,8 @@ const slice = createSlice({ hydrateGridPresets: api.reducer>((state, action) => { return { ...state, gridPresets: action.payload }; }), - upsertGridPreset: api.reducer<{ presetSlot: string; grid: IGrid }>((state, action) => { - const { presetSlot: key, grid: value } = action.payload; + upsertGridPreset: api.reducer<{ slot: string; grid: IGrid }>((state, action) => { + const { slot: key, grid: value } = action.payload; return { ...state, gridPresets: { ...state.gridPresets, [key]: value } }; }), removeGridPreset: api.reducer<{ songId: SongId; presetSlot: string }>((state, action) => { diff --git a/src/store/features/clipboard.slice.ts b/src/store/features/clipboard.slice.ts index d4fda411..b4ae94a4 100644 --- a/src/store/features/clipboard.slice.ts +++ b/src/store/features/clipboard.slice.ts @@ -1,10 +1,9 @@ import { createSlice } from "@reduxjs/toolkit"; import { sortObjectFn } from "bsmap"; -import type { App, View } from "$/types"; +import type { App } from "$/types"; const initialState = { - view: null as View | null, data: {} as Partial>, }; diff --git a/src/store/features/events.slice.ts b/src/store/features/events.slice.ts index 8cdb3502..cc78b4fc 100644 --- a/src/store/features/events.slice.ts +++ b/src/store/features/events.slice.ts @@ -4,7 +4,7 @@ import { createHistoryAdapter, type HistoryState } from "history-adapter/redux"; import { isBasicEvent, isBoostEvent, isTrackGroupable, resolveEventId, resolveGroupTrackIds, resolveTrackIdForEvent } from "$/helpers/events.helpers"; import { nudgeItem } from "$/helpers/item.helpers"; -import { cutSelection, deselectAllEntities, drawEventSelectionBox, leaveEditor, loadBeatmapEntities, nudgeSelection, pasteSelection, selectAllEntities, selectAllEntitiesInRange, startLoadingMap } from "$/store/actions"; +import { deselectAllEntities, drawEventSelectionBox, leaveEditor, loadBeatmapEntities, selectAllEntities, selectAllEntitiesInRange, startLoadingMap } from "$/store/actions"; import { createEditorObjectAdapter } from "$/store/helpers/editor.helpers"; import { selectNextSnapshot, selectPrevSnapshot } from "$/store/helpers/selectors"; import { type App, View } from "$/types"; @@ -147,6 +147,23 @@ const slice = createSlice({ deselectBoostEvent: api.reducer<{ id: EntityId; environment: EnvironmentName; areLasersLocked?: boolean }>((state, action) => { boostEvents.updateOne(state.present.boostEvents, { id: action.payload.id, changes: { selected: false } }); }), + upsertEvents: api.reducer>( + history.undoable((state, action) => { + if (action.payload.basicEvents) { + basicEvents.upsertMany(state.basicEvents, action.payload.basicEvents); + } + if (action.payload.boostEvents) { + boostEvents.upsertMany(state.boostEvents, action.payload.boostEvents); + } + }), + ), + nudgeAllSelectedEvents: api.reducer<{ direction: "forwards" | "backwards"; amount: number }>( + history.undoable((state, action) => { + const { direction, amount } = action.payload; + updateAllSelectedBasicEvents(state.basicEvents, nudgeItem(direction, amount)); + updateAllSelectedBoostEvents(state.boostEvents, nudgeItem(direction, amount)); + }), + ), removeAllSelectedEvents: api.reducer( history.undoable((state) => { removeAllSelectedBasicEvents(state.basicEvents); @@ -160,42 +177,7 @@ const slice = createSlice({ basicEvents.setAll(state.present.basicEvents, action.payload.basicEvents ?? []); boostEvents.setAll(state.present.boostEvents, action.payload.boostEvents ?? []); }); - builder.addCase( - nudgeSelection.fulfilled, - history.undoable((state, action) => { - if (action.payload.view !== View.LIGHTSHOW) return state; - const { direction, amount } = action.payload; - updateAllSelectedBasicEvents(state.basicEvents, nudgeItem(direction, amount)); - updateAllSelectedBoostEvents(state.boostEvents, nudgeItem(direction, amount)); - }), - ); - builder.addCase( - cutSelection.fulfilled, - history.undoable((state, action) => { - if (action.payload.view !== View.LIGHTSHOW) return state; - removeAllSelectedBasicEvents(state.basicEvents); - removeAllSelectedBoostEvents(state.boostEvents); - }), - ); - builder.addCase( - pasteSelection.fulfilled, - history.undoable((state, action) => { - if (action.payload.view !== View.LIGHTSHOW) return state; - if (action.payload.data.basicEvents) { - basicEvents.upsertMany( - state.basicEvents, - action.payload.data.basicEvents.map((x) => ({ ...x, selected: true, time: x.time + action.payload.deltaBetweenPeriods })), - ); - } - if (action.payload.data.boostEvents) { - boostEvents.upsertMany( - state.boostEvents, - action.payload.data.boostEvents.map((x) => ({ ...x, selected: true, time: x.time + action.payload.deltaBetweenPeriods })), - ); - } - }), - ); - builder.addCase(selectAllEntities.fulfilled, (state, action) => { + builder.addCase(selectAllEntities, (state, action) => { if (action.payload.view !== View.LIGHTSHOW) return state; updateAllBasicEvents(state.present.basicEvents, () => ({ selected: true })); updateAllBoostEvents(state.present.boostEvents, () => ({ selected: true })); @@ -210,8 +192,8 @@ const slice = createSlice({ updateAllBasicEvents(state.present.basicEvents, (x) => ({ selected: x.time >= action.payload.startBeat - 0.01 && x.time < action.payload.endBeat })); updateAllBoostEvents(state.present.boostEvents, (x) => ({ selected: x.time >= action.payload.startBeat - 0.01 && x.time < action.payload.endBeat })); }); - builder.addCase(drawEventSelectionBox.fulfilled, (state, action) => { - const { tracks, selectionBoxInBeats, metadata } = action.payload; + builder.addCase(drawEventSelectionBox, (state, action) => { + const { window, tracks, selectionBoxInBeats } = action.payload; const allTracks = Object.keys(tracks).concat("5"); const allBasicEvents = basicSelectors.selectAll(state.present.basicEvents); @@ -229,7 +211,7 @@ const slice = createSlice({ } const allVisibleEvents = [...allBasicEvents, ...allBoostEvents].filter((x) => { - const isInWindow = x.time >= metadata.window.startBeat && x.time <= metadata.window.endBeat; + const isInWindow = x.time >= window.startBeat && x.time <= window.endBeat; const isInVisibleTracks = x.time >= selectionBoxInBeats.startBeat && x.time <= selectionBoxInBeats.endBeat; return isInWindow && isInVisibleTracks; }); diff --git a/src/store/features/objects.slice.ts b/src/store/features/objects.slice.ts index acd2e5f5..1f3dde07 100644 --- a/src/store/features/objects.slice.ts +++ b/src/store/features/objects.slice.ts @@ -5,10 +5,10 @@ import { createHistoryAdapter } from "history-adapter/redux"; import { mirrorBaseNoteProperties, mirrorGridObjectProperties, nudgeItem } from "$/helpers/item.helpers"; import { resolveNoteId } from "$/helpers/notes.helpers"; import { resolveObstacleId } from "$/helpers/obstacles.helpers"; -import { cutSelection, deselectAllEntities, deselectAllEntitiesOfType, leaveEditor, loadBeatmapEntities, mirrorSelection, nudgeSelection, pasteSelection, selectAllEntities, selectAllEntitiesInRange, startLoadingMap } from "$/store/actions"; +import { deselectAllEntities, deselectAllEntitiesOfType, leaveEditor, loadBeatmapEntities, selectAllEntities, selectAllEntitiesInRange, startLoadingMap } from "$/store/actions"; import { createEditorObjectAdapter } from "$/store/helpers/editor.helpers"; import { selectNextSnapshot, selectPrevSnapshot } from "$/store/helpers/selectors"; -import { type App, ObjectType, View } from "$/types"; +import { type App, type IGrid, ObjectType, View } from "$/types"; const notes = createEntityAdapter, EntityId>({ selectId: resolveNoteId, sortComparer: sortObjectFn }); const bombs = createEntityAdapter, EntityId>({ selectId: resolveNoteId, sortComparer: sortObjectFn }); @@ -146,11 +146,39 @@ const slice = createSlice({ }), updateAllSelectedObstacles: api.reducer<{ changes: Partial }>( history.undoable((state, action) => { - const entities = obstacleSelectors.selectAll(state.obstacles).filter((data) => !!data.selected); - obstacles.updateMany( - state.obstacles, - entities.map((data) => ({ id: obstacles.selectId(data), changes: action.payload.changes })), - ); + updateAllSelectedObstacles(state.obstacles, () => action.payload.changes); + }), + ), + upsertObjects: api.reducer>( + history.undoable((state, action) => { + if (action.payload.notes) { + notes.upsertMany(state.notes, action.payload.notes); + } + if (action.payload.bombs) { + bombs.upsertMany(state.bombs, action.payload.bombs); + } + if (action.payload.obstacles) { + obstacles.upsertMany(state.obstacles, action.payload.obstacles); + } + }), + ), + mirrorAllSelectedObjects: api.reducer<{ axis: "horizontal" | "vertical"; grid?: IGrid }>( + history.undoable((state, action) => { + const { axis, grid } = action.payload; + replaceAllSelectedColorNotes(state.notes, mirrorGridObjectProperties(axis, grid, 0)); + replaceAllSelectedColorNotes(state.notes, mirrorBaseNoteProperties(axis)); + replaceAllSelectedBombNotes(state.bombs, mirrorGridObjectProperties(axis, grid, 0)); + if (axis === "horizontal") { + replaceAllSelectedObstacles(state.obstacles, mirrorGridObjectProperties(axis, grid, 0)); + } + }), + ), + nudgeAllSelectedObjects: api.reducer<{ direction: "forwards" | "backwards"; amount: number }>( + history.undoable((state, action) => { + const { direction, amount } = action.payload; + updateAllSelectedColorNotes(state.notes, nudgeItem(direction, amount)); + updateAllSelectedBombNotes(state.bombs, nudgeItem(direction, amount)); + updateAllSelectedObstacles(state.obstacles, nudgeItem(direction, amount)); }), ), removeAllSelectedObjects: api.reducer( @@ -168,62 +196,7 @@ const slice = createSlice({ bombs.setAll(state.present.bombs, action.payload.bombs ?? []); obstacles.setAll(state.present.obstacles, action.payload.obstacles ?? []); }); - builder.addCase( - mirrorSelection, - history.undoable((state, action) => { - const { axis, grid } = action.payload; - replaceAllSelectedColorNotes(state.notes, mirrorGridObjectProperties(axis, grid, 0)); - replaceAllSelectedColorNotes(state.notes, mirrorBaseNoteProperties(axis)); - replaceAllSelectedBombNotes(state.bombs, mirrorGridObjectProperties(axis, grid, 0)); - if (axis === "horizontal") { - replaceAllSelectedObstacles(state.obstacles, mirrorGridObjectProperties(axis, grid, 0)); - } - }), - ); - builder.addCase( - nudgeSelection.fulfilled, - history.undoable((state, action) => { - if (action.payload.view !== View.BEATMAP) return state; - const { direction, amount } = action.payload; - updateAllSelectedColorNotes(state.notes, nudgeItem(direction, amount)); - updateAllSelectedBombNotes(state.bombs, nudgeItem(direction, amount)); - updateAllSelectedObstacles(state.obstacles, nudgeItem(direction, amount)); - }), - ); - builder.addCase( - cutSelection.fulfilled, - history.undoable((state, action) => { - if (action.payload.view !== View.BEATMAP) return state; - removeAllSelectedColorNotes(state.notes); - removeAllSelectedBombNotes(state.bombs); - removeAllSelectedObstacles(state.obstacles); - }), - ); - builder.addCase( - pasteSelection.fulfilled, - history.undoable((state, action) => { - if (action.payload.view !== View.BEATMAP) return state; - if (action.payload.data.notes) { - notes.upsertMany( - state.notes, - action.payload.data.notes.map((x) => ({ ...x, selected: true, time: x.time + action.payload.deltaBetweenPeriods })), - ); - } - if (action.payload.data.bombs) { - bombs.upsertMany( - state.bombs, - action.payload.data.bombs.map((x) => ({ ...x, selected: true, time: x.time + action.payload.deltaBetweenPeriods })), - ); - } - if (action.payload.data.obstacles) { - obstacles.upsertMany( - state.obstacles, - action.payload.data.obstacles.map((x) => ({ ...x, selected: true, time: x.time + action.payload.deltaBetweenPeriods })), - ); - } - }), - ); - builder.addCase(selectAllEntities.fulfilled, (state, action) => { + builder.addCase(selectAllEntities, (state, action) => { if (action.payload.view !== View.BEATMAP) return state; updateAllColorNotes(state.present.notes, () => ({ selected: true })); updateAllBombNotes(state.present.bombs, () => ({ selected: true })); diff --git a/src/store/features/songs.slice.ts b/src/store/features/songs.slice.ts index 549b024e..03fb374a 100644 --- a/src/store/features/songs.slice.ts +++ b/src/store/features/songs.slice.ts @@ -1,4 +1,4 @@ -import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, createEntityAdapter, createSelector, type EntityId, type GetThunkAPI, isAnyOf } from "@reduxjs/toolkit"; +import { type AsyncThunkPayloadCreator, asyncThunkCreator, buildCreateSlice, createEntityAdapter, createSelector, type EntityId, type GetThunkAPI } from "@reduxjs/toolkit"; import { distinct } from "@std/collections/distinct"; import { EnvironmentName, getBasicTracksForEnvironment } from "bsmap"; import { eventTypeRename } from "bsmap/extensions/renamer"; @@ -6,7 +6,7 @@ import { eventTypeRename } from "bsmap/extensions/renamer"; import { deserializeInfoContents } from "$/helpers/packaging.helpers"; import { createAppBeatmap, createAppSong, getColorScheme, getEnvironment, resolveSongId } from "$/helpers/song.helpers"; import { importMapArchiveToFilestore } from "$/services/packaging.service"; -import { finishLoadingMap, loadGridPreset, startLoadingMap } from "$/store/actions"; +import { finishLoadingMap, startLoadingMap } from "$/store/actions"; import type { AppThunkApiConfig } from "$/store/types"; import type { App, BeatmapId, IColorScheme, IGrid, SongId } from "$/types"; import { deepAssign } from "$/utils"; @@ -270,14 +270,6 @@ const slice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })( const { lastOpenedAt } = songData; return adapter.updateOne(state, { id: songId, changes: { lastOpenedAt } }); }); - builder.addMatcher(isAnyOf(loadGridPreset), (state, action) => { - const { songId, grid } = action.payload; - const song = selectById(state, songId); - return adapter.updateOne(state, { - id: songId, - changes: deepAssign(song, { modSettings: { mappingExtensions: { ...grid } } }), - }); - }); builder.addDefaultCase((state) => state); }, }); diff --git a/src/store/middleware/_setup.ts b/src/store/middleware/_setup.ts index 5f1bde3a..67e1508e 100644 --- a/src/store/middleware/_setup.ts +++ b/src/store/middleware/_setup.ts @@ -7,6 +7,8 @@ import { withFluxStandardMeta } from "$/store/utils/guards.utils"; import createAudioMiddleware from "./audio.middleware"; import createBackupMiddleware from "./backup.middleware"; import createDemoMiddleware from "./demo.middleware"; +import createEditorMiddleware from "./editor.middleware"; +import createEntitiesMiddleware from "./entities.middleware"; import createFileMiddleware from "./file.middleware"; import createHistoryMiddleware from "./history.middleware"; import createPackagingMiddleware from "./packaging.middleware"; @@ -28,13 +30,15 @@ export function createAppMiddleware({ extraArgument: extra }: Options) { const songSample = new AudioSample(audioContext, { volume: 1, playbackRate: 1 }); const tickSample = new AudioSample(audioContext, { volume: 1, playbackRate: 1 }); + const editorMiddleware = createEditorMiddleware({ extra }); const audioMiddleware = createAudioMiddleware({ songSample, tickSample, extra }); const playbackMiddleware = createPlaybackMiddleware({ songSample, extra }); const fileMiddleware = createFileMiddleware({ extra }); const downloadMiddleware = createPackagingMiddleware({ extra }); const demoMiddleware = createDemoMiddleware({ extra }); + const entitiesMiddleware = createEntitiesMiddleware({ extra }); const historyMiddleware = createHistoryMiddleware({ extra }); const backupMiddleware = createBackupMiddleware({ extra }); - return [stateSyncMiddleware as Middleware, audioMiddleware, playbackMiddleware, fileMiddleware, downloadMiddleware, demoMiddleware, historyMiddleware, backupMiddleware]; + return [stateSyncMiddleware as Middleware, editorMiddleware, audioMiddleware, playbackMiddleware, fileMiddleware, downloadMiddleware, demoMiddleware, entitiesMiddleware, historyMiddleware, backupMiddleware]; } diff --git a/src/store/middleware/editor.middleware.ts b/src/store/middleware/editor.middleware.ts new file mode 100644 index 00000000..5af178c1 --- /dev/null +++ b/src/store/middleware/editor.middleware.ts @@ -0,0 +1,88 @@ +import { createListenerMiddleware, isAnyOf } from "@reduxjs/toolkit"; + +import { SNAPPING_INCREMENT_VALUES, ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN } from "$/constants/editor.constants"; +import { + decrementEventsEditorZoomLevel, + decrementPlaybackRate, + decrementSnap, + decrementSongVolume, + decrementTickVolume, + incrementEventsEditorZoomLevel, + incrementPlaybackRate, + incrementSnap, + incrementSongVolume, + incrementTickVolume, + loadGridPreset, + saveGridPreset, + updateEventsEditorZoomLevel, + updateGridSize, + updatePlaybackRate, + updateSnap, + updateSongVolume, + updateTickVolume, + upsertGridPreset, +} from "$/store/actions"; +import { selectActiveSongId } from "$/store/helpers/route.helpers"; +import { selectEventsEditorZoomLevel, selectGridPresetById, selectGridSize, selectPlaybackRate, selectSnap, selectSongVolume, selectTickVolume } from "$/store/selectors"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; +import { createIncrementByIndexPayloadActionCreator, createIncrementByValuePayloadActionCreator } from "$/store/utils/thunk.utils"; + +interface Options { + extra: Pick; +} + +export default function createEditorMiddleware({ extra }: Options) { + const instance = createListenerMiddleware({ extra }); + + instance.startListening({ + matcher: isAnyOf(incrementSnap, decrementSnap), + effect: (action, api) => { + return createIncrementByIndexPayloadActionCreator(SNAPPING_INCREMENT_VALUES, selectSnap, updateSnap)({ delta: incrementSnap.match(action) ? 1 : -1 }, api); + }, + }); + instance.startListening({ + matcher: isAnyOf(incrementPlaybackRate, decrementPlaybackRate), + effect: (action, api) => { + return createIncrementByValuePayloadActionCreator([0, 2], selectPlaybackRate, updatePlaybackRate)({ delta: incrementPlaybackRate.match(action) ? 0.25 : -0.25 }, api); + }, + }); + instance.startListening({ + matcher: isAnyOf(incrementSongVolume, decrementSongVolume), + effect: (action, api) => { + return createIncrementByValuePayloadActionCreator([0, 1], selectSongVolume, updateSongVolume)({ delta: incrementSongVolume.match(action) ? 0.125 : -0.125 }, api); + }, + }); + instance.startListening({ + matcher: isAnyOf(incrementTickVolume, decrementTickVolume), + effect: (action, api) => { + return createIncrementByValuePayloadActionCreator([0, 1], selectTickVolume, updateTickVolume)({ delta: incrementPlaybackRate.match(action) ? 0.125 : -0.125 }, api); + }, + }); + + instance.startListening({ + actionCreator: loadGridPreset, + effect: (action, api) => { + const songId = selectActiveSongId(api.extra.getRouter()); + const gridFromPreset = selectGridPresetById(api.getState(), action.payload.slot); + api.dispatch(updateGridSize({ songId, changes: gridFromPreset })); + }, + }); + instance.startListening({ + actionCreator: saveGridPreset, + effect: (action, api) => { + const songId = selectActiveSongId(api.extra.getRouter()); + const state = api.getState(); + const grid = selectGridSize(state, songId); + api.dispatch(upsertGridPreset({ ...action.payload, grid })); + }, + }); + + instance.startListening({ + matcher: isAnyOf(incrementEventsEditorZoomLevel, decrementEventsEditorZoomLevel), + effect: (action, api) => { + return createIncrementByValuePayloadActionCreator([ZOOM_LEVEL_MIN, ZOOM_LEVEL_MAX], selectEventsEditorZoomLevel, updateEventsEditorZoomLevel)({ delta: incrementEventsEditorZoomLevel.match(action) ? 1 : -1 }, api); + }, + }); + + return instance.middleware; +} diff --git a/src/store/middleware/entities.middleware.ts b/src/store/middleware/entities.middleware.ts new file mode 100644 index 00000000..ed374775 --- /dev/null +++ b/src/store/middleware/entities.middleware.ts @@ -0,0 +1,158 @@ +import { createListenerMiddleware, isAnyOf } from "@reduxjs/toolkit"; + +import { + copySelection, + cutSelection, + deselectAllEntities, + mirrorAllSelectedObjects, + mirrorSelection, + nudgeAllSelectedEvents, + nudgeAllSelectedObjects, + nudgeSelection, + pasteSelection, + removeAllSelectedEvents, + removeAllSelectedObjects, + selectAllEntities, + selectAllEntitiesInRange, + setClipboardData, + toggleSelectAllEntities, + upsertEvents, + upsertObjects, +} from "$/store/actions"; +import { selectActiveSongId, selectActiveView } from "$/store/helpers/route.helpers"; +import { selectAnySelectedEvents, selectAnySelectedObjects, selectClipboardData, selectCursorPositionInBeats, selectEarliestBeat, selectEventEditorStartAndEndBeat, selectEventsEditorCursor, selectGridSize, selectSelectedBeatmapEntities, selectSnap } from "$/store/selectors"; +import type { AppDispatch, AppExtraArgs, RootState } from "$/store/types"; +import { View } from "$/types"; + +interface Options { + extra: Pick; +} + +export default function createEntitiesMiddleware({ extra }: Options) { + const instance = createListenerMiddleware({ extra }); + + instance.startListening({ + actionCreator: toggleSelectAllEntities, + effect: (_, api) => { + const view = selectActiveView(api.extra.getRouter()); + const isAnythingSelected = (view === View.LIGHTSHOW ? selectAnySelectedEvents : selectAnySelectedObjects)(api.getState()); + + if (isAnythingSelected) { + api.dispatch(deselectAllEntities()); + } else { + switch (view) { + case View.LIGHTSHOW: { + const songId = selectActiveSongId(api.extra.getRouter()); + const { startBeat, endBeat } = selectEventEditorStartAndEndBeat(api.getState(), songId); + api.dispatch(selectAllEntitiesInRange({ startBeat, endBeat })); + break; + } + default: { + api.dispatch(selectAllEntities()); + break; + } + } + } + }, + }); + instance.startListening({ + actionCreator: mirrorSelection, + effect: (action, api) => { + const songId = selectActiveSongId(api.extra.getRouter()); + const view = selectActiveView(api.extra.getRouter()); + + switch (view) { + case View.BEATMAP: { + const grid = selectGridSize(api.getState(), songId); + api.dispatch(mirrorAllSelectedObjects({ ...action.payload, grid })); + break; + } + } + }, + }); + instance.startListening({ + actionCreator: nudgeSelection, + effect: (action, api) => { + const view = selectActiveView(api.extra.getRouter()); + const snapTo = selectSnap(api.getState()); + + switch (view) { + case View.BEATMAP: { + api.dispatch(nudgeAllSelectedObjects({ ...action.payload, amount: snapTo })); + break; + } + case View.LIGHTSHOW: { + api.dispatch(nudgeAllSelectedEvents({ ...action.payload, amount: snapTo })); + break; + } + } + }, + }); + instance.startListening({ + matcher: isAnyOf(cutSelection, copySelection), + effect: (_, api) => { + const state = api.getState(); + const view = selectActiveView(api.extra.getRouter()); + + api.dispatch(setClipboardData({ data: selectSelectedBeatmapEntities(state, view) })); + + switch (view) { + case View.BEATMAP: { + api.dispatch(removeAllSelectedObjects()); + break; + } + case View.LIGHTSHOW: { + api.dispatch(removeAllSelectedEvents()); + } + } + }, + }); + instance.startListening({ + actionCreator: pasteSelection, + effect: (_, api) => { + const state = api.getState(); + const view = selectActiveView(api.extra.getRouter()); + + // when we're pasting, we need to deselect all currently selected entities + if (selectAnySelectedObjects(state) || selectAnySelectedEvents(state)) { + api.dispatch(deselectAllEntities({ view: view })); + } + + const data = selectClipboardData(state); + const earliestBeat = selectEarliestBeat(state); + // When pasting in notes view, we want to paste at the cursor position, where the song is currently playing. + // For the events view, we want to paste it where the mouse cursor is, the selected beat. + switch (view) { + case View.BEATMAP: { + const songId = selectActiveSongId(api.extra.getRouter()); + const pasteAtBeat = selectCursorPositionInBeats(state, songId); + const deltaBetweenPeriods = pasteAtBeat - earliestBeat; + + api.dispatch( + upsertObjects({ + notes: data.notes?.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), + bombs: data.bombs?.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), + obstacles: data.obstacles?.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), + }), + ); + break; + } + case View.LIGHTSHOW: { + const songId = selectActiveSongId(api.extra.getRouter()); + const pasteAtBeat = selectEventsEditorCursor(state) ?? selectCursorPositionInBeats(state, songId); + const deltaBetweenPeriods = pasteAtBeat - earliestBeat; + + api.dispatch( + upsertEvents({ + basicEvents: data.basicEvents?.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), + boostEvents: data.boostEvents?.map((x) => ({ ...x, selected: true, time: x.time + deltaBetweenPeriods })), + }), + ); + break; + } + } + }, + }); + + return instance.middleware; +} diff --git a/src/store/utils/thunk.utils.ts b/src/store/utils/thunk.utils.ts index f142d951..0fc46ce4 100644 --- a/src/store/utils/thunk.utils.ts +++ b/src/store/utils/thunk.utils.ts @@ -68,18 +68,18 @@ export function createThunk; } -export function createIncrementByIndexPayloadActionCreator(iterable: Iterable, options: { select: (state: TState) => TValue; update: (value: TValue) => UnknownAction }) { +export function createIncrementByIndexPayloadActionCreator(iterable: Iterable, select: (state: TState) => TValue, update: (value: TValue) => UnknownAction) { const values = Object.values(iterable); return (args: { delta: number }, api: { getState: () => TState; dispatch: Dispatch }) => { - const value = options.select(api.getState()); - api.dispatch(options.update(cycle(values, value, args.delta, "stop"))); + const value = select(api.getState()); + api.dispatch(update(cycle(values, value, args.delta, "stop"))); }; } -export function createIncrementByValuePayloadActionCreator([min, max]: [number, number], options: { select: (state: TState) => number; update: (value: number) => UnknownAction }) { +export function createIncrementByValuePayloadActionCreator([min, max]: [number, number], select: (state: TState) => number, update: (value: number) => UnknownAction) { return (args: { delta: number }, api: { getState: () => TState; dispatch: Dispatch }) => { - const value = options.select(api.getState()); + const value = select(api.getState()); const isIncrement = args.delta > 0; - api.dispatch(options.update((isIncrement ? Math.min : Math.max)(value + args.delta, isIncrement ? max : min))); + api.dispatch(update((isIncrement ? Math.min : Math.max)(value + args.delta, isIncrement ? max : min))); }; } From 2f0c6f23e579545bc085144ecddd81def265677b Mon Sep 17 00:00:00 2001 From: officialMECH Date: Tue, 18 Aug 2026 11:56:58 -0500 Subject: [PATCH 23/27] consolidate editor lifecycle logic into imperative actions --- .../app/templates/download/index.tsx | 2 +- .../app/templates/home/first-time.tsx | 17 ++- .../app/templates/shortcuts/default.tsx | 14 +-- .../app/templates/tables/songs/actions.tsx | 2 +- src/helpers/audio.helpers.ts | 40 ++++--- src/routes/editor/layout.tsx | 12 +- src/store/actions.ts | 58 ++++++---- src/store/features/bookmarks.slice.ts | 11 +- src/store/features/events.slice.ts | 22 ++-- src/store/features/global.slice.ts | 4 +- src/store/features/navigation.slice.ts | 4 +- src/store/features/objects.slice.ts | 26 ++--- src/store/features/songs.slice.ts | 20 +--- src/store/features/timeline.slice.ts | 15 +-- src/store/features/user.slice.ts | 7 +- src/store/features/visualizer.slice.ts | 9 +- src/store/middleware/_setup.ts | 4 +- src/store/middleware/audio.middleware.ts | 20 ++-- src/store/middleware/backup.middleware.ts | 108 ++++++------------ src/store/middleware/demo.middleware.ts | 35 ------ src/store/middleware/file.middleware.ts | 67 ++--------- src/store/middleware/packaging.middleware.ts | 51 ++++++--- src/store/middleware/playback.middleware.ts | 24 +--- src/store/types.ts | 2 +- 24 files changed, 217 insertions(+), 357 deletions(-) delete mode 100644 src/store/middleware/demo.middleware.ts 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/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 } })))} />}>