Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ソング] Undo/Redoの実装 #1836

Merged
merged 36 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
822cb16
[wip] add song undo redo function
y-chan Feb 9, 2024
8525f05
improve last command time
y-chan Feb 9, 2024
80aec89
clear song commands too
y-chan Feb 9, 2024
f0d170a
add undo redo button to song toolbar
y-chan Feb 9, 2024
ae484b6
Merge branch 'main' into feat/undo-redo-in-song-editor
y-chan Feb 18, 2024
4636ac8
resolve conflict
y-chan Feb 18, 2024
3e29803
fix vuex test
y-chan Feb 18, 2024
13cd984
Merge remote-tracking branch 'upstream/main' into feat/undo-redo-in-s…
y-chan Feb 18, 2024
ecd8bbe
improve command set singer (add setup singer)
y-chan Feb 18, 2024
22056db
refactor set tempo
y-chan Feb 18, 2024
c9ef19a
refactor remove tempo
y-chan Feb 18, 2024
e99de74
refactor set time signature
y-chan Feb 18, 2024
d2b3131
refactor remove time signature
y-chan Feb 18, 2024
1e681f0
refactor add notes
y-chan Feb 18, 2024
f0f9d31
refactor update notes
y-chan Feb 18, 2024
774abcb
refactor remove notes
y-chan Feb 18, 2024
5767402
remove remove selected notes
y-chan Feb 18, 2024
07bdf79
remove comment
y-chan Feb 18, 2024
c86ee16
add COMMAND_SET_VOICE_KEY_SHIFT
y-chan Feb 18, 2024
e8b7665
revert comments
y-chan Feb 19, 2024
f52aee7
remove song
y-chan Feb 19, 2024
4a21141
create overlapping note infos type
y-chan Feb 19, 2024
e878b27
remove overlapping notes detector
y-chan Feb 19, 2024
1124ae8
add return type
y-chan Feb 19, 2024
c81dfbd
remove comment
y-chan Feb 19, 2024
64964a7
remove copy and modify destructively
y-chan Feb 19, 2024
b05cc85
add editor type and use it
y-chan Feb 19, 2024
e5cd7e9
integrate undo redo queues of talk and song
y-chan Feb 19, 2024
0651744
must editor type args
y-chan Feb 19, 2024
0a74ca3
refactor LAST_COMMAND_UNIX_MILLISEC
y-chan Feb 19, 2024
4fa1b45
fix store test
y-chan Feb 19, 2024
e7b8f51
update unit test
y-chan Feb 19, 2024
ad479b8
remove export
y-chan Feb 19, 2024
6daef83
move func
y-chan Feb 19, 2024
b698d04
Merge branch 'main' into feat/undo-redo-in-song-editor
y-chan Feb 19, 2024
ed4c761
デザイン微調整
Hiroshiba Feb 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/Sing/CharacterMenuButton/MenuButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ const changeStyleId = (speakerUuid: SpeakerId, styleId: StyleId) => {
`No engineId for target character style (speakerUuid == ${speakerUuid}, styleId == ${styleId})`
);

store.dispatch("SET_SINGER", { singer: { engineId, styleId } });
store.dispatch("COMMAND_SET_SINGER", { singer: { engineId, styleId } });
};

