Skip to content

Commit

Permalink
辞書の単語・読み入力欄で右クリックメニューを使えるようにする (#2156)
Browse files Browse the repository at this point in the history
* 右クリックによるコンテキストメニュー表示

* 辞書の単語と読みに全選択操作を追加

* 辞書の単語と読みにコピー操作を追加

* 辞書の単語と読みに切り取り操作を追加

* 辞書の単語と読みに貼り付け操作を追加

* 右クリックメニューに関するコンポーザブルを追加し、処理をそちらに委譲

* コンポーザブルを修正し、切り取りやコピーペーストができるようにする

* 選択したinputテキストをコンテキストメニューヘッダーに表示する

* コンテキストメニューの開閉によるfocusやblurに対する処理

* 右側のパネル描画をv-ifからv-showによる切り替えに修正

* コンポーザブルで不要なinputField引数の削除

* テキスト未選択時のコンテキストメニューヘッダーにテキストを表示させなくする

* eslintのエラーを回避

* コメントの追加

* 関数の統合

* 選択したテキストの表示・非表示処理をリファクタリング

* nativeElをキャッシュせず、常に新しく取得し直す

* コメント追加や関数名の変更など、細かい修正

* Apply suggestions from code review

* Apply suggestions from code review

---------

Co-authored-by: Hiroshiba <hihokaruta@gmail.com>
  • Loading branch information
jdkfx and Hiroshiba committed Aug 19, 2024
1 parent 00ba6fc commit e640cd2
Show file tree
Hide file tree
Showing 3 changed files with 234 additions and 20 deletions.
42 changes: 40 additions & 2 deletions src/components/Dialog/DictionaryManageDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@

<!-- 右側のpane -->
<div
v-if="wordEditing"
v-show="wordEditing"
class="col-8 no-wrap text-no-wrap word-editor"
>
<div class="row q-pl-md q-mt-md">
Expand All @@ -129,9 +129,18 @@
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>
<div class="row q-pl-md q-pt-sm">
<div class="text-h6">読み</div>
Expand All @@ -142,12 +151,20 @@
dense
:error="!isOnlyHiraOrKana"
:disable="uiLocked"
@focus="clearYomiInputSelection()"
@blur="setYomi(yomi)"
@keydown.enter="setYomiWhenEnter"
>
<template #error>
読みに使える文字はひらがなとカタカナのみです。
</template>
<ContextMenu
ref="yomiContextMenu"
:header="yomiContextMenuHeader"
:menudata="yomiContextMenudata"
@beforeShow="startYomiContextMenuOperation()"
@beforeHide="endYomiContextMenuOperation()"
/>
</QInput>
</div>
<div class="row q-pl-md q-mt-lg text-h6">アクセント調整</div>
Expand Down Expand Up @@ -272,6 +289,8 @@
import { computed, ref, watch } from "vue";
import { QInput } from "quasar";
import AudioAccent from "@/components/Talk/AudioAccent.vue";
import ContextMenu from "@/components/Menu/ContextMenu.vue";
import { useRightClickContextMenu } from "@/composables/useRightClickContextMenu";
import { useStore } from "@/store";
import type { FetchAudioResult } from "@/store/type";
import { AccentPhrase, UserDictWord } from "@/openapi";
Expand Down Expand Up @@ -676,6 +695,25 @@ const toWordEditingState = () => {
const toDialogClosedState = () => {
dictionaryManageDialogOpenedComputed.value = false;
};
const surfaceContextMenu = ref<InstanceType<typeof ContextMenu>>();
const yomiContextMenu = ref<InstanceType<typeof ContextMenu>>();
const {
contextMenuHeader: surfaceContextMenuHeader,
contextMenudata: surfaceContextMenudata,
startContextMenuOperation: startSurfaceContextMenuOperation,
clearInputSelection: clearSurfaceInputSelection,
endContextMenuOperation: endSurfaceContextMenuOperation,
} = useRightClickContextMenu(surfaceContextMenu, surfaceInput, surface);
const {
contextMenuHeader: yomiContextMenuHeader,
contextMenudata: yomiContextMenudata,
startContextMenuOperation: startYomiContextMenuOperation,
clearInputSelection: clearYomiInputSelection,
endContextMenuOperation: endYomiContextMenuOperation,
} = useRightClickContextMenu(yomiContextMenu, yomiInput, yomi);
</script>

<style lang="scss" scoped>
Expand Down
172 changes: 172 additions & 0 deletions src/composables/useRightClickContextMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* テキスト編集エリアの右クリックメニュー用の処理
* 参考実装: https://github.com/VOICEVOX/voicevox/pull/1374/files#diff-444f263f72d4db11fe82c672d5c232eb4c29d29dbc1ffd20e279d586b1b2c180
*/

import { QInput } from "quasar";
import { ref, Ref, nextTick } from "vue";
import { MenuItemButton, MenuItemSeparator } from "@/components/Menu/type";
import ContextMenu from "@/components/Menu/ContextMenu.vue";
import { SelectionHelperForQInput } from "@/helpers/SelectionHelperForQInput";

/**
* <QInput> に対して切り取りやコピー、貼り付けの処理を行う
*/
export function useRightClickContextMenu(
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
contextMenuRef: Ref<InstanceType<typeof ContextMenu> | undefined>,
qInputRef: Ref<QInput | undefined>,
inputText: Ref<string>,
) {
const inputSelection = new SelectionHelperForQInput(qInputRef);

/**
* コンテキストメニューの開閉によりFocusやBlurが発生する可能性のある間は`true`
* no-focusを付けた場合と付けてない場合でタイミングが異なるため、両方に対応
*/
const willFocusOrBlur = ref(false);

const contextMenuHeader = ref<string | undefined>("");
const startContextMenuOperation = () => {
const MAX_HEADER_LENGTH = 15;
const SHORTED_HEADER_FRAGMENT_LENGTH = 5;

willFocusOrBlur.value = true;

const getMenuItemButton = (label: string) => {
const item = contextMenudata.value.find((item) => item.label === label);
if (item?.type !== "button")
throw new Error("コンテキストメニューアイテムの取得に失敗しました。");
return item;
};

const text = inputSelection.getAsString();

if (text.length > MAX_HEADER_LENGTH) {
contextMenuHeader.value =
text.length <= MAX_HEADER_LENGTH
? text
: `${text.substring(
0,
SHORTED_HEADER_FRAGMENT_LENGTH,
)} ... ${text.substring(
text.length - SHORTED_HEADER_FRAGMENT_LENGTH,
)}`;
} else {
contextMenuHeader.value = text;
}

if (inputSelection.isEmpty) {
getMenuItemButton("切り取り").disabled = true;
getMenuItemButton("コピー").disabled = true;
} else {
getMenuItemButton("切り取り").disabled = false;
getMenuItemButton("コピー").disabled = false;
}
};

const contextMenudata = ref<
[
MenuItemButton,
MenuItemButton,
MenuItemButton,
MenuItemSeparator,
MenuItemButton,
]
>([
{
type: "button",
label: "切り取り",
onClick: async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
contextMenuRef.value?.hide();
await handleCut();
},
disableWhenUiLocked: false,
},
{
type: "button",
label: "コピー",
onClick: async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
contextMenuRef.value?.hide();
await navigator.clipboard.writeText(inputSelection.getAsString());
},
disableWhenUiLocked: false,
},
{
type: "button",
label: "貼り付け",
onClick: async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
contextMenuRef.value?.hide();
await handlePaste();
},
disableWhenUiLocked: false,
},
{ type: "separator" },
{
type: "button",
label: "全選択",
onClick: async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
contextMenuRef.value?.hide();
qInputRef.value?.select();
},
disableWhenUiLocked: false,
},
]);

