Skip to content

Commit

Permalink
コンテキストメニューのVue化 (#1374)
Browse files Browse the repository at this point in the history
Co-authored-by: Hiroshiba <hihokaruta@gmail.com>
  • Loading branch information
thiramisu and Hiroshiba authored Jul 22, 2023
1 parent b84438e commit b8e08ed
Show file tree
Hide file tree
Showing 13 changed files with 360 additions and 94 deletions.
5 changes: 0 additions & 5 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import dayjs from "dayjs";
import windowStateKeeper from "electron-window-state";
import zodToJsonSchema from "zod-to-json-schema";
import { hasSupportedGpu } from "./electron/device";
import { contextMenu } from "./electron/contextMenu";
import {
HotkeySetting,
ThemeConf,
Expand Down Expand Up @@ -745,10 +744,6 @@ ipcMainHandle("SHOW_IMPORT_FILE_DIALOG", (_, { title }) => {
})?.[0];
});

ipcMainHandle("OPEN_CONTEXT_MENU", (_, { menuType }) => {
contextMenu[menuType].popup({ window: win });
});

ipcMainHandle("IS_AVAILABLE_GPU_MODE", () => {
return hasSupportedGpu(process.platform);
});
Expand Down
3 changes: 0 additions & 3 deletions src/browser/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,6 @@ export const api: Sandbox = {
"ブラウザ版では現在ファイルの読み込みをサポートしていません"
);
},
openContextMenu() {
throw new Error(`Not supported on Browser version: openContextMenu`);
},
isAvailableGPUMode() {
// TODO: WebAssembly版をサポートする時に実装する
// FIXME: canvasでWebGLから調べたり、WebGPUがサポートされているかを調べたりで判断は出来そう
Expand Down
249 changes: 203 additions & 46 deletions src/components/AudioCell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
:ui-locked="uiLocked"
@focus="setActiveAudioKey()"
/>
<!--
input.valueをスクリプトから変更した場合は@changeが発火しないため、
@blurと@keydown.prevent.enter.exactに分けている
-->
<q-input
ref="textfield"
filled
Expand All @@ -34,12 +38,15 @@
:model-value="audioTextBuffer"
:aria-label="`${textLineNumberIndex}行目`"
@update:model-value="setAudioTextBuffer"
@change="willRemove || pushAudioText()"
@focus="
clearInputSelection();
setActiveAudioKey();
"
@blur="pushAudioTextIfNeeded()"
@paste="pasteOnAudioCell"
@focus="setActiveAudioKey()"
@keydown.prevent.up.exact="moveUpCell"
@keydown.prevent.down.exact="moveDownCell"
@mouseup.right="onRightClickTextField"
@keydown.prevent.enter.exact="pushAudioTextIfNeeded()"
>
<template #error>
文章が長いと正常に動作しない可能性があります。
Expand All @@ -56,16 +63,29 @@
@click="removeCell"
/>
</template>
<context-menu
ref="contextMenu"
:header="contextMenuHeader"
:menudata="contextMenudata"
@before-show="
startContextMenuOperation();
readyForContextMenu();
"
@before-hide="endContextMenuOperation()"
/>
</q-input>
</div>
</template>

<script setup lang="ts">
import { computed, watch, ref } from "vue";
import { computed, watch, ref, nextTick } from "vue";
import { QInput } from "quasar";
import CharacterButton from "./CharacterButton.vue";
import { MenuItemButton, MenuItemSeparator } from "./MenuBar.vue";
import ContextMenu from "./ContextMenu.vue";
import { useStore } from "@/store";
import { AudioKey, Voice } from "@/type/preload";
import { AudioKey, SplitTextWhenPasteType, Voice } from "@/type/preload";
import { SelectionHelperForQInput } from "@/helpers/SelectionHelperForQInput";
const props =
defineProps<{
Expand Down Expand Up @@ -149,8 +169,8 @@ watch(
}
);
const pushAudioText = async () => {
if (isChangeFlag.value) {
const pushAudioTextIfNeeded = async () => {
if (!willRemove.value && isChangeFlag.value && !willFocusOrBlur.value) {
isChangeFlag.value = false;
await store.dispatch("COMMAND_CHANGE_AUDIO_TEXT", {
audioKey: props.audioKey,
Expand All @@ -159,44 +179,81 @@ const pushAudioText = async () => {
}
};
// バグ修正用
// see https://github.com/VOICEVOX/voicevox/pull/1364#issuecomment-1620594931
const clearInputSelection = () => {
if (!willFocusOrBlur.value) {
textfieldSelection.toEmpty();
}
};
const setActiveAudioKey = () => {
store.dispatch("SET_ACTIVE_AUDIO_KEY", { audioKey: props.audioKey });
};
const isEnableSplitText = computed(() => store.state.splitTextWhenPaste);
// コピペしたときに句点と改行で区切る
const textSplitType = computed(() => store.state.splitTextWhenPaste);
const pasteOnAudioCell = async (event: ClipboardEvent) => {
if (event.clipboardData && isEnableSplitText.value !== "OFF") {
let texts: string[] = [];
const clipBoardData = event.clipboardData.getData("text/plain");
switch (isEnableSplitText.value) {
case "PERIOD_AND_NEW_LINE":
texts = clipBoardData.replaceAll("", "\n\r").split(/[\n\r]/);
break;
case "NEW_LINE":
texts = clipBoardData.split(/[\n\r]/);
break;
event.preventDefault();
paste({ text: event.clipboardData?.getData("text/plain") });
};
/**
* 貼り付け。
* ブラウザ版を考えるとClipboard APIをなるべく回避したいため、積極的に`options.text`を指定してください。
*/
const paste = async (options?: { text?: string }) => {
const text = options ? options.text : await navigator.clipboard.readText();
if (text === undefined) return;
// 複数行貼り付けできるか試す
if (textSplitType.value !== "OFF") {
const textSplitter: Record<
SplitTextWhenPasteType,
(text: string) => string[]
> = {
PERIOD_AND_NEW_LINE: (text) =>
text.replaceAll("", "\r\n").split(/[\r\n]/),
NEW_LINE: (text) => text.split(/[\r\n]/),
OFF: (text) => [text],
};
const texts = textSplitter[textSplitType.value](text);
if (texts.length >= 2 && texts.some((text) => text !== "")) {
await putMultilineText(texts);
return;
}
}
if (texts.length > 1) {
event.preventDefault();
blurCell(); // フォーカスを外して編集中のテキスト内容を確定させる
const beforeLength = audioTextBuffer.value.length;
const end = textfieldSelection.selectionEnd ?? 0;
setAudioTextBuffer(textfieldSelection.getReplacedStringTo(text));
await nextTick();
// 自動的に削除される改行などの文字数を念のため考慮している
textfieldSelection.setCursorPosition(
end + audioTextBuffer.value.length - beforeLength
);
};
const putMultilineText = async (texts: string[]) => {
// フォーカスを外して編集中のテキスト内容を確定させる
if (document.activeElement instanceof HTMLInputElement) {
document.activeElement.blur();
}
const prevAudioKey = props.audioKey;
if (audioTextBuffer.value == "") {
const text = texts.shift();
if (text == undefined) return;
setAudioTextBuffer(text);
await pushAudioText();
}
const prevAudioKey = props.audioKey;
if (audioTextBuffer.value == "") {
const text = texts.shift();
if (text == undefined) throw new Error("予期せぬタイプエラーです。");
setAudioTextBuffer(text);
await pushAudioTextIfNeeded();
}
const audioKeys = await store.dispatch("COMMAND_PUT_TEXTS", {
texts,
voice: audioItem.value.voice,
prevAudioKey,
});
if (audioKeys)
emit("focusCell", { audioKey: audioKeys[audioKeys.length - 1] });
}
const audioKeys = await store.dispatch("COMMAND_PUT_TEXTS", {
texts,
voice: audioItem.value.voice,
prevAudioKey,
});
if (audioKeys.length > 0) {
emit("focusCell", { audioKey: audioKeys[audioKeys.length - 1] });
}
};
Expand Down Expand Up @@ -259,21 +316,121 @@ const deleteButtonEnable = computed(() => {
});
// テキスト編集エリアの右クリック
const onRightClickTextField = async () => {
await store.dispatch("OPEN_CONTEXT_MENU", { menuType: "TEXT_EDIT" });
const contextMenu = ref<InstanceType<typeof ContextMenu>>();
// FIXME: 可能なら`isRangeSelected`と`contextMenuHeader`をcomputedに
const isRangeSelected = ref(false);
const contextMenuHeader = ref<string | undefined>("");
const contextMenudata = ref<
[
MenuItemButton,
MenuItemButton,
MenuItemButton,
MenuItemSeparator,
MenuItemButton
]
>([
// NOTE: audioTextBuffer.value の変更が nativeEl.value に反映されるのはnextTick。
{
type: "button",
label: "切り取り",
onClick: async () => {
contextMenu.value?.hide();
if (textfieldSelection.isEmpty) return;
const text = textfieldSelection.getAsString();
const start = textfieldSelection.selectionStart;
setAudioTextBuffer(textfieldSelection.getReplacedStringTo(""));
await nextTick();
navigator.clipboard.writeText(text);
textfieldSelection.setCursorPosition(start);
},
disableWhenUiLocked: true,
},
{
type: "button",
label: "コピー",
onClick: () => {
contextMenu.value?.hide();
if (textfieldSelection.isEmpty) return;
navigator.clipboard.writeText(textfieldSelection.getAsString());
},
disableWhenUiLocked: true,
},
{
type: "button",
label: "貼り付け",
onClick: async () => {
contextMenu.value?.hide();
paste();
},
disableWhenUiLocked: true,
},
{ type: "separator" },
{
type: "button",
label: "全選択",
onClick: async () => {
contextMenu.value?.hide();
textfield.value?.select();
},
disableWhenUiLocked: true,
},
]);
/**
* コンテキストメニューの開閉によりFocusやBlurが発生する可能性のある間は`true`。
*/
// no-focus を付けた場合と付けてない場合でタイミングが異なるため、両方に対応。
const willFocusOrBlur = ref(false);
const startContextMenuOperation = () => {
willFocusOrBlur.value = true;
};
const blurCell = (event?: KeyboardEvent) => {
if (event?.isComposing) {
return;
}
if (document.activeElement instanceof HTMLInputElement) {
document.activeElement.blur();
const readyForContextMenu = () => {
const getMenuItemButton = (label: string) => {
const item = contextMenudata.value.find((item) => item.label === label);
if (item?.type !== "button")
throw new Error("コンテキストメニューアイテムの取得に失敗しました。");
return item;
};
const MAX_HEADER_LENGTH = 15;
const SHORTED_HEADER_FRAGMENT_LENGTH = 5;
// 選択範囲を1行目に表示
const selectionText = textfieldSelection.getAsString();
if (selectionText.length === 0) {
isRangeSelected.value = false;
getMenuItemButton("切り取り").disabled = true;
getMenuItemButton("コピー").disabled = true;
} else {
isRangeSelected.value = true;
getMenuItemButton("切り取り").disabled = false;
getMenuItemButton("コピー").disabled = false;
if (selectionText.length > MAX_HEADER_LENGTH) {
// 長すぎる場合適度な長さで省略
contextMenuHeader.value =
selectionText.length <= MAX_HEADER_LENGTH
? selectionText
: `${selectionText.substring(
0,
SHORTED_HEADER_FRAGMENT_LENGTH
)} ... ${selectionText.substring(
selectionText.length - SHORTED_HEADER_FRAGMENT_LENGTH
)}`;
} else {
contextMenuHeader.value = selectionText;
}
}
};
const endContextMenuOperation = async () => {
await nextTick();
willFocusOrBlur.value = false;
};
// フォーカス
// テキスト欄
const textfield = ref<QInput>();
const textfieldSelection = new SelectionHelperForQInput(textfield);
// 複数エンジン
const isMultipleEngine = computed(() => store.state.engineIds.length > 1);
Expand Down
Loading

0 comments on commit b8e08ed

Please sign in to comment.