Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4c07bdc
コピーをカスタムMIMEタイプで振り分け、シーケンサでのペーストの場合はノート情報・一般ペーストの場合は歌詞のみにする
romot-co Mar 15, 2025
63a8655
フォールバック動作を修正
romot-co Mar 15, 2025
5c39b4a
Merge branch 'main' into fix/copy_paste_with_custom_mimetype-2591
Hiroshiba Mar 18, 2025
e27ae0e
Merge remote-tracking branch 'origin/main' into fix/copy_paste_with_c…
romot-co Mar 26, 2025
a218d66
クリップボードへの書き込み・読み込みをclipboardHelperに移管
romot-co Mar 26, 2025
933a7b7
noteSchemeでのバリデーション
romot-co Mar 26, 2025
fdb29c5
Merge branch 'fix/copy_paste_with_custom_mimetype-2591' of https://gi…
romot-co Mar 26, 2025
8a6c7e8
バリデーションを分離
romot-co Mar 26, 2025
6460bd4
コメント修正
romot-co Mar 26, 2025
b7f1402
フォーマット
romot-co Mar 26, 2025
f3a52dd
Merge branch 'main' into fix/copy_paste_with_custom_mimetype-2591
romot-co Apr 1, 2025
0bf2b52
指摘事項修正
romot-co Apr 3, 2025
6c6d6df
Merge branch 'fix/copy_paste_with_custom_mimetype-2591' of https://gi…
romot-co Apr 3, 2025
928b3ba
フォールバックの場合においても一般ペーストでは何もペーストされないようにする
romot-co Apr 3, 2025
5b7452f
clipboardのwrite/writeText/read/readTextそれぞれでフォールバック
romot-co Apr 4, 2025
e081265
typo
romot-co Apr 4, 2025
6cbdd98
コピー&ペーストのフォールバックを見直す
romot-co Apr 5, 2025
cff813d
コンフリクト解消
romot-co Apr 5, 2025
0bf5ffc
revert: setSinkIdの対応をもとに戻す
romot-co Apr 5, 2025
28a869c
コメントを調整
romot-co Apr 5, 2025
aaa69cc
Merge remote-tracking branch 'origin/main' into fix/copy_paste_with_c…
romot-co May 13, 2025
69510b8
指摘事項修正およびE2Eテストの追加
romot-co May 13, 2025
05d7de8
テストの調整
romot-co May 13, 2025
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
138 changes: 138 additions & 0 deletions src/store/singing/clipboardHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { ActionContext, Note } from "../type";
import { noteSchema } from "@/domain/project/schema";

// VOICEVOXソングのノート専用のMIMEType
export const VOICEVOX_NOTES_MIME_TYPE =
"web application/vnd.voicevox.song-notes";

/**
* 選択されたノートをクリップボードにコピーする
*/
export async function copyNotesToClipboard(
context: ActionContext,
): Promise<void> {
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: Note) => noteIds.has(note.id))
.map((note: Note) => {
// idのみコピーしない
const { id, ...noteWithoutId } = note;
return noteWithoutId;
});

// VOICEVOXのノートのペースト用としてノートをJSONにシリアライズ
const jsonVoicevoxNotes = JSON.stringify(selectedNotes);
// 歌詞のみのテキストとしてノートのテキストを結合
const plainTextLyric = selectedNotes.map((note) => note.lyric).join("");

await writeNotesToClipboard(jsonVoicevoxNotes, plainTextLyric);
}