const handleCut = async () => {
if (!inputSelection || inputSelection.isEmpty) return;

const text = inputSelection.getAsString();
const start = inputSelection.selectionStart;
setText(inputSelection.getReplacedStringTo(""));
await nextTick();
void navigator.clipboard.writeText(text);
inputSelection.setCursorPosition(start);
};

const setText = (text: string | number | null) => {
if (typeof text !== "string") throw new Error("typeof text !== 'string'");
inputText.value = text;
};

const handlePaste = async (options?: { text?: string }) => {
// NOTE: 自動的に削除される文字があることを念の為考慮している
// FIXME: 考慮は要らないかも
const text = options ? options.text : await navigator.clipboard.readText();
if (text == undefined) return;
const beforeLength = inputText.value.length;
const end = inputSelection.selectionEnd ?? 0;
setText(inputSelection.getReplacedStringTo(text));
await nextTick();
inputSelection.setCursorPosition(
end + inputText.value.length - beforeLength,
);
};

/**
* バグ修正用
* 参考: https://github.com/VOICEVOX/voicevox/pull/1364#issuecomment-1620594931
*/
const clearInputSelection = () => {
if (!willFocusOrBlur.value) {
inputSelection.toEmpty();
}
};

const endContextMenuOperation = async () => {
await nextTick();
willFocusOrBlur.value = false;
};

