diff --git a/src/components/App.vue b/src/components/App.vue index a6418c2807..24ab41418f 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -32,6 +32,7 @@ import AllDialog from "@/components/Dialog/AllDialog.vue"; import MenuBar from "@/components/Menu/MenuBar/MenuBar.vue"; import { useMenuBarData as useTalkMenuBarData } from "@/components/Talk/menuBarData"; import { useMenuBarData as useSingMenuBarData } from "@/components/Sing/menuBarData"; +import { setFont, themeToCss } from "@/domain/dom"; const store = useStore(); @@ -64,7 +65,7 @@ watch( watch( () => store.state.editorFont, (editorFont) => { - document.body.setAttribute("data-editor-font", editorFont); + setFont(editorFont); }, { immediate: true }, ); @@ -79,6 +80,33 @@ watch( }, ); +// テーマの変更を監視してCSS変数を変更する +watch( + () => + [ + store.state.currentTheme, + store.state.availableThemes, + store.state.isVuexReady, + ] as const, + ([currentTheme, availableThemes, isVuexReady]) => { + const theme = availableThemes.find((value) => { + return value.name == currentTheme; + }); + + if (theme == undefined) { + // NOTE: Vuexが初期化されていない場合はまだテーマが読み込まれていないので無視 + if (isVuexReady) { + throw Error(`Theme not found: ${currentTheme}`); + } else { + return; + } + } + + themeToCss(theme); + }, + { immediate: true }, +); + // ソフトウェアを初期化 const { hotkeyManager } = useHotkeyManager(); const isEnginesReady = ref(false); diff --git a/src/components/Talk/TalkEditor.stories.ts b/src/components/Talk/TalkEditor.stories.ts index 17e43cbbd4..9c0c6fd9cf 100644 --- a/src/components/Talk/TalkEditor.stories.ts +++ b/src/components/Talk/TalkEditor.stories.ts @@ -1,23 +1,34 @@ -import { userEvent, within, expect, fn } from "@storybook/test"; +import { userEvent, within, expect, fn, waitFor } from "@storybook/test"; +import hotkeys from "hotkeys-js"; import { Meta, StoryObj } from "@storybook/vue3"; import { provide, toRaw } from "vue"; import TalkEditor from "./TalkEditor.vue"; import { createStoreWrapper, storeKey } from "@/store"; import { HotkeyManager, hotkeyManagerKey } from "@/plugins/hotkeyPlugin"; -import { StoreType } from "@/store/type"; import { assetsPath, createOpenAPIEngineMock, mockHost, -} from "@/storybook/engineMock"; +} from "@/mock/engineMock"; import { proxyStoreCreator } from "@/store/proxy"; -import { CharacterInfo, EngineId, SpeakerId, StyleId } from "@/type/preload"; -import { getEngineManifestMock } from "@/storybook/engineMock/manifestMock"; +import { + CharacterInfo, + defaultHotkeySettings, + DefaultStyleId, + EngineId, + EngineInfo, + SpeakerId, + StyleId, + ThemeConf, +} from "@/type/preload"; +import { getEngineManifestMock } from "@/mock/engineMock/manifestMock"; import { getSpeakerInfoMock, getSpeakersMock, -} from "@/storybook/engineMock/speakerResourceMock"; +} from "@/mock/engineMock/speakerResourceMock"; +import { setFont, themeToCss } from "@/domain/dom"; +import defaultTheme from "@/../public/themes/default.json"; const meta: Meta = { component: TalkEditor, @@ -27,20 +38,51 @@ const meta: Meta = { }, decorators: [ (story, context) => { + // CSS関連 + themeToCss(defaultTheme as ThemeConf); + setFont("default"); + + // ショートカットキーの管理 const hotkeyManager = new HotkeyManager(); provide(hotkeyManagerKey, hotkeyManager); + hotkeyManager.load(defaultHotkeySettings); + + hotkeyManager.onEditorChange("talk"); + // setup store const store = createStoreWrapper({ proxyStoreDI: proxyStoreCreator(createOpenAPIEngineMock()), }); - store.dispatch("HYDRATE_SETTING_STORE"); // FIXME: 色設定取得のため。設定も読み込んでしまうため不要にしたい。 + provide(storeKey, store); - // context.parameters.store = store; + // なぜか必要、これがないとdispatch内でcommitしたときにエラーになる + store.replaceState({ + ...structuredClone(toRaw(store.state)), + }); + // エンジンの情報 const engineManifest = getEngineManifestMock(); const engineId = EngineId(engineManifest.uuid); + const engineInfo: EngineInfo = { + uuid: engineId, + host: mockHost, + name: engineManifest.name, + path: undefined, + executionEnabled: false, + executionFilePath: "not_found", + executionArgs: [], + type: "default", + }; + store.commit("SET_ENGINE_INFOS", { + engineIds: [engineId], + engineInfos: [engineInfo], + }); + store.commit("SET_ENGINE_MANIFESTS", { + engineManifests: { [engineId]: engineManifest }, + }); + store.commit("SET_ENGINE_STATE", { engineId, engineState: "READY" }); - // setup store + // キャラクター情報 const speakers = getSpeakersMock(); const characterInfos: CharacterInfo[] = speakers.map((speaker) => { const speakerInfo = getSpeakerInfoMock(speaker.speakerUuid, assetsPath); @@ -66,45 +108,24 @@ const meta: Meta = { }, }; }); - - store.replaceState({ - ...structuredClone(toRaw(store.state)), - engineIds: [engineId], - engineStates: { - [engineId]: "READY", - }, - engineInfos: { - [engineId]: { - uuid: engineId, - host: mockHost, - name: engineManifest.name, - path: undefined, - executionEnabled: false, - executionFilePath: "not_found", - executionArgs: [], - type: "default", - }, - }, - engineManifests: { - [engineId]: engineManifest, - }, - characterInfos: { [engineId]: characterInfos }, - defaultStyleIds: speakers.map((speaker) => ({ - engineId: engineId, - speakerUuid: SpeakerId(speaker.speakerUuid), - defaultStyleId: StyleId(speaker.styles[0].id), - })), + store.commit("SET_CHARACTER_INFOS", { engineId, characterInfos }); + store.commit("SET_USER_CHARACTER_ORDER", { + userCharacterOrder: store.state.characterInfos[engineId].map( + (c) => c.metas.speakerUuid, + ), }); - provide(storeKey, store); + // デフォルトスタイルID + const defaultStyleIds: DefaultStyleId[] = speakers.map((speaker) => ({ + engineId: engineId, + speakerUuid: SpeakerId(speaker.speakerUuid), + defaultStyleId: StyleId(speaker.styles[0].id), + })); + store.commit("SET_DEFAULT_STYLE_IDS", { defaultStyleIds }); return story(); }, ], - beforeEach: async ({ parameters }) => { - // const store = parameters.store; // TODO: 型を付けたい - // await store.dispatch("LOAD_CHARACTER", { engineId: EngineId(mockHost) }); - }, }; export default meta; @@ -120,3 +141,18 @@ export const NowLoading: Story = { isProjectFileLoaded: "waiting", }, }; + +export const KeyboardShortcuts: Story = { + name: "キーボードショートカットのテスト ", + play: async ({ args, canvasElement }) => { + await waitFor(() => { + expect(args.onCompleteInitialStartup).toHaveBeenCalled(); + }); + + // Shift+Enter でテキスト欄を追加 + // await userEvent.keyboard("{Shift>}[Enter]{/Shift}"); + // await userEvent.keyboard("AAAAAA{Escape}"); + hotkeys.trigger("shift+enter", "talk"); + たぶんショートカットは使えない! + }, +}; diff --git a/src/components/Talk/TalkEditor.vue b/src/components/Talk/TalkEditor.vue index 5d6253f65e..7cf2f92c24 100644 --- a/src/components/Talk/TalkEditor.vue +++ b/src/components/Talk/TalkEditor.vue @@ -147,6 +147,11 @@ const props = defineProps<{ isProjectFileLoaded: boolean | "waiting"; }>(); +const emit = defineEmits<{ + /** トークエディタの準備が完了するときに呼ばれる */ + completeInitialStartup: []; +}>(); + const store = useStore(); const audioKeys = computed(() => store.state.audioKeys); @@ -508,7 +513,7 @@ watch(userOrderedCharacterInfos, (userOrderedCharacterInfos) => { } }); -// エンジン初期化後の処理 +/** トークエディタの準備が完了したフラグ */ const isCompletedInitialStartup = ref(false); // TODO: Vueっぽくないので解体する onetimeWatch( @@ -533,6 +538,7 @@ onetimeWatch( } isCompletedInitialStartup.value = true; + emit("completeInitialStartup"); return "unwatch"; }, diff --git a/src/domain/dom.ts b/src/domain/dom.ts new file mode 100644 index 0000000000..1160b215ae --- /dev/null +++ b/src/domain/dom.ts @@ -0,0 +1,42 @@ +import { colors, Dark, setCssVar } from "quasar"; +import { EditorFontType, ThemeColorType, ThemeConf } from "@/type/preload"; + +/** テーマの設定をCSSへ反映する */ +export function themeToCss(theme: ThemeConf) { + for (const key in theme.colors) { + const color = theme.colors[key as ThemeColorType]; + const { r, g, b } = colors.hexToRgb(color); + document.documentElement.style.setProperty(`--color-${key}`, color); + document.documentElement.style.setProperty( + `--color-${key}-rgb`, + `${r}, ${g}, ${b}`, + ); + } + const mixColors: ThemeColorType[][] = [ + ["primary", "background"], + ["warning", "background"], + ]; + for (const [color1, color2] of mixColors) { + const color1Rgb = colors.hexToRgb(theme.colors[color1]); + const color2Rgb = colors.hexToRgb(theme.colors[color2]); + const r = Math.trunc((color1Rgb.r + color2Rgb.r) / 2); + const g = Math.trunc((color1Rgb.g + color2Rgb.g) / 2); + const b = Math.trunc((color1Rgb.b + color2Rgb.b) / 2); + const propertyName = `--color-mix-${color1}-${color2}-rgb`; + const cssColor = `${r}, ${g}, ${b}`; + document.documentElement.style.setProperty(propertyName, cssColor); + } + Dark.set(theme.isDark); + setCssVar("primary", theme.colors["primary"]); + setCssVar("warning", theme.colors["warning"]); + + document.documentElement.setAttribute( + "is-dark-theme", + theme.isDark ? "true" : "false", + ); +} + +/** フォントを設定する */ +export function setFont(font: EditorFontType) { + document.body.setAttribute("data-editor-font", font); +} diff --git a/src/storybook/engineMock/index.ts b/src/mock/engineMock/index.ts similarity index 100% rename from src/storybook/engineMock/index.ts rename to src/mock/engineMock/index.ts diff --git a/src/storybook/engineMock/manifestMock.ts b/src/mock/engineMock/manifestMock.ts similarity index 100% rename from src/storybook/engineMock/manifestMock.ts rename to src/mock/engineMock/manifestMock.ts diff --git a/src/storybook/engineMock/phonemeMock.ts b/src/mock/engineMock/phonemeMock.ts similarity index 100% rename from src/storybook/engineMock/phonemeMock.ts rename to src/mock/engineMock/phonemeMock.ts diff --git a/src/storybook/engineMock/speakerResourceMock.ts b/src/mock/engineMock/speakerResourceMock.ts similarity index 100% rename from src/storybook/engineMock/speakerResourceMock.ts rename to src/mock/engineMock/speakerResourceMock.ts diff --git a/src/storybook/engineMock/talkModelMock.ts b/src/mock/engineMock/talkModelMock.ts similarity index 100% rename from src/storybook/engineMock/talkModelMock.ts rename to src/mock/engineMock/talkModelMock.ts diff --git a/src/storybook/engineMock/test.stories.ts b/src/mock/engineMock/test.stories.ts similarity index 100% rename from src/storybook/engineMock/test.stories.ts rename to src/mock/engineMock/test.stories.ts diff --git a/src/mock/geneareNormalizedRandomState.ts b/src/mock/geneareNormalizedRandomState.ts new file mode 100644 index 0000000000..e6145c08d1 --- /dev/null +++ b/src/mock/geneareNormalizedRandomState.ts @@ -0,0 +1,28 @@ +import { State } from "@/store/type"; +import { AudioKey } from "@/type/preload"; + +function generateIncrementalUuid(index: number) { + return `00000000-0000-0000-0000-${index.toString().padStart(12, "0")}`; +} + +/** Stateのランダムな部分を正規化する便利関数。テスト用。 */ +export function geneareNormalizedRandomState(state: State) { + // これだと入れ替えたときとかにテストできない + // ので、やっぱりrandomUuidを使う側をmockにしたい + // globalにステートを作って、reset関数を1個まとめてあげる形がまるそう + // どうやって1箇所に集めるかは課題 + // フォルダ名storybookに戻しても良さそう + + // AudioKey + const oldAudioKeys = state.audioKeys; + const newAudioKeys = oldAudioKeys.map((_, index) => + AudioKey(generateIncrementalUuid(index)), + ); + state.audioItems = Object.fromEntries( + oldAudioKeys.map((oldKey, index) => [ + newAudioKeys[index], + state.audioItems[oldKey], + ]), + ); + state.audioKeys = newAudioKeys; +} diff --git a/src/store/audio.ts b/src/store/audio.ts index 2b39f8ee33..0af92b05a2 100644 --- a/src/store/audio.ts +++ b/src/store/audio.ts @@ -684,8 +684,8 @@ export const audioStore = createPartialStore({ const query = getters.IS_ENGINE_READY(voice.engineId) ? await dispatch("FETCH_AUDIO_QUERY", fetchQueryParams).catch( - () => undefined, - ) + () => undefined, + ) : undefined; const newAudioItem: AudioItem = { text, voice }; @@ -2003,16 +2003,16 @@ export const audioCommandStore = transformCommandStore( changes: Record< AudioKey, | { - update: "AccentPhrases"; - accentPhrases: AccentPhrase[]; - } + update: "AccentPhrases"; + accentPhrases: AccentPhrase[]; + } | { - update: "AudioQuery"; - query: AudioQuery; - } + update: "AudioQuery"; + query: AudioQuery; + } | { - update: "OnlyVoice"; - } + update: "OnlyVoice"; + } >; }, ) { @@ -2067,16 +2067,16 @@ export const audioCommandStore = transformCommandStore( const changes: Record< AudioKey, | { - update: "AccentPhrases"; - accentPhrases: AccentPhrase[]; - } + update: "AccentPhrases"; + accentPhrases: AccentPhrase[]; + } | { - update: "AudioQuery"; - query: AudioQuery; - } + update: "AudioQuery"; + query: AudioQuery; + } | { - update: "OnlyVoice"; - } + update: "OnlyVoice"; + } > = {}; for (const audioKey of audioKeys) { diff --git a/src/store/setting.ts b/src/store/setting.ts index de6d909095..23056cd2ba 100644 --- a/src/store/setting.ts +++ b/src/store/setting.ts @@ -219,6 +219,8 @@ export const settingStore = createPartialStore({ state.currentTheme = currentTheme; }, action({ state, commit }, { currentTheme }: { currentTheme: string }) { + // メモ:テーマ周りリファクタリングしたのでモック挿せるはず。 + // うまくいけば、テーマ周りのリファクタリングだけでプルリク出す。 window.backend.setSetting("currentTheme", currentTheme); const theme = state.availableThemes.find((value) => { return value.name == currentTheme; @@ -228,38 +230,6 @@ export const settingStore = createPartialStore({ throw Error("Theme not found"); } - for (const key in theme.colors) { - const color = theme.colors[key as ThemeColorType]; - const { r, g, b } = colors.hexToRgb(color); - document.documentElement.style.setProperty(`--color-${key}`, color); - document.documentElement.style.setProperty( - `--color-${key}-rgb`, - `${r}, ${g}, ${b}`, - ); - } - const mixColors: ThemeColorType[][] = [ - ["primary", "background"], - ["warning", "background"], - ]; - for (const [color1, color2] of mixColors) { - const color1Rgb = colors.hexToRgb(theme.colors[color1]); - const color2Rgb = colors.hexToRgb(theme.colors[color2]); - const r = Math.trunc((color1Rgb.r + color2Rgb.r) / 2); - const g = Math.trunc((color1Rgb.g + color2Rgb.g) / 2); - const b = Math.trunc((color1Rgb.b + color2Rgb.b) / 2); - const propertyName = `--color-mix-${color1}-${color2}-rgb`; - const cssColor = `${r}, ${g}, ${b}`; - document.documentElement.style.setProperty(propertyName, cssColor); - } - Dark.set(theme.isDark); - setCssVar("primary", theme.colors["primary"]); - setCssVar("warning", theme.colors["warning"]); - - document.documentElement.setAttribute( - "is-dark-theme", - theme.isDark ? "true" : "false", - ); - window.backend.setNativeTheme(theme.isDark ? "dark" : "light"); commit("SET_CURRENT_THEME_SETTING", {