複数箇所で使用するコンテキストメニューの処理を、コンポーザブルを利用して実装する
2024-09-11
はじめに
VOICEVOXは、無料で使える中品質なテキスト読み上げソフトウェア
でVOICEVOX エディター、VOICEVOX エンジン、VOICEVOX コアがOSSとして公開されています。
VOICEVOX エディター(以下、エディター)には、「読み方&アクセント辞書」という機能があり、単語と読み、その単語を読み上げる際のアクセントを設定することができます。
Version 0.20.0時点での「読み方&アクセント辞書」には単語と読みのインプット要素でクリック操作のみのコピー・ペースト、切り取り、全選択をすることができません。Ctrl+C
やCtrl+V
といったキーボードショートカットを使うことでこれらの機能は使用することができますが、キーボードショートカットを普段利用しないユーザーや、クリック操作のみを利用しているユーザーにとっては少し不便に感じるところであると考えられます。
辞書の単語追加・編集UIの単語・読み入力欄で右クリックメニューを使えるようにする #747
「読み方&アクセント辞書」のインプット要素で、右クリックを利用してコピー・ペースト、切り取り、全選択をすることができるコンテキストメニュー機能を実装しました。この記事内では、Vue.jsに慣れていないものの、実装に至るまでに考えたことや、マージされるまでの過程で考えたこと、学んだことにフォーカスして書いています。
読み方&アクセント辞書について
「読み方&アクセント辞書」のフォームは以下のような形で実装されており、インプット要素がsurface
に関するものとyomi
に関するもので分かれています。
src/components/Dialog/DictionaryManageDialog.vue
<div class="row q-pl-md q-mt-md">
<div class="text-h6">単語</div>
<QInput
ref="surfaceInput"
v-model="surface"
class="word-input"
dense
:disable="uiLocked"
@blur="setSurface(surface)"
@keydown.enter="yomiFocus"
/>
</div>
<div class="row q-pl-md q-pt-sm">
<div class="text-h6">読み</div>
<QInput
ref="yomiInput"
v-model="yomi"
class="word-input q-pb-none"
dense
:error="!isOnlyHiraOrKana"
:disable="uiLocked"
@blur="setYomi(yomi)"
@keydown.enter="setYomiWhenEnter"
>
<template #error>
読みに使える文字はひらがなとカタカナのみです。
</template>
</QInput>
</div>
今回の実装で参考にした部分ではコンテキストメニューで操作をするインプット要素が一つしかないため、今回のように二つあるインプット要素に対する実装について少し考える必要がありました。
この二つのインプット要素において、コンテキストメニューで操作を行う際にsurface
とyomi
のそれぞれに対する操作の実装をするのではないかと最初は考えていました。しかし、この場合、同じような処理を二つもかいてしまうのは、コードの量が大きくなってしまうことや可読性が低くなってしまうことなど、他の問題を作ってしまうことが考えられました。
コンポーザブル(composable)を活用する
どのような実装を行ったら良いか、Vue.jsに詳しい方やメンテナーの方と相談し、surface
とyomi
の二つのインプット要素に対するコンテキストメニューの実装について、Vue.jsのコンポーザブル(composable)を利用することにしました。
Vue アプリケーションの文脈で「コンポーザブル(composable)」とは、Vue の Composition API を活用して状態を持つロジックをカプセル化して再利用するための関数です。
二つのインプット要素に対するコンテキストメニューのロジック部分をコンポーザブル関数として外部に抽出することで、双方のインプット要素でコンテキストメニューを再利用することができます。外部に抽出するロジックは、参考にしている実装があったので、その部分を新しくコンポーザブルとして作成する方針で進めていきました。
コンポーネントからコンポーザブルを呼び出す
「読み方&アクセント辞書」のコンポーネントでコンテキストメニューを使用するために、以下のように実装を行いました。
<template>
<div class="row q-pl-md q-mt-md">
<div class="text-h6">単語</div>
<QInput
ref="surfaceInput"
v-model="surface"
class="word-input"
dense
:disable="uiLocked"
@focus="clearSurfaceInputSelection()"
@blur="setSurface(surface)"
@keydown.enter="yomiFocus"
>
<ContextMenu
ref="surfaceContextMenu"
:header="surfaceContextMenuHeader"
:menudata="surfaceContextMenudata"
@beforeShow="startSurfaceContextMenuOperation()"
@beforeHide="endSurfaceContextMenuOperation()"
/>
</QInput>
</div>
<!-- 読みに対しても同様のフォームを用意する -->
</template>
<script>
//...
const surfaceContextMenu = ref<InstanceType<typeof ContextMenu>>();
const {
contextMenuHeader: surfaceContextMenuHeader,
contextMenudata: surfaceContextMenudata,
startContextMenuOperation: startSurfaceContextMenuOperation,
clearInputSelection: clearSurfaceInputSelection,
endContextMenuOperation: endSurfaceContextMenuOperation,
} = useRightClickContextMenu(surfaceContextMenu, surfaceInput, surface);
// 読みに対しても同様の処理を用意する
</script>
コンテキストメニューに関する処理を行うために、ContextMenu型の要素を参照するためのrefオブジェクトと、それぞれのインプット要素、インプット要素内のテキストをコンポーザブルに渡しています。渡されたそれぞれの値に対してコンポーザブルでコンテキストメニューの表示を行ったり、コピー・ペースト、切り取り、全選択の処理を行っています。
コンポーザブルのコードは長いため、以下で確認することができます。
src/composables/useRightClickContextMenu.ts
実装を行う過程で起きた課題
コンテキストメニューを使用してコピー・ペーストした内容が、別の単語・読み辞書内でも反映されてしまう
こちらは自分で解決することができず、メンテナーの方のアドバイスによって解決した課題です。
「読み方&アクセント辞書」のコンポーネントでは辞書に登録した単語・読みを一覧として表示している部分と、単語・読みを追加(もしくは、選択した単語・読みを編集)するフォーム部分が同じコンポーネント内に実装されており、後者のフォーム部分を表示するための制御を
v-if
で行っていました。v-if
でフォーム部分を表示・非表示するたびにDOMが再構築されてしまいます。その結果、右クリックのコンテキストメニューで取得するインプット要素が再構築される前の古いDOM要素を参照してしまい、別の単語・読み辞書の内容を反映してしまうようでした。解決策として、
v-if
によるフォームの表示制御をv-show
に変更し、要素の再構築をしないようにしました。この過程を通して、v-if
とv-show
について、それらの違いを学ぶことができました。「読み方&アクセント辞書」を開閉した後、選択した文字列がコンテキストメニューに表示されない
コンテキストメニューでは選択しているテキストを上部に表示する機能を実装しています。一度「読み方&アクセント辞書」でテキストを範囲選択し、「読み方&アクセント辞書」から離れた後、再度開いた「読み方&アクセント辞書」内で選択したテキストがコンテキストメニューの上部に表示されないという挙動が起きているようでした。
インプット要素で選択しているテキストをキャッシュする実装をしていたので、「読み方&アクセント辞書」を開閉してしまうと、再構築されたDOMからは前回キャッシュしているテキストを読み込むことができず、選択範囲をコンテキストメニュー上部に表示することができませんでした。
インプット要素で選択した範囲のテキストを取得するヘルパ内で、選択範囲をキャッシュしないように修正することで、選択範囲を毎回コンテキストメニューの上部に表示させるようにしました。
終わりに
今回行った実装の中でVue.jsにあるコンポーザブルという機能自体とその使い方、v-if
やv-show
の違いなどを学ぶことができました。まだまだ一人で問題を解決させるための技量が足りてないように感じてしまった部分もありますが、無事に実装をマージするところまでできたので良かったかなと。
今後の課題として、辞書に登録した単語・読みを一覧として表示している部分と、単語・読みを追加(もしくは、選択した単語・読みを編集)するフォーム部分が同一のコンポーネントに実装されているため、それらを別々のコンポーネントに切り出したいと考えています。