return {
contextMenuHeader,
contextMenudata,
startContextMenuOperation,
clearInputSelection,
endContextMenuOperation,
};
}
40 changes: 22 additions & 18 deletions src/helpers/SelectionHelperForQInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,64 +5,68 @@ import { Ref } from "vue";
* QInput の選択範囲への操作を簡単にできるようにするクラス
*/
export class SelectionHelperForQInput {
private _nativeEl: HTMLInputElement | undefined = undefined;

constructor(private textfield: Ref<QInput | undefined>) {}

// this.start が number | null なので null も受け付ける
setCursorPosition(index: number | null) {
if (index == undefined) return;

this.nativeEl.selectionStart = this.nativeEl.selectionEnd = index;
const nativeEl = this.getNativeEl();
nativeEl.selectionStart = nativeEl.selectionEnd = index;
}

getReplacedStringTo(str: string) {
return `${this.substringBefore}${str}${this.substringAfter}`;
}

getAsString() {
return this.nativeEl.value.substring(
this.nativeEl.selectionStart ?? 0,
this.nativeEl.selectionEnd ?? 0,
const nativeEl = this.getNativeEl();
return nativeEl.value.substring(
nativeEl.selectionStart ?? 0,
nativeEl.selectionEnd ?? 0,
);
}

toEmpty() {
this.nativeEl.selectionEnd = this.nativeEl.selectionStart;
const nativeEl = this.getNativeEl();
nativeEl.selectionEnd = nativeEl.selectionStart;
}

get selectionStart() {
return this.nativeEl.selectionStart;
const nativeEl = this.getNativeEl();
return nativeEl.selectionStart;
}

get selectionEnd() {
return this.nativeEl.selectionEnd;
const nativeEl = this.getNativeEl();
return nativeEl.selectionEnd;
}

get substringBefore() {
return this.nativeEl.value.substring(0, this.nativeEl.selectionStart ?? 0);
const nativeEl = this.getNativeEl();
return nativeEl.value.substring(0, nativeEl.selectionStart ?? 0);
}

get substringAfter() {
return this.nativeEl.value.substring(this.nativeEl.selectionEnd ?? 0);
const nativeEl = this.getNativeEl();
return nativeEl.value.substring(nativeEl.selectionEnd ?? 0);
}

get isEmpty() {
const start = this.nativeEl.selectionStart;
const end = this.nativeEl.selectionEnd;
const nativeEl = this.getNativeEl();
const start = nativeEl.selectionStart;
const end = nativeEl.selectionEnd;
return start == undefined || end == undefined || start === end;
}

private get nativeEl() {
return this._nativeEl ?? this.getNativeEl();
}

/**
* NOTE: 最新の textfield を反映すべきなので nativeEl はキャッシュしない
*/
private getNativeEl() {
const nativeEl = this.textfield.value?.nativeEl;
if (!(nativeEl instanceof HTMLInputElement)) {
throw new Error("nativeElの取得に失敗しました。");
}
this._nativeEl = nativeEl;
return nativeEl;
}
}

0 comments on commit e640cd2

Please sign in to comment.