From d37de3cbf2ff56bec75abcbdebcbf3ef4d2aafeb Mon Sep 17 00:00:00 2001 From: Sig <62321214+sigprogramming@users.noreply.github.com> Date: Thu, 12 Dec 2024 01:15:52 +0900 Subject: [PATCH] =?UTF-8?q?MoveNoteState=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stateMachine/sequencerStateMachine.ts | 260 ++++++++++++++++-- 1 file changed, 244 insertions(+), 16 deletions(-) diff --git a/src/sing/stateMachine/sequencerStateMachine.ts b/src/sing/stateMachine/sequencerStateMachine.ts index 7091f1bf3d..fe48834eda 100644 --- a/src/sing/stateMachine/sequencerStateMachine.ts +++ b/src/sing/stateMachine/sequencerStateMachine.ts @@ -9,10 +9,13 @@ import { getButton, getDoremiFromNoteNumber, isSelfEventTarget, + keyInfos, PREVIEW_SOUND_DURATION, } from "@/sing/viewHelper"; import { Note, SequencerEditTarget } from "@/store/type"; -import { NoteId } from "@/type/preload"; +import { NoteId, TrackId } from "@/type/preload"; +import { getOrThrow } from "@/helpers/mapHelper"; +import { isOnCommandOrCtrlKeyDown } from "@/store/utility"; export type PositionOnSequencer = { readonly ticks: number; @@ -47,6 +50,9 @@ type Input = type ComputedRefs = { readonly snapTicks: ComputedRef; readonly editTarget: ComputedRef; + readonly selectedTrackId: ComputedRef; + readonly notesInSelectedTrack: ComputedRef; + readonly selectedNoteIds: ComputedRef>; }; type Refs = { @@ -57,14 +63,16 @@ type Refs = { type StoreActions = { readonly deselectAllNotes: () => void; - readonly commandAddNotes: (notes: Note[]) => void; + readonly deselectNotes: (noteIds: NoteId[]) => void; + readonly commandAddNotes: (notes: Note[], trackId: TrackId) => void; + readonly commandUpdateNotes: (notes: Note[], trackId: TrackId) => void; readonly selectNotes: (noteIds: NoteId[]) => void; readonly playPreviewSound: (noteNumber: number, duration?: number) => void; }; type Context = ComputedRefs & Refs & { readonly storeActions: StoreActions }; -type State = IdleState | AddNoteState; +type State = IdleState | AddNoteState | MoveNoteState; const getGuideLineTicks = ( cursorPos: PositionOnSequencer, @@ -76,9 +84,54 @@ const getGuideLineTicks = ( return Math.round(cursorTicks / snapTicks - 0.25) * snapTicks; }; +const selectOnlyThisNote = (context: Context, note: Note) => { + void context.storeActions.deselectAllNotes(); + void context.storeActions.selectNotes([note.id]); + void context.storeActions.playPreviewSound(note.noteNumber, PREVIEW_SOUND_DURATION); +}; + class IdleState implements IState { readonly id = "idle"; + private executeNotesSelectionProcess(context: Context, mouseEvent: MouseEvent, mouseDownNote: Note) { + if (mouseEvent.shiftKey) { + // Shiftキーが押されている場合は選択ノートまでの範囲選択 + let minIndex = context.notesInSelectedTrack.value.length - 1; + let maxIndex = 0; + for (let i = 0; i < context.notesInSelectedTrack.value.length; i++) { + const noteId = context.notesInSelectedTrack.value[i].id; + if (context.selectedNoteIds.value.has(noteId) || noteId === mouseDownNote.id) { + minIndex = Math.min(minIndex, i); + maxIndex = Math.max(maxIndex, i); + } + } + const noteIdsToSelect: NoteId[] = []; + for (let i = minIndex; i <= maxIndex; i++) { + const noteId = context.notesInSelectedTrack.value[i].id; + if (!context.selectedNoteIds.value.has(noteId)) { + noteIdsToSelect.push(noteId); + } + } + void context.storeActions.selectNotes(noteIdsToSelect); + } else if (isOnCommandOrCtrlKeyDown(mouseEvent)) { + // CommandキーかCtrlキーが押されている場合 + if (context.selectedNoteIds.value.has(mouseDownNote.id)) { + // 選択中のノートなら選択解除 + void context.storeActions.deselectNotes([mouseDownNote.id]); + return; + } + // 未選択のノートなら選択に追加 + void context.storeActions.selectNotes([mouseDownNote.id]); + } else if (!context.selectedNoteIds.value.has(mouseDownNote.id)) { + // 選択中のノートでない場合は選択状態にする + void selectOnlyThisNote(context, mouseDownNote); + } + } + + private getSelectedNotes(context: Context) { + return context.notesInSelectedTrack.value.filter(value => context.selectedNoteIds.value.has(value.id)); + } + onEnter() {} process({ @@ -91,14 +144,15 @@ class IdleState implements IState { setNextState: (nextState: State) => void; }) { const mouseButton = getButton(input.mouseEvent); - if (input.targetArea === "SequencerBody") { - context.guideLineTicks.value = getGuideLineTicks( - input.cursorPos, - context, - ); - if (input.mouseEvent.type === "mousedown") { - // TODO: メニューが表示されている場合はメニュー非表示のみ行いたい - if (context.editTarget.value === "NOTE") { + const selectedTrackId = context.selectedTrackId.value; + + if (context.editTarget.value === "NOTE") { + if (input.targetArea === "SequencerBody") { + context.guideLineTicks.value = getGuideLineTicks( + input.cursorPos, + context, + ); + if (input.mouseEvent.type === "mousedown") { if (!isSelfEventTarget(input.mouseEvent)) { return; } @@ -110,11 +164,27 @@ class IdleState implements IState { ) { return; } - setNextState(new AddNoteState(input.cursorPos)); + context.storeActions.deselectAllNotes(); + const addNoteState = new AddNoteState(input.cursorPos, selectedTrackId); + setNextState(addNoteState); + } + } + } else if (input.targetArea === "Note") { + if (input.mouseEvent.type === "mousedown") { + if (!isSelfEventTarget(input.mouseEvent)) { + return; + } + if (mouseButton === "LEFT_BUTTON") { + this.executeNotesSelectionProcess(context, input.mouseEvent, input.note); + const selectedNotes = this.getSelectedNotes(context); + const moveNoteState = new MoveNoteState(input.cursorPos, selectedTrackId, selectedNotes, input.note.id); + setNextState(moveNoteState); } } } } + + } onExit() {} @@ -124,6 +194,7 @@ class AddNoteState implements IState { readonly id = "addNote"; private readonly cursorPosAtStart: PositionOnSequencer; + private readonly targetTrackId: TrackId; private currentCursorPos: PositionOnSequencer; private innerContext: @@ -134,8 +205,10 @@ class AddNoteState implements IState { } | undefined; - constructor(cursorPosAtStart: PositionOnSequencer) { + constructor(cursorPosAtStart: PositionOnSequencer, targetTrackId: TrackId) { this.cursorPosAtStart = cursorPosAtStart; + this.targetTrackId = targetTrackId; + this.currentCursorPos = cursorPosAtStart; } @@ -166,8 +239,6 @@ class AddNoteState implements IState { } onEnter(context: Context) { - context.storeActions.deselectAllNotes(); - const guideLineTicks = getGuideLineTicks(this.cursorPosAtStart, context); const noteToAdd = { id: NoteId(crypto.randomUUID()), @@ -231,14 +302,171 @@ class AddNoteState implements IState { const previewNoteIds = previewNotes.map((value) => value.id); cancelAnimationFrame(this.innerContext.previewRequestId); - context.storeActions.commandAddNotes(context.previewNotes.value); + + context.storeActions.commandAddNotes(context.previewNotes.value, this.targetTrackId); + context.storeActions.selectNotes(previewNoteIds); + + if (previewNotes.length === 1) { + context.storeActions.playPreviewSound( + previewNotes[0].noteNumber, + PREVIEW_SOUND_DURATION, + ); + } + context.nowPreviewing.value = false; + } +} + +class MoveNoteState implements IState { + readonly id = "moveNote"; + + private readonly cursorPosAtStart: PositionOnSequencer; + private readonly targetTrackId: TrackId; + private readonly clonedTargetNotes: Map; + private readonly mouseDownNoteId: NoteId; + + private currentCursorPos: PositionOnSequencer; + + private innerContext: + | { + previewRequestId: number; + executePreviewProcess: boolean; + edited: boolean; + guideLineTicksAtStart: number; + } + | undefined; + + constructor(cursorPosAtStart: PositionOnSequencer, targetTrackId: TrackId, targetNotes: Note[], mouseDownNoteId: NoteId) { + if (!(targetNotes.some(value => value.id === mouseDownNoteId))) { + throw new Error("mouseDownNote is not included in targetNotes."); + } + this.cursorPosAtStart = cursorPosAtStart; + this.targetTrackId = targetTrackId; + this.clonedTargetNotes = new Map(); + for (const targetNote of targetNotes) { + this.clonedTargetNotes.set(targetNote.id, { ...targetNote }); + } + this.mouseDownNoteId = mouseDownNoteId; + + this.currentCursorPos = cursorPosAtStart; + } + + private previewMove(context: Context) { + if (this.innerContext == undefined) { + throw new Error("innerContext is undefined."); + } + const snapTicks = context.snapTicks.value; + const previewNotes = context.previewNotes.value; + const clonedTargetNotes = this.clonedTargetNotes; + const mouseDownNote = getOrThrow(clonedTargetNotes, this.mouseDownNoteId); + const dragTicks = this.currentCursorPos.ticks - this.cursorPosAtStart.ticks; + const notePos = mouseDownNote.position; + const newNotePos = Math.round((notePos + dragTicks) / snapTicks) * snapTicks; + const movingTicks = newNotePos - notePos; + const movingSemitones = this.currentCursorPos.noteNumber - this.cursorPosAtStart.noteNumber; + + const editedNotes = new Map(); + for (const note of previewNotes) { + const clonedTargetNote = clonedTargetNotes.get(note.id); + if (!clonedTargetNote) { + throw new Error("clonedTargetNote is undefined."); + } + const position = clonedTargetNote.position + movingTicks; + const noteNumber = clonedTargetNote.noteNumber + movingSemitones; + if (note.position !== position || note.noteNumber !== noteNumber) { + editedNotes.set(note.id, { ...note, noteNumber, position }); + } + } + + for (const note of editedNotes.values()) { + if (note.noteNumber < 0 || note.noteNumber >= keyInfos.length) { + // MIDIキー範囲外へはドラッグしない + return; + } + if (note.position < 0) { + // 左端より前はドラッグしない + return; + } + } + + if (editedNotes.size !== 0) { + context.previewNotes.value = previewNotes.map((value) => { + return editedNotes.get(value.id) ?? value; + }); + this.innerContext.edited = true; + } + + context.guideLineTicks.value = this.innerContext.guideLineTicksAtStart + movingTicks; + } + + onEnter(context: Context) { + const guideLineTicks = getGuideLineTicks(this.cursorPosAtStart, context); + + context.previewNotes.value = [...(this.clonedTargetNotes.values())]; + context.nowPreviewing.value = true; + + const preview = () => { + if (this.innerContext == undefined) { + throw new Error("innerContext is undefined."); + } + if (this.innerContext.executePreviewProcess) { + this.previewMove(context); + this.innerContext.executePreviewProcess = false; + } + this.innerContext.previewRequestId = requestAnimationFrame(preview); + }; + const previewRequestId = requestAnimationFrame(preview); + + this.innerContext = { + executePreviewProcess: false, + previewRequestId, + edited: false, + guideLineTicksAtStart: guideLineTicks + }; + } + + process({ + input, + setNextState, + }: { + input: Input; + context: Context; + setNextState: (nextState: State) => void; + }) { + if (this.innerContext == undefined) { + throw new Error("innerContext is undefined."); + } + const mouseButton = getButton(input.mouseEvent); + if (input.targetArea === "SequencerBody") { + if (input.mouseEvent.type === "mousemove") { + this.currentCursorPos = input.cursorPos; + this.innerContext.executePreviewProcess = true; + } else if (input.mouseEvent.type === "mouseup") { + if (mouseButton === "LEFT_BUTTON") { + setNextState(new IdleState()); + } + } + } + } + + onExit(context: Context) { + if (this.innerContext == undefined) { + throw new Error("innerContext is undefined."); + } + const previewNotes = context.previewNotes.value; + const previewNoteIds = previewNotes.map((value) => value.id); + + cancelAnimationFrame(this.innerContext.previewRequestId); + + context.storeActions.commandUpdateNotes(previewNotes, this.targetTrackId); context.storeActions.selectNotes(previewNoteIds); + if (previewNotes.length === 1) { context.storeActions.playPreviewSound( previewNotes[0].noteNumber, PREVIEW_SOUND_DURATION, ); } + context.previewNotes.value = []; context.nowPreviewing.value = false; } }