diff --git a/src/store/singing/clipboardHelper.ts b/src/store/singing/clipboardHelper.ts new file mode 100644 index 0000000000..d755d1333c --- /dev/null +++ b/src/store/singing/clipboardHelper.ts @@ -0,0 +1,175 @@ +import { ActionContext, Note } from "../type"; +import { noteSchema } from "@/domain/project/schema"; + +// VOICEVOXソングのノート専用のMIMEタイプ +// VOICEVOX内でノートデータを共有するために使用 +// NOTE: どこかに定数で持たせるべきかもしれないが、適切な場所がなさそうなためヘルパーにまとめる +export const VOICEVOX_NOTES_MIME_TYPE = + "web application/vnd.voicevox.song-notes"; + +/** + * 選択されたノートをクリップボードにコピーする + * @param context ActionContext + * @throws クリップボードへの書き込みに失敗した場合 + */ +export async function copyNotesToClipboard( + context: ActionContext, +): Promise { + const { getters } = context; + const selectedTrack = getters.SELECTED_TRACK; + const noteIds = getters.SELECTED_NOTE_IDS; + + // 選択されたトラックがない場合は何もしない + if (!selectedTrack) return; + + // ノートが選択されていない場合は何もしない + if (noteIds.size === 0) return; + + // 選択されたノートを抽出 + const selectedNotes = selectedTrack.notes.filter((note) => + noteIds.has(note.id), + ); + + // VOICEVOXのノートのペースト用としてノートをJSONにシリアライズ(idを除外) + const serializedNotes = JSON.stringify( + selectedNotes.map((note) => { + const { id, ...noteWithoutId } = note; + return noteWithoutId; + }), + ); + + // プレーンテキストとしての歌詞を作成 + const plainTextLyrics = selectedNotes.map((note) => note.lyric).join(""); + + await writeNotesToClipboard(serializedNotes, plainTextLyrics); +} + +/** + * クリップボードにデータを書き込む + * @param serializedNotes シリアライズされたノートデータオブジェクト + * @param plainTextLyrics プレーンテキストとしての歌詞 + * @throws クリップボードへの書き込みに失敗した場合 + */ +async function writeNotesToClipboard( + serializedNotes: string, + plainTextLyrics: string, +): Promise { + // text/plain としての歌詞を用意 + const lyricsTextBlob = new Blob([plainTextLyrics], { + type: "text/plain", + }); + + try { + // 1. まずはカスタムMIMEタイプを利用してコピーを試みます(ElectronをふくむChrome用) + // 以下のカスタムMIMEタイプでのコピーを行います。 + // "web application/vnd.voicevox.song-notes" + // + // 参考: https://developer.chrome.com/blog/web-custom-formats-for-the-async-clipboard-api?hl=ja + const notesBlob = new Blob([serializedNotes], { + type: VOICEVOX_NOTES_MIME_TYPE, + }); + const clipboardItem = new ClipboardItem({ + [VOICEVOX_NOTES_MIME_TYPE]: notesBlob, + "text/plain": lyricsTextBlob, + }); + await navigator.clipboard.write([clipboardItem]); + } catch { + try { + // 2. カスタムMIMEタイプが利用できない(Chrome以外のブラウザ環境等)の場合はフォールバックします + // ノートデータをdata属性に埋め込んだ text/html でコピーします。 + // - VOICEVOXシーケンサーだと、ペースト時に data-* 属性からノートを復元できます。 + // - 他のアプリでは通常、 タグや data-* 属性は無視され、何もペーストされません。 + // + // コピー&ペーストはブラウザやアプリの実装依存となり、 + // text/html しかない場合にHTMLタグ自体がペーストされる可能性があります。 + // これを防ぐ目的でより優先されやすい text/plain も設定します。 + + // のdata属性にノートオブジェクトを埋め込む + const encodedHtmlNotes = ``; + // ノートデータを持つtext/html + const textHtmlBlob = new Blob([encodedHtmlNotes], { + type: "text/html", + }); + + const clipboardItem = new ClipboardItem({ + "text/html": textHtmlBlob, + "text/plain": lyricsTextBlob, + }); + await navigator.clipboard.write([clipboardItem]); + } catch (clipboardWriteError) { + // クリップボード書き込みに失敗した場合はエラー + throw new Error("Failed to copy notes to clipboard.", { + cause: clipboardWriteError, + }); + } + } +} + +/** + * クリップボードからノートオブジェクトを読み取る + * @returns 読み取ったノート配列(idは除外されている) + */ +export async function readNotesFromClipboard(): Promise[]> { + try { + const clipboardItems = await navigator.clipboard.read(); + + for (const item of clipboardItems) { + // 1. カスタムMIMEタイプがあればそれを優先してパース + if (item.types.includes(VOICEVOX_NOTES_MIME_TYPE)) { + const blob = await item.getType(VOICEVOX_NOTES_MIME_TYPE); + const notesText = await blob.text(); + return validateNotesForClipboard(notesText); + } + // 2. なければフォールバックとしてtext/htmlをチェックしてパース + if (item.types.includes("text/html")) { + const blob = await item.getType("text/html"); + const htmlText = await blob.text(); + const domParser = new DOMParser(); + const doc = domParser.parseFromString(htmlText, "text/html"); + // data-voicevox-song-notesデータ属性を持つ要素を取得 + const elementCandidate = doc.querySelector( + "[data-voicevox-song-notes]", + ); + // 要素が取得できないなら次のClipboardItemへ + if (!elementCandidate) continue; + // data-voicevox-song-notesデータ属性値を取得 + const encodedData = elementCandidate.getAttribute( + "data-voicevox-song-notes", + ); + // 属性値がないなら次のClipboardItemへ + if (!encodedData) continue; + const decodedData = decodeURIComponent(encodedData); + return validateNotesForClipboard(decodedData); + } + // 他のタイプはノートペーストにおいては無視 + } + + // なにも見つからなければ空配列とし何もペーストされない + return []; + } catch (clipboardReadError) { + throw new Error("Failed to read notes from clipboard.", { + cause: clipboardReadError, + }); + } +} + +/** + * コピー&ペースト用のノートデータをバリデーションする + * @param clipboardText + * @returns バリデーション済みのノート配列(idは除外) + * @throws バリデーション失敗時にエラーをスロー + */ +function validateNotesForClipboard(clipboardText: string): Omit[] { + try { + return noteSchema + .omit({ id: true }) + .array() + .parse(JSON.parse(clipboardText)); + } catch (validationError) { + throw new Error("Failed to validate notes for clipboard data.", { + cause: validationError, + }); + } +} diff --git a/src/store/singing.ts b/src/store/singing/index.ts similarity index 98% rename from src/store/singing.ts rename to src/store/singing/index.ts index 2698649762..a170756bc6 100644 --- a/src/store/singing.ts +++ b/src/store/singing/index.ts @@ -1,6 +1,6 @@ import { ref, toRaw } from "vue"; -import { createPartialStore } from "./vuex"; -import { createUILockAction } from "./ui"; +import { createPartialStore } from "../vuex"; +import { createUILockAction } from "../ui"; import { Tempo, TimeSignature, @@ -26,7 +26,7 @@ import { TrackParameters, SingingPitchKey, SingingPitch, -} from "./type"; +} from "../type"; import { buildSongTrackAudioFileNameFromRawData, currentDateString, @@ -34,7 +34,11 @@ import { DEFAULT_STYLE_NAME, generateLabelFileDataFromFramePhonemes, sanitizeFileName, -} from "./utility"; +} from "../utility"; +import { + copyNotesToClipboard, + readNotesFromClipboard, +} from "./clipboardHelper"; import { CharacterInfo, EngineId, @@ -99,7 +103,6 @@ import { } from "@/sing/utility"; import { getWorkaroundKeyRangeAdjustment } from "@/sing/workaroundKeyRangeAdjustment"; import { createLogger } from "@/helpers/log"; -import { noteSchema } from "@/domain/project/schema"; import { getOrThrow } from "@/helpers/mapHelper"; import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; import { ufProjectToVoicevox } from "@/sing/utaformatixProject/toVoicevox"; @@ -2786,27 +2789,8 @@ export const singingStore = createPartialStore({ }, COPY_NOTES_TO_CLIPBOARD: { - async action({ getters }) { - const selectedTrack = getters.SELECTED_TRACK; - const noteIds = getters.SELECTED_NOTE_IDS; - // ノートが選択されていない場合は何もしない - if (noteIds.size === 0) { - return; - } - // 選択されたノートのみをコピーする - const selectedNotes = selectedTrack.notes - .filter((note: Note) => noteIds.has(note.id)) - .map((note: Note) => { - // idのみコピーしない - const { id, ...noteWithoutId } = note; - return noteWithoutId; - }); - // ノートをJSONにシリアライズしてクリップボードにコピーする - const serializedNotes = JSON.stringify(selectedNotes); - // クリップボードにテキストとしてコピーする - // NOTE: Electronのclipboardも使用する必要ある? - await navigator.clipboard.writeText(serializedNotes); - logger.info("Copied to clipboard.", serializedNotes); + async action(context) { + await copyNotesToClipboard(context); }, }, @@ -2819,28 +2803,11 @@ export const singingStore = createPartialStore({ COMMAND_PASTE_NOTES_FROM_CLIPBOARD: { async action({ mutations, state, getters, actions }) { - // クリップボードからテキストを読み込む - let clipboardText; - try { - clipboardText = await navigator.clipboard.readText(); - } catch (error) { - throw new Error("Failed to read the clipboard text.", { - cause: error, - }); - } + // ノートをクリップボードから読み込む + const notes = await readNotesFromClipboard(); - // クリップボードのテキストをJSONとしてパースする(失敗した場合はエラーを返す) - let notes; - try { - notes = noteSchema - .omit({ id: true }) - .array() - .parse(JSON.parse(clipboardText)); - } catch (error) { - throw new Error("Failed to parse the clipboard text as JSON.", { - cause: error, - }); - } + // notesが空の場合は何もしない + if (notes.length === 0) return; // パースしたJSONのノートの位置を現在の再生位置に合わせてクオンタイズして貼り付ける const currentPlayheadPosition = getters.PLAYHEAD_POSITION; diff --git "a/tests/e2e/browser/song/\343\202\257\343\203\252\343\203\203\343\203\227\343\203\234\343\203\274\343\203\211.spec.ts" "b/tests/e2e/browser/song/\343\202\257\343\203\252\343\203\203\343\203\227\343\203\234\343\203\274\343\203\211.spec.ts" new file mode 100644 index 0000000000..12a8d6f0c2 --- /dev/null +++ "b/tests/e2e/browser/song/\343\202\257\343\203\252\343\203\203\343\203\227\343\203\234\343\203\274\343\203\211.spec.ts" @@ -0,0 +1,106 @@ +import { test, expect, Page } from "@playwright/test"; +import { gotoHome, navigateToSong } from "../../navigators"; +import { noteSchema } from "@/domain/project/schema"; +import { VOICEVOX_NOTES_MIME_TYPE } from "@/store/singing/clipboardHelper"; + +async function grantClipboardPermissions(page: Page) { + const origin = new URL(page.url()).origin; + if (!origin) return; + await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { + origin, + }); +} + +function getSequencer(page: Page) { + return page.getByLabel("シーケンサ"); +} + +async function getRuler(page: Page) { + const rulerLocator = page.locator(".sequencer-ruler"); + const boundingBox = await rulerLocator.boundingBox(); + if (boundingBox == null) { + throw new Error("ルーラーが見つかりません"); + } + return rulerLocator; +} + +async function addNotes(page: Page, count: number) { + await test.step(`ノートを${count}つ追加`, async () => { + const sequencer = getSequencer(page); + for (let i = 0; i < count; i++) { + await sequencer.click({ position: { x: (i + 1) * 100, y: 171 } }); + } + const notes = sequencer.locator(".note"); + await expect(notes).toHaveCount(count); + }); +} + +test.beforeEach(async ({ page }) => { + await gotoHome({ page }); + await navigateToSong(page); + await grantClipboardPermissions(page); +}); + +test("選択した単一ノートをコピー&ペーストできる", async ({ page }) => { + await test.step("コピー元のノートを1つ作成して選択", async () => { + await addNotes(page, 1); + }); + + await test.step("ノートをコピーする", async () => { + const isMac = process.platform === "darwin"; + await page.keyboard.press(isMac ? "Meta+C" : "Control+C"); + }); + + await test.step("クリップボードには妥当なノートが含まれている", async () => { + // クリップボードのカスタムMIMEタイプの内容を取得 + const clipboardData = await page.evaluate( + async ({ mimeType }) => { + const items = await navigator.clipboard.read(); + for (const item of items) { + if (item.types.includes(mimeType)) { + const blob = await item.getType(mimeType); + return await blob.text(); + } + } + return null; + }, + { mimeType: VOICEVOX_NOTES_MIME_TYPE }, + ); + // クリップボードは空ではないはず + expect(clipboardData).not.toBeNull(); + + // クリップボードの内容はzodスキーマでパースできるはず + const parseResult = noteSchema + .omit({ id: true }) + .array() + .safeParse(JSON.parse(clipboardData as string)); + expect(parseResult.success).toBe(true); + + if (parseResult.success) { + // クリップボードの内容は1つのノートであるはず + expect(parseResult.data.length).toBe(1); + // クリップボードの内容は作成時の位置的に"ソ"であるはず + expect(parseResult.data[0].lyric).toBe("ソ"); + } + }); + + await test.step("別の場所にペースト", async () => { + const ruler = await getRuler(page); + // ルーラーをクリックして別の場所を選択 + await ruler.click({ position: { x: 300, y: 20 } }); + const isMac = process.platform === "darwin"; + // ペースト + await page.keyboard.press(isMac ? "Meta+V" : "Control+V"); + }); + + await test.step("ペーストされたノートの歌詞は妥当なものである", async () => { + const sequencer = getSequencer(page); + const noteLyrics = sequencer.locator(".note-lyric"); + + // ノートはペースト分含め2つあるはず + await expect(noteLyrics).toHaveCount(2); + // ペーストされたノートは"ソ"であるはず + const pastedNoteLyric = await noteLyrics.nth(1).textContent(); + expect(pastedNoteLyric).toBe("ソ"); + }); +});