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 14 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
49 changes: 47 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,21 @@
class="word-input"
dense
:disable="uiLocked"
@focus="clearSurfaceInputSelection()"
@blur="setSurface(surface)"
@keydown.enter="yomiFocus"
/>
>
<ContextMenu
ref="surfaceContextMenu"
:header="surfaceContextMenuHeader"
:menudata="surfaceContextMenudata"
@beforeShow="
readyForSurfaceContextMenu();
startSurfaceContextMenuOperation();
"
@beforeHide="endSurfaceContextMenuOperation()"
/>
</QInput>
</div>
<div class="row q-pl-md q-pt-sm">
<div class="text-h6">読み</div>
Expand All @@ -142,12 +154,23 @@
dense
:error="!isOnlyHiraOrKana"
:disable="uiLocked"
@focus="clearYomiInputSelection()"
@blur="setYomi(yomi)"
@keydown.enter="setYomiWhenEnter"
>
<template #error>
読みに使える文字はひらがなとカタカナのみです。
</template>
<ContextMenu
ref="yomiContextMenu"
:header="yomiContextMenuHeader"
:menudata="yomiContextMenudata"
@beforeShow="
readyForYomiContextMenu();
startYomiContextMenuOperation();
"
@beforeHide="endYomiContextMenuOperation()"
/>
</QInput>
</div>
<div class="row q-pl-md q-mt-lg text-h6">アクセント調整</div>
Expand Down Expand Up @@ -272,6 +295,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 +697,26 @@ const toWordEditingState = () => {
const toDialogClosedState = () => {
dictionaryManageDialogOpenedComputed.value = false;
};

const {
contextMenu: surfaceContextMenu,
contextMenuHeader: surfaceContextMenuHeader,
contextMenudata: surfaceContextMenudata,
readyForContextMenu: readyForSurfaceContextMenu,
clearInputSelection: clearSurfaceInputSelection,
startContextMenuOperation: startSurfaceContextMenuOperation,
endContextMenuOperation: endSurfaceContextMenuOperation,
} = useRightClickContextMenu(surfaceInput, surface);

const {
contextMenu: yomiContextMenu,
contextMenuHeader: yomiContextMenuHeader,
contextMenudata: yomiContextMenudata,
readyForContextMenu: readyForYomiContextMenu,
clearInputSelection: clearYomiInputSelection,
startContextMenuOperation: startYomiContextMenuOperation,
endContextMenuOperation: endYomiContextMenuOperation,
} = useRightClickContextMenu(yomiInput, yomi);
</script>

<style lang="scss" scoped>
Expand Down
167 changes: 167 additions & 0 deletions src/composables/useRightClickContextMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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";

// テキスト編集エリアの右クリック
// 参考実装: https://github.com/VOICEVOX/voicevox/pull/1374/files#diff-444f263f72d4db11fe82c672d5c232eb4c29d29dbc1ffd20e279d586b1b2c180R371-R379

/**
* コンポーネントの中で呼ばれた <QInput> に対して
* 切り取りやコピー、貼り付けの処理を行う
*/
export function useRightClickContextMenu(
qInputRef: Ref<QInput | undefined>,
inputText: Ref<string>,
) {
const inputSelection = new SelectionHelperForQInput(qInputRef);

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

const contextMenu = ref<InstanceType<typeof ContextMenu>>();

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

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 === 0) {
getMenuItemButton("切り取り").disabled = true;
getMenuItemButton("コピー").disabled = true;
} else {
getMenuItemButton("切り取り").disabled = false;
getMenuItemButton("コピー").disabled = false;
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;
}
}
};

const contextMenudata = ref<
[
MenuItemButton,
MenuItemButton,
MenuItemButton,
MenuItemSeparator,
MenuItemButton,
]
>([
{
type: "button",
label: "切り取り",
onClick: async () => {
contextMenu.value?.hide();

Check failure on line 76 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe call of an `any` typed value

Check failure on line 76 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access .hide on an `any` value

Check failure on line 76 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / build-test

Unsafe call of an `any` typed value

Check failure on line 76 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / build-test

Unsafe member access .hide on an `any` value
await handleCut();
},
disableWhenUiLocked: false,
},
{
type: "button",
label: "コピー",
onClick: async () => {
contextMenu.value?.hide();

Check failure on line 85 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe call of an `any` typed value

Check failure on line 85 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access .hide on an `any` value

Check failure on line 85 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / build-test

Unsafe call of an `any` typed value

Check failure on line 85 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / build-test

Unsafe member access .hide on an `any` value
if (inputSelection) {
await navigator.clipboard.writeText(inputSelection.getAsString());
}
},
disableWhenUiLocked: false,
},
{
type: "button",
label: "貼り付け",
onClick: async () => {
contextMenu.value?.hide();

Check failure on line 96 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe call of an `any` typed value

Check failure on line 96 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access .hide on an `any` value

Check failure on line 96 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / build-test

Unsafe call of an `any` typed value

Check failure on line 96 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / build-test

Unsafe member access .hide on an `any` value
await handlePaste();
},
disableWhenUiLocked: false,
},
{ type: "separator" },
{
type: "button",
label: "全選択",
onClick: async () => {
contextMenu.value?.hide();

Check failure on line 106 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe call of an `any` typed value

Check failure on line 106 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access .hide on an `any` value

Check failure on line 106 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / build-test

Unsafe call of an `any` typed value

Check failure on line 106 in src/composables/useRightClickContextMenu.ts

View workflow job for this annotation

GitHub Actions / build-test

Unsafe member access .hide on an `any` value
qInputRef.value?.select();
},
disableWhenUiLocked: false,
},
]);

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

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

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

const handlePaste = async (options?: { text?: string }) => {
if (!inputSelection) return;

const text = options ? options.text : await navigator.clipboard.readText();
if (text == undefined) return;
const beforeLength = inputText.value.length;
const end = inputSelection.selectionEnd ?? 0;
setSurfaceOrYomiText(inputSelection.getReplacedStringTo(text));
await nextTick();
inputSelection.setCursorPosition(
end + inputText.value.length - beforeLength,
);
};

const clearInputSelection = () => {
if (!willFocusOrBlur.value) {
inputSelection.toEmpty();
}
};

const startContextMenuOperation = () => {
willFocusOrBlur.value = true;
};

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

return {
contextMenu,
contextMenuHeader,
contextMenudata,
readyForContextMenu,
clearInputSelection,
startContextMenuOperation,
endContextMenuOperation,
};
}
Loading