diff --git a/package-lock.json b/package-lock.json index 91d10e7310..a1141f5927 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,6 +99,7 @@ "vite-plugin-node-polyfills": "0.21.0", "vite-tsconfig-paths": "4.2.1", "vitest": "2.1.2", + "vue-component-type-helpers": "2.1.6", "vue-tsc": "2.1.6", "yargs": "17.2.1" }, diff --git a/package.json b/package.json index f24383a13d..3aba259dec 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "vite-plugin-node-polyfills": "0.21.0", "vite-tsconfig-paths": "4.2.1", "vitest": "2.1.2", + "vue-component-type-helpers": "2.1.6", "vue-tsc": "2.1.6", "yargs": "17.2.1" } diff --git a/src/components/Dialog/TempoOrTimeSignatureChangeDialog.stories.ts b/src/components/Dialog/TempoOrTimeSignatureChangeDialog.stories.ts new file mode 100644 index 0000000000..795e786c3d --- /dev/null +++ b/src/components/Dialog/TempoOrTimeSignatureChangeDialog.stories.ts @@ -0,0 +1,231 @@ +import { userEvent, within, expect, fn } from "@storybook/test"; +import { Meta, StoryObj } from "@storybook/vue3"; + +import TempoOrTimeSignatureChangeDialog from "./TempoOrTimeSignatureChangeDialog.vue"; + +const meta: Meta = { + component: TempoOrTimeSignatureChangeDialog, + args: { + modelValue: true, + timeSignatureChange: undefined, + tempoChange: undefined, + mode: "add", + canDeleteTempo: true, + canDeleteTimeSignature: true, + + onOk: fn(), + "onUpdate:modelValue": fn(), + }, + tags: ["!autodocs"], // ダイアログ系はautodocsのプレビューが正しく表示されないので無効化 +}; + +export default meta; +type Story = StoryObj; + +export const CreateOpened: Story = { + name: "開いている:追加", + args: { + modelValue: true, + mode: "add", + }, +}; +export const ChangeOpened: Story = { + name: "開いている:変更", + args: { + modelValue: true, + timeSignatureChange: { + beats: 4, + beatType: 4, + }, + tempoChange: { + bpm: 120, + }, + mode: "edit", + }, +}; + +export const CannotCloseWithoutCreate: Story = { + name: "作成:追加されない状態だと閉じられない", + args: { ...CreateOpened.args }, + play: async () => { + const canvas = within(document.body); // ダイアログなので例外的にdocument.bodyを使う + + const button = canvas.getByRole("button", { name: /追加する/ }); + await expect(button).toBeDisabled(); + }, +}; +export const CannotCloseWithoutChange: Story = { + name: "変更:変更されない状態だと閉じられない", + args: { ...ChangeOpened.args }, + play: async () => { + const canvas = within(document.body); // ダイアログなので例外的にdocument.bodyを使う + + const button = canvas.getByRole("button", { name: /変更する/ }); + await expect(button).toBeDisabled(); + }, +}; +export const CannotCloseIfDeletionDisabled: Story = { + name: "変更:削除不可の場合は削除できない", + args: { + ...ChangeOpened.args, + canDeleteTempo: false, + canDeleteTimeSignature: false, + }, + play: async () => { + const canvas = within(document.body); // ダイアログなので例外的にdocument.bodyを使う + + const bpmToggle = canvas.getByRole("switch", { name: /BPM変更の有無/ }); + await expect(bpmToggle.getAttribute("aria-disabled")).toBe("true"); + + const timeSignatureToggle = canvas.getByRole("switch", { + name: /拍子変更の有無/, + }); + await expect(timeSignatureToggle.getAttribute("aria-disabled")).toBe( + "true", + ); + }, +}; +export const SayChangeIfExist: Story = { + name: "変更:どちらかが変更されていれば閉じられる", + args: { ...ChangeOpened.args }, + play: async (context) => { + const canvas = within(document.body); // ダイアログなので例外的にdocument.bodyを使う + + const okButton = canvas.getByRole("button", { name: /変更する/ }); + await context.step("BPMを変更する", async () => { + const input = canvas.getByLabelText("BPM"); + await userEvent.clear(input); + await userEvent.type(input, "100"); + + await expect(okButton).toBeEnabled(); + + await userEvent.clear(input); + await userEvent.type(input, "120"); + + await expect(okButton).toBeDisabled(); + }); + + await context.step("拍子を変更する", async () => { + const findOption = (text: string) => { + const maybeElement = canvas + .getAllByRole("option") + .find((el) => el.textContent === text); + if (!maybeElement) throw new Error("Element not found"); + return maybeElement; + }; + const selectRoot = canvas.getByLabelText("拍子の分子"); + await userEvent.click(selectRoot); + await new Promise((resolve) => setTimeout(resolve, 0)); // メニューが開くのを待つ + + const option = findOption("3"); + await userEvent.click(option); + + await expect(okButton).toBeEnabled(); + + await userEvent.click(selectRoot); + await new Promise((resolve) => setTimeout(resolve, 0)); + const option2 = findOption("4"); + await userEvent.click(option2); + + await expect(okButton).toBeDisabled(); + }); + + await context.step("BPMの存在を変更する", async () => { + const tempoToggle = canvas.getByRole("switch", { + name: /BPM変更の有無/, + }); + await userEvent.click(tempoToggle); + + await expect(okButton).toBeEnabled(); + + await userEvent.click(tempoToggle); + + await expect(okButton).toBeDisabled(); + }); + + await context.step("拍子の存在を変更する", async () => { + const timeSignatureToggle = canvas.getByRole("switch", { + name: /拍子変更の有無/, + }); + await userEvent.click(timeSignatureToggle); + + await expect(okButton).toBeEnabled(); + + await userEvent.click(timeSignatureToggle); + + await expect(okButton).toBeDisabled(); + }); + }, +}; + +export const ClickOk: Story = { + name: "OKボタンを押す:作成", + args: { ...CreateOpened.args }, + play: async ({ args }) => { + const canvas = within(document.body); // ダイアログなので例外的にdocument.bodyを使う + + const bpmToggle = canvas.getByRole("switch", { name: /BPM変更の有無/ }); + await userEvent.click(bpmToggle); + const bpmInput = canvas.getByLabelText("BPM"); + await userEvent.clear(bpmInput); + await userEvent.type(bpmInput, "100"); + + const button = canvas.getByRole("button", { name: /追加する/ }); + await userEvent.click(button); + + await expect(args["onOk"]).toBeCalledWith({ + timeSignatureChange: undefined, + tempoChange: { + bpm: 100, + }, + }); + await expect(args["onUpdate:modelValue"]).toBeCalledWith(false); + }, +}; + +export const ClickDelete: Story = { + name: "OKボタンを押す:削除", + args: { ...ChangeOpened.args }, + play: async ({ args }) => { + const canvas = within(document.body); // ダイアログなので例外的にdocument.bodyを使う + + const bpmToggle = canvas.getByRole("switch", { name: /BPM変更の有無/ }); + await userEvent.click(bpmToggle); + const timeSignatureToggle = canvas.getByRole("switch", { + name: /拍子変更の有無/, + }); + await userEvent.click(timeSignatureToggle); + + const button = canvas.getByRole("button", { name: /削除する/ }); + await userEvent.click(button); + + await expect(args["onOk"]).toBeCalledWith({ + timeSignatureChange: undefined, + tempoChange: undefined, + }); + await expect(args["onUpdate:modelValue"]).toBeCalledWith(false); + }, +}; + +export const CancelClose: Story = { + name: "キャンセルボタンを押す", + args: { ...ChangeOpened.args }, + play: async ({ args }) => { + const canvas = within(document.body); // ダイアログなので例外的にdocument.bodyを使う + + const button = canvas.getByRole("button", { name: /キャンセル/ }); + await userEvent.click(button); + + // ダイアログを閉じるイベントが呼ばれ、onOkは呼ばれない + await expect(args["onOk"]).not.toBeCalled(); + await expect(args["onUpdate:modelValue"]).toBeCalledWith(false); + }, +}; + +export const Closed: Story = { + name: "閉じている", + tags: ["skip-screenshot"], + args: { + modelValue: false, + }, +}; diff --git a/src/components/Dialog/TempoOrTimeSignatureChangeDialog.vue b/src/components/Dialog/TempoOrTimeSignatureChangeDialog.vue new file mode 100644 index 0000000000..351e94dba6 --- /dev/null +++ b/src/components/Dialog/TempoOrTimeSignatureChangeDialog.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/src/components/Menu/ContextMenu.vue b/src/components/Menu/ContextMenu.vue index 398e75e8d3..3f5441b620 100644 --- a/src/components/Menu/ContextMenu.vue +++ b/src/components/Menu/ContextMenu.vue @@ -37,10 +37,14 @@ defineProps<{ menudata: ContextMenuItemData[]; }>(); defineExpose({ + show: (event?: MouseEvent | undefined) => { + contextMenu.value?.show(event); + }, hide: () => { contextMenu.value?.hide(); }, }); + const store = useStore(); const uiLocked = computed(() => store.getters.UI_LOCKED); diff --git a/src/components/Sing/SequencerGrid/Presentation.vue b/src/components/Sing/SequencerGrid/Presentation.vue index 8b6f9fb5c5..d49830ec7f 100644 --- a/src/components/Sing/SequencerGrid/Presentation.vue +++ b/src/components/Sing/SequencerGrid/Presentation.vue @@ -8,9 +8,12 @@ > @@ -19,13 +22,13 @@ :key="`cell-${index}`" x="0" :y="gridCellHeight * index" - :width="beatWidth * beatsPerMeasure" + :width="gridPatterns[patternIndex].patternWidth" :height="gridCellHeight" :class="`sequencer-grid-cell sequencer-grid-cell-${keyInfo.color}`" /> { return gridCellBaseHeight * props.sequencerZoomY; }); -const beatsPerMeasure = computed(() => { - return props.timeSignatures[0].beats; -}); -const beatWidth = computed(() => { - const beatType = props.timeSignatures[0].beatType; +const beatWidth = (timeSignature: TimeSignature) => { + const beatType = timeSignature.beatType; const wholeNoteDuration = props.tpqn * 4; const beatTicks = wholeNoteDuration / beatType; return tickToBaseX(beatTicks, props.tpqn) * props.sequencerZoomX; -}); +}; + const gridWidth = computed(() => { - // TODO: 複数拍子に対応する - const beats = props.timeSignatures[0].beats; - const beatType = props.timeSignatures[0].beatType; - const measureDuration = getMeasureDuration(beats, beatType, props.tpqn); - const numMeasures = props.numMeasures; - const numOfGridColumns = - Math.round(measureDuration / gridCellTicks.value) * numMeasures; + let numOfGridColumns = 0; + for (const [i, timeSignature] of props.timeSignatures.entries()) { + const nextTimeSignature = props.timeSignatures[i + 1]; + const nextMeasureNumber = + nextTimeSignature?.measureNumber ?? props.numMeasures + 1; + const beats = timeSignature.beats; + const beatType = timeSignature.beatType; + const measureDuration = getMeasureDuration(beats, beatType, props.tpqn); + numOfGridColumns += + Math.round(measureDuration / gridCellTicks.value) * + (nextMeasureNumber - timeSignature.measureNumber); + } return gridCellWidth.value * numOfGridColumns; }); const gridHeight = computed(() => { return gridCellHeight.value * keyInfos.length; }); + +const gridPatterns = computed(() => { + const gridPatterns: { + id: string; + x: number; + beatWidth: number; + beatsPerMeasure: number; + beatLineIndices: number[]; + snapLinePositions: number[]; + patternWidth: number; + width: number; + }[] = []; + for (const [i, timeSignature] of props.timeSignatures.entries()) { + const nextTimeSignature = props.timeSignatures.at(i + 1); + const nextMeasureNumber = + nextTimeSignature?.measureNumber ?? props.numMeasures + 1; + const patternWidth = measureWidth(timeSignature); + gridPatterns.push({ + id: `sequencer-grid-pattern-${i}`, + x: + gridPatterns.length === 0 + ? 0 + : gridPatterns[gridPatterns.length - 1].x + + gridPatterns[gridPatterns.length - 1].width, + beatWidth: beatWidth(timeSignature), + beatsPerMeasure: timeSignature.beats, + beatLineIndices: beatLineIndices(timeSignature), + snapLinePositions: snapLinePositions(timeSignature), + patternWidth, + width: patternWidth * (nextMeasureNumber - timeSignature.measureNumber), + }); + } + + return gridPatterns; +}); + // 小節幅 -const measureWidth = computed(() => beatWidth.value * beatsPerMeasure.value); +const measureWidth = (timeSignature: TimeSignature) => + beatWidth(timeSignature) * timeSignature.beats; + // グリッド線の計算 // オクターブ線や小節線と重なる線は除外 const gridLines = computed(() => { @@ -150,25 +196,44 @@ const gridLines = computed(() => { { horizontalLines: [], octaveLines: [] }, ); }); -const numberOfMeasureLines = computed(() => - Math.ceil(gridWidth.value / measureWidth.value), -); +const measureLines = computed(() => { + const measureLines = [0]; + for (const [i, timeSignature] of props.timeSignatures.entries()) { + const nextTimeSignature = props.timeSignatures.at(i + 1); + const nextMeasureNumber = + nextTimeSignature?.measureNumber ?? props.numMeasures + 1; + const width = measureWidth(timeSignature); + + const left = measureLines[measureLines.length - 1]; + for ( + let measureNumber = timeSignature.measureNumber; + measureNumber < nextMeasureNumber; + measureNumber++ + ) { + measureLines.push( + left + width * (measureNumber - timeSignature.measureNumber + 1), + ); + } + } + return measureLines; +}); const horizontalLineIndices = computed(() => gridLines.value.horizontalLines); const octaveLineIndices = computed(() => gridLines.value.octaveLines); -const beatLineIndices = computed(() => - Array.from({ length: beatsPerMeasure.value - 1 }, (_, i) => i + 1), -); -const snapLinePositions = computed(() => { +const beatLineIndices = (timeSignature: TimeSignature) => + Array.from({ length: timeSignature.beats - 1 }, (_, i) => i + 1); +const snapLinePositions = (timeSignature: TimeSignature) => { const snapTicks = gridCellTicks.value; const measureTicks = - (props.tpqn * 4 * beatsPerMeasure.value) / props.timeSignatures[0].beatType; + (props.tpqn * 4 * timeSignature.beats) / timeSignature.beatType; const snapCount = Math.floor(measureTicks / snapTicks); return Array.from({ length: snapCount }, (_, index) => { const currentTick = snapTicks * index; - return (currentTick / measureTicks) * measureWidth.value; + return Math.round( + (currentTick / measureTicks) * measureWidth(timeSignature), + ); }); -}); +};