const getDefaultStyle = (speakerUuid: string) => {
Expand Down
16 changes: 8 additions & 8 deletions src/components/Sing/ScoreSequencer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -732,12 +732,12 @@ const onMouseUp = (event: MouseEvent) => {
cancelAnimationFrame(previewRequestId);
if (edited) {
if (previewMode === "ADD") {
store.dispatch("ADD_NOTES", { notes: previewNotes.value });
store.dispatch("COMMAND_ADD_NOTES", { notes: previewNotes.value });
store.dispatch("SELECT_NOTES", {
noteIds: previewNotes.value.map((value) => value.id),
});
} else {
store.dispatch("UPDATE_NOTES", { notes: previewNotes.value });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: previewNotes.value });
}
if (previewNotes.value.length === 1) {
store.dispatch("PLAY_PREVIEW_SOUND", {
Expand Down Expand Up @@ -782,7 +782,7 @@ const handleNotesArrowUp = () => {
if (editedNotes.some((note) => note.noteNumber > 127)) {
return;
}
store.dispatch("UPDATE_NOTES", { notes: editedNotes });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes });

if (editedNotes.length === 1) {
store.dispatch("PLAY_PREVIEW_SOUND", {
Expand All @@ -801,7 +801,7 @@ const handleNotesArrowDown = () => {
if (editedNotes.some((note) => note.noteNumber < 0)) {
return;
}
store.dispatch("UPDATE_NOTES", { notes: editedNotes });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes });

if (editedNotes.length === 1) {
store.dispatch("PLAY_PREVIEW_SOUND", {
Expand All @@ -821,7 +821,7 @@ const handleNotesArrowRight = () => {
// TODO: 例外処理は`UPDATE_NOTES`内に移す?
return;
}
store.dispatch("UPDATE_NOTES", { notes: editedNotes });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes });
};

const handleNotesArrowLeft = () => {
Expand All @@ -836,15 +836,15 @@ const handleNotesArrowLeft = () => {
) {
return;
}
store.dispatch("UPDATE_NOTES", { notes: editedNotes });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: editedNotes });
};

const handleNotesBackspaceOrDelete = () => {
if (state.selectedNoteIds.size === 0) {
// TODO: 例外処理は`REMOVE_SELECTED_NOTES`内に移す?
// TODO: 例外処理は`COMMAND_REMOVE_SELECTED_NOTES`内に移す?
return;
}
store.dispatch("REMOVE_SELECTED_NOTES");
store.dispatch("COMMAND_REMOVE_SELECTED_NOTES");
};

const handleKeydown = (event: KeyboardEvent) => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/Sing/SequencerNote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ const lyric = computed({
return;
}
const note: Note = { ...props.note, lyric: value };
store.dispatch("UPDATE_NOTES", { notes: [note] });
store.dispatch("COMMAND_UPDATE_NOTES", { notes: [note] });
},
});
const showLyricInput = computed(() => {
Expand All @@ -123,7 +123,7 @@ const contextMenuData = ref<[MenuItemButton]>([
label: "削除",
onClick: async () => {
contextMenu.value?.hide();
store.dispatch("REMOVE_SELECTED_NOTES");
store.dispatch("COMMAND_REMOVE_SELECTED_NOTES");
},
disableWhenUiLocked: true,
},
Expand Down
19 changes: 16 additions & 3 deletions src/components/Sing/ToolBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@
.{{ playHeadPositionMilliSecStr }}
</div>
</div>
<!-- undo/redo -->
<q-btn flat round icon="undo" :disable="!canSongUndo" @click="songUndo" />
<q-btn flat round icon="redo" :disable="!canSongRedo" @click="songRedo" />
</div>
<!-- settings for edit controls -->
<div class="sing-controls">
Expand Down Expand Up @@ -118,6 +121,16 @@ import CharacterMenuButton from "@/components/Sing/CharacterMenuButton/MenuButto

const store = useStore();

const canSongUndo = computed(() => store.getters.CAN_SONG_UNDO);
const canSongRedo = computed(() => store.getters.CAN_SONG_REDO);

const songUndo = () => {
store.dispatch("SONG_UNDO");
};
const songRedo = () => {
store.dispatch("SONG_REDO");
};
y-chan marked this conversation as resolved.
Show resolved Hide resolved

const tempos = computed(() => store.state.tempos);
const timeSignatures = computed(() => store.state.timeSignatures);
const keyShift = computed(() => store.getters.SELECTED_TRACK.voiceKeyShift);
Expand Down Expand Up @@ -182,7 +195,7 @@ const setKeyShiftInputBuffer = (keyShiftStr: string | number | null) => {

const setTempo = () => {
const bpm = bpmInputBuffer.value;
store.dispatch("SET_TEMPO", {
store.dispatch("COMMAND_SET_TEMPO", {
tempo: {
position: 0,
bpm,
Expand All @@ -193,7 +206,7 @@ const setTempo = () => {
const setTimeSignature = () => {
const beats = beatsInputBuffer.value;
const beatType = beatTypeInputBuffer.value;
store.dispatch("SET_TIME_SIGNATURE", {
store.dispatch("COMMAND_SET_TIME_SIGNATURE", {
timeSignature: {
measureNumber: 1,
beats,
Expand All @@ -204,7 +217,7 @@ const setTimeSignature = () => {

const setKeyShift = () => {
const voiceKeyShift = keyShiftInputBuffer.value;
store.dispatch("SET_VOICE_KEY_SHIFT", { voiceKeyShift });
store.dispatch("COMMAND_SET_VOICE_KEY_SHIFT", { voiceKeyShift });
};

const playheadTicks = ref(0);
Expand Down
62 changes: 34 additions & 28 deletions src/sing/storeHelper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Note, Singer, Tempo } from "@/store/type";
import { Note, NoteInfo, Singer, Tempo } from "@/store/type";
import { generateHash } from "@/sing/utility";

export const DEFAULT_TPQN = 480;
Expand Down Expand Up @@ -54,30 +54,26 @@ export class FrequentlyUpdatedState<T> {
}
}

type NoteInfo = {
startTicks: number;
endTicks: number;
overlappingNoteIds: Set<string>;
};

/**
* 重なっているノートを検出します。
*/
export class OverlappingNotesDetector {
private readonly noteInfos = new Map<string, NoteInfo>();

addNotes(notes: Note[]) {
addNotes(
prevNoteInfos: Map<string, NoteInfo>,
y-chan marked this conversation as resolved.
Show resolved Hide resolved
notes: Note[]
): Map<string, NoteInfo> {
const currentNoteInfos = new Map(prevNoteInfos.entries());
for (const note of notes) {
y-chan marked this conversation as resolved.
Show resolved Hide resolved
this.noteInfos.set(note.id, {
currentNoteInfos.set(note.id, {
startTicks: note.position,
endTicks: note.position + note.duration,
overlappingNoteIds: new Set<string>(),
overlappingNoteIds: new Set(),
});
}
// TODO: 計算量がO(n^2)になっているので、区間木などを使用してO(nlogn)にする
for (const note of notes) {
const overlappingNoteIds = new Set<string>();
for (const [noteId, noteInfo] of this.noteInfos) {
for (const [noteId, noteInfo] of currentNoteInfos) {
if (noteId === note.id) {
continue;
}
Expand All @@ -91,30 +87,37 @@ export class OverlappingNotesDetector {
}

const noteId1 = note.id;
const noteInfo1 = this.noteInfos.get(noteId1);
const noteInfo1 = currentNoteInfos.get(noteId1);
if (!noteInfo1) {
throw new Error("noteInfo1 is undefined.");
}
for (const noteId2 of overlappingNoteIds) {
const noteInfo2 = this.noteInfos.get(noteId2);
const noteInfo2 = currentNoteInfos.get(noteId2);
if (!noteInfo2) {
throw new Error("noteInfo2 is undefined.");
}
noteInfo2.overlappingNoteIds.add(noteId1);
noteInfo1.overlappingNoteIds.add(noteId2);
}
}

return currentNoteInfos;
}

removeNotes(notes: Note[]) {
removeNotes(
prevNoteInfos: Map<string, NoteInfo>,
notes: Note[]
): Map<string, NoteInfo> {
const currentNoteInfos = new Map(prevNoteInfos.entries());

for (const note of notes) {
const noteId1 = note.id;
const noteInfo1 = this.noteInfos.get(noteId1);
const noteInfo1 = currentNoteInfos.get(noteId1);
if (!noteInfo1) {
throw new Error("noteInfo1 is undefined.");
}
for (const noteId2 of noteInfo1.overlappingNoteIds) {
const noteInfo2 = this.noteInfos.get(noteId2);
const noteInfo2 = currentNoteInfos.get(noteId2);
if (!noteInfo2) {
throw new Error("noteInfo2 is undefined.");
}
Expand All @@ -123,26 +126,29 @@ export class OverlappingNotesDetector {
}
}
for (const note of notes) {
this.noteInfos.delete(note.id);
currentNoteInfos.delete(note.id);
}

return currentNoteInfos;
}

updateNotes(notes: Note[]) {
this.removeNotes(notes);
this.addNotes(notes);
updateNotes(
prevNoteInfos: Map<string, NoteInfo>,
notes: Note[]
): Map<string, NoteInfo> {
let currentNoteInfos = this.removeNotes(prevNoteInfos, notes);
currentNoteInfos = this.addNotes(currentNoteInfos, notes);

return currentNoteInfos;
}

getOverlappingNoteIds() {
getOverlappingNoteIds(currentNoteInfos: Map<string, NoteInfo>) {
const overlappingNoteIds = new Set<string>();
for (const [noteId, noteInfo] of this.noteInfos) {
for (const [noteId, noteInfo] of currentNoteInfos) {
if (noteInfo.overlappingNoteIds.size !== 0) {
overlappingNoteIds.add(noteId);
}
}
return overlappingNoteIds;
}

clear() {
this.noteInfos.clear();
}
}
83 changes: 74 additions & 9 deletions src/store/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,13 @@ export type PayloadRecipeTree<S, M> = {
* @returns Mutationを持つオブジェクト(MutationTree)
*/
export const createCommandMutationTree = <S, M extends MutationsBase>(
payloadRecipeTree: PayloadRecipeTree<S, M>
payloadRecipeTree: PayloadRecipeTree<S, M>,
isSongCommand: boolean
y-chan marked this conversation as resolved.
Show resolved Hide resolved
): MutationTree<S, M> =>
Object.fromEntries(
Object.entries(payloadRecipeTree).map(([key, val]) => [
key,
createCommandMutation(val),
createCommandMutation(val, isSongCommand),
])
) as MutationTree<S, M>;

Expand All @@ -53,13 +54,19 @@ export const createCommandMutationTree = <S, M extends MutationsBase>(
*/
export const createCommandMutation =
<S extends State, M extends MutationsBase, K extends keyof M>(
payloadRecipe: PayloadRecipe<S, M[K]>
payloadRecipe: PayloadRecipe<S, M[K]>,
isSongCommand: boolean
): Mutation<S, M, K> =>
(state: S, payload: M[K]): void => {
const command = recordPatches(payloadRecipe)(state, payload);
applyPatchesImpl(state, command.redoPatches);
state.undoCommands.push(command);
state.redoCommands.splice(0);
if (isSongCommand) {
state.undoSongCommands.push(command);
state.redoSongCommands.splice(0);
} else {
state.undoCommands.push(command);
state.redoCommands.splice(0);
}
};
y-chan marked this conversation as resolved.
Show resolved Hide resolved

/**
Expand All @@ -83,6 +90,8 @@ const recordPatches =
export const commandStoreState: CommandStoreState = {
undoCommands: [],
redoCommands: [],
undoSongCommands: [],
redoSongCommands: [],
y-chan marked this conversation as resolved.
Show resolved Hide resolved
};

export const commandStore = createPartialStore<CommandStoreTypes>({
Expand Down Expand Up @@ -124,20 +133,76 @@ export const commandStore = createPartialStore<CommandStoreTypes>({
},
},

CAN_SONG_UNDO: {
getter(state) {
return state.undoSongCommands.length > 0;
},
},

CAN_SONG_REDO: {
getter(state) {
return state.redoSongCommands.length > 0;
},
},

SONG_UNDO: {
mutation(state) {
const command = state.undoSongCommands.pop();
if (command != null) {
state.redoSongCommands.push(command);
applyPatchesImpl(state, command.undoPatches);
}
},
action({ commit, dispatch }) {
commit("SONG_UNDO");
dispatch("RENDER");
},
},

SONG_REDO: {
mutation(state) {
const command = state.redoSongCommands.pop();
if (command != null) {
state.undoSongCommands.push(command);
applyPatchesImpl(state, command.redoPatches);
}
},
action({ commit, dispatch }) {
commit("SONG_REDO");
dispatch("RENDER");
},
},

LAST_COMMAND_UNIX_MILLISEC: {
getter(state) {
if (state.undoCommands.length === 0) {
return null;
} else {
return state.undoCommands[state.undoCommands.length - 1].unixMillisec;
let lastCommandTime: number | null = null;
let lastSongCommandTime: number | null = null;
if (state.undoCommands.length !== 0) {
lastCommandTime =
state.undoCommands[state.undoCommands.length - 1].unixMillisec;
}
if (state.undoSongCommands.length !== 0) {
lastSongCommandTime =
state.undoSongCommands[state.undoSongCommands.length - 1]
.unixMillisec;
}
if (lastCommandTime != null && lastSongCommandTime != null) {
return Math.max(lastCommandTime, lastSongCommandTime);
} else if (lastCommandTime != null) {
return lastCommandTime;
} else if (lastSongCommandTime != null) {
return lastSongCommandTime;
}
return null;
y-chan marked this conversation as resolved.
Show resolved Hide resolved
},
},

CLEAR_COMMANDS: {
mutation(state) {
state.redoCommands.splice(0);
state.undoCommands.splice(0);
state.redoSongCommands.splice(0);
state.undoSongCommands.splice(0);
},
},
});
Loading
Loading