Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

辞書の単語・読み入力欄で右クリックメニューを使えるようにする #2156

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5a9a0e0
右クリックによるコンテキストメニュー表示
Jun 27, 2024
1f5bb84
辞書の単語と読みに全選択操作を追加
Jul 3, 2024
b32ecf1
辞書の単語と読みにコピー操作を追加
Jul 3, 2024
439cafd
辞書の単語と読みに切り取り操作を追加
Jul 3, 2024
0d1160f
辞書の単語と読みに貼り付け操作を追加
Jul 3, 2024
0d5f112
mainブランチのコミットを取り込み
Jul 19, 2024
330ab5a
右クリックメニューに関するコンポーザブルを追加し、処理をそちらに委譲
Jul 19, 2024
1fb4b65
mainブランチのコミットを取り込み
Aug 2, 2024
ba201cc
コンポーザブルを修正し、切り取りやコピーペーストができるようにする
Aug 2, 2024
9bdb2e5
選択したinputテキストをコンテキストメニューヘッダーに表示する
Aug 2, 2024
3ce1f1f
コンテキストメニューの開閉によるfocusやblurに対する処理
Aug 5, 2024
9c6e00d
mainブランチのコミットを取り込み
Aug 8, 2024
36701c3
右側のパネル描画をv-ifからv-showによる切り替えに修正
Aug 8, 2024
c6c0a88
コンポーザブルで不要なinputField引数の削除
Aug 14, 2024
7f65358
テキスト未選択時のコンテキストメニューヘッダーにテキストを表示させなくする
Aug 14, 2024
fc9bac0
mainブランチを取り込み
Aug 14, 2024
97bd8eb
eslintのエラーを回避
Aug 14, 2024
0680595
コメントの追加
Aug 16, 2024
e483d4a
関数の統合
Aug 16, 2024
6ea3e37
選択したテキストの表示・非表示処理をリファクタリング
Aug 16, 2024
58b0431
nativeElをキャッシュせず、常に新しく取得し直す
Aug 17, 2024
beb591e
コメント追加や関数名の変更など、細かい修正
Aug 18, 2024
d4b518d
Apply suggestions from code review
Hiroshiba Aug 19, 2024
f9c6f20
Apply suggestions from code review
Hiroshiba Aug 19, 2024
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
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 @@ -672,6 +691,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
Hiroshiba marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}
Loading