Skip to content
Open
Show file tree
Hide file tree
Changes from 18 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
153 changes: 153 additions & 0 deletions src/store/singing/clipboardHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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";

/**
* 選択されたノートをクリップボードにコピーする
* @param context ActionContext
* @throws クリップボードへの書き込みに失敗した場合
*/
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;
Comment on lines +22 to +23
Copy link
Member

Choose a reason for hiding this comment

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

これがundefinedになることは無さそう?


// ノートが選択されていない場合は何もしない
if (noteIds.size === 0) return;

// 選択されたノートのみをコピーする
const selectedNotesForCopy = selectedTrack.notes
.filter((note: Note) => noteIds.has(note.id))
.map((note: Note) => {
// idのみコピーしない
const { id, ...noteWithoutId } = note;
return noteWithoutId;
});

// VOICEVOXのノートのペースト用としてノートをJSONにシリアライズ
const serializedNotes = JSON.stringify(selectedNotesForCopy);

await writeNotesToClipboard(serializedNotes);
}

/**
* クリップボードにデータを書き込む
* @param serializedNotes シリアライズされたノートデータオブジェクト
* @throws クリップボードへの書き込みに失敗した場合
*/
async function writeNotesToClipboard(serializedNotes: string): Promise<void> {
try {
// 1. カスタムMIMEタイプを利用してコピー
// web application/vnd.voicevox.song-notes
// web からはじまる形式はChromeのみでサポート
const notesBlob = new Blob([serializedNotes], {
type: VOICEVOX_NOTES_MIME_TYPE,
});
const clipboardItem = new ClipboardItem({
[VOICEVOX_NOTES_MIME_TYPE]: notesBlob,
});
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タイプが利用できない(Chrome以外のブラウザ環境)の場合のフォールバック
// VOICEVOXのシーケンサでは、ペースト時に text/html を読み取り、data-* 属性からノートデータを復元できる。
// VOICEVOXの歌詞入力など他の入力inputや多くの他のアプリケーションでは、<i> タグや data-* 属性は無視され、
// text/plainの空文字が優先されるため、結果的に何もペーストされないように見える
try {
// <i>のdata属性にノートオブジェクトを埋め込む
const encodedHtmlNotes = `<i data-voicevox-song-notes="${encodeURIComponent(serializedNotes)}" />`;
// ノートデータを持つtext/html
const textHtmlBlob = new Blob([encodedHtmlNotes], {
type: "text/html",
});
// 多くの場合に表示される空文字
const emptyTextBlob = new Blob([""], {
Copy link
Member

Choose a reason for hiding this comment

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

気持ち的に歌詞もコピーしてくれたほうが嬉しいかも?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

歌詞コピーにしました!

※ コピペは現実的にそんなに困らず優先度高くないと思うので、レビュー後回しでお願いします…!

type: "text/plain",
});
const clipboardItem = new ClipboardItem({
"text/html": textHtmlBlob,
"text/plain": emptyTextBlob,
});
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<Omit<Note, "id">[]> {
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<Note, "id">[] {
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,
});
}
}
64 changes: 17 additions & 47 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 @@ -1665,6 +1668,9 @@ export const singingStore = createPartialStore<SingingStoreTypes>({
throw new Error("audioContext is undefined.");
}
const sinkId = device === "default" ? "" : device;
if (typeof audioContext.setSinkId !== "function") {
return;
}
audioContext.setSinkId(sinkId).catch((err: unknown) => {
void showAlertDialog({
title: "エラー",
Expand Down Expand Up @@ -3198,27 +3204,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 +3218,11 @@ 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,
});
}
// ノートをクリップボードから読み込む
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;
Expand Down
Loading