/**
* クリップボードにデータを書き込む
* @throws クリップボードへの書き込みに失敗した場合
*/
export async function writeNotesToClipboard(
jsonVoicevoxNotes: string,
plainTextLyric: string,
): Promise<void> {
try {
// 1. カスタムMIMEタイプを利用してクリップボードに書き込みを試みる
// MIMEタイプとしてapplication/jsonとtext/plainを使用してクリップボードにコピーする
// web application/vnd.voicevox.song-notes - VOICEVOXでのノート構造を保持してペーストできる
// text/plain - 歌詞テキストだけを内部または他のエディタなどで利用できるようにする
// web形式からはじまる形式はChromeのみでサポートされている
const voicevoxNotesBlob = new Blob([jsonVoicevoxNotes], {
type: VOICEVOX_NOTES_MIME_TYPE,
});
const textBlob = new Blob([plainTextLyric], { type: "text/plain" });
// 書き込むデータ
const clipboardItem = new ClipboardItem({
[VOICEVOX_NOTES_MIME_TYPE]: voicevoxNotesBlob,
"text/plain": textBlob,
});
await navigator.clipboard.write([clipboardItem]);
Copy link
Member

@sevenc-nanashi sevenc-nanashi Apr 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(try VOICEVOX_NOTES_MIME_TYPE catch text/html) and text/plainみたいな感じに整理したほうが見やすいかも。

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

こちら修正しました!

} catch {
// 2. カスタムMIMEタイプを利用してのコピーが失敗した場合、
// クリップボードにノートデータをシリアライズした形式で書き込みを試みる
try {
await navigator.clipboard.writeText(jsonVoicevoxNotes);
} catch (clipboardWriteError) {
// クリップボード書き込みに失敗した場合はエラー
throw new Error("Failed to copy notes to clipboard.", {
cause: clipboardWriteError,
});
}
}
}

/**
* クリップボードからVOICEVOXノートを読み取る
* @returns 読み取ったノート配列(idは除外されている)
*/
export async function readNotesFromClipboard(): Promise<Omit<Note, "id">[]> {
try {
const clipboardItems = await navigator.clipboard.read();

// 1. カスタムMIMEタイプのアイテムを先に探して、あれば返す
for (const item of clipboardItems) {
if (item.types.includes(VOICEVOX_NOTES_MIME_TYPE)) {
const blob = await item.getType(VOICEVOX_NOTES_MIME_TYPE);
const voicevoxNotesText = await blob.text();
// 発見したら早期リターン
return validateVoicevoxNotesForClipboard(voicevoxNotesText);
}
}

// 2. カスタムMIMEタイプが見つからなかったらテキストとして読み取り、JSONとしてパースを試みて返す
const clipboardText = await navigator.clipboard.readText();
try {
return validateVoicevoxNotesForClipboard(clipboardText);
} catch (validationError) {
// バリデーション失敗したらエラーをスロー
throw new Error("Failed to parse notes from clipboard.", {
cause: validationError,
});
}
// クリップボードが読めないなどであればエラーをスロー

Check failure on line 111 in src/store/singing/clipboardHelper.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `··`
} catch (clipboardReadError) {
throw new Error("Failed to read notes from clipboard.", {
cause: clipboardReadError,
});
}
}

/**
* コピー&ペースト用のノートデータをバリデーションする
* @param jsonVoicevoxNotes
* @returns バリデーション済みのノート配列(idは除外)
* @throws バリデーション失敗時にエラーをスロー
*/
export function validateVoicevoxNotesForClipboard(
jsonVoicevoxNotes: string,
): Omit<Note, "id">[] {
try {
return noteSchema
.omit({ id: true })
.array()
.parse(JSON.parse(jsonVoicevoxNotes));
} catch (validationError) {
throw new Error("Failed to validate notes for clipboard data.", {
cause: validationError,
});
}
}
59 changes: 11 additions & 48 deletions src/store/singing.ts → src/store/singing/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -26,15 +26,19 @@ import {
TrackParameters,
SingingPitchKey,
SingingPitch,
} from "./type";
} from "../type";
import {
buildSongTrackAudioFileNameFromRawData,
currentDateString,
DEFAULT_PROJECT_NAME,
DEFAULT_STYLE_NAME,
generateLabelFileDataFromFramePhonemes,
sanitizeFileName,
} from "./utility";
} from "../utility";
import {
copyNotesToClipboard,
readNotesFromClipboard,
} from "./clipboardHelper";
import {
CharacterInfo,
EngineId,
Expand Down Expand Up @@ -103,7 +107,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";
Expand Down Expand Up @@ -3198,27 +3201,8 @@ export const singingStore = createPartialStore<SingingStoreTypes>({
},

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);
},
},

Expand All @@ -3231,28 +3215,7 @@ export const singingStore = createPartialStore<SingingStoreTypes>({

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,
});
}

// クリップボードのテキストを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,
});
}
const notes = await readNotesFromClipboard();

// パースしたJSONのノートの位置を現在の再生位置に合わせてクオンタイズして貼り付ける
const currentPlayheadPosition = getters.PLAYHEAD_POSITION;
Expand Down
Loading