feat: support furigana and romaji generation on web (#2232)

* feat: support furigana and romaji generation on web
This commit is contained in:
York
2026-07-16 13:26:05 +08:00
committed by GitHub
parent 763e7d5638
commit 62c1ac04e4
12 changed files with 133 additions and 27 deletions
+16 -5
View File
@@ -1,15 +1,16 @@
import { externalizeDepsPlugin, UserConfig } from 'electron-vite';
import { defineConfig, externalizeDepsPlugin, UserConfig } from 'electron-vite';
import { resolve } from 'path';
import conditionalImportPlugin from 'vite-plugin-conditional-import';
import dynamicImportPlugin from 'vite-plugin-dynamic-import';
import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { kuromojiDictionaryPlugin } from './vite.kuromoji-plugin';
import { createReactPlugin } from './vite.react-plugin';
const currentOSEnv = process.platform;
const electronRendererTarget = 'chrome87';
const config: UserConfig = {
const createConfig = (isDevelopment: boolean): UserConfig => ({
main: {
build: {
rollupOptions: {
@@ -65,16 +66,26 @@ const config: UserConfig = {
localsConvention: 'camelCase',
},
},
plugins: [createReactPlugin(), ViteEjsPlugin({ web: false })],
plugins: [
createReactPlugin(),
...(isDevelopment ? [kuromojiDictionaryPlugin({ emitDictionary: false })] : []),
ViteEjsPlugin({ web: false }),
],
resolve: {
alias: {
'/@/i18n': resolve('src/i18n'),
'/@/lyrics-conversion-api': resolve(
isDevelopment
? 'src/renderer/features/lyrics/api/development-lyrics-conversion-api.ts'
: 'src/renderer/features/lyrics/api/electron-lyrics-conversion-api.ts',
),
'/@/remote': resolve('src/remote'),
'/@/renderer': resolve('src/renderer'),
'/@/shared': resolve('src/shared'),
...(isDevelopment ? { path: resolve('src/renderer/shims/path.ts') } : {}),
},
},
},
};
});
export default config;
export default defineConfig(({ command }) => createConfig(command === 'serve'));
+14 -1
View File
@@ -17,6 +17,14 @@ export type RomajiToken = LyricTextToken & {
let kuroshiroInstance: any = null;
let initPromise: null | Promise<void> = null;
const getDictionaryPath = (): string | undefined => {
if (typeof document === 'undefined') {
return undefined;
}
return new URL('./assets/kuromoji/', document.baseURI).href;
};
const getKuroshiro = async () => {
if (initPromise) {
await initPromise;
@@ -26,8 +34,13 @@ const getKuroshiro = async () => {
if (kuroshiroInstance) return kuroshiroInstance;
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
const dictionaryPath = getDictionaryPath();
const analyzer = dictionaryPath
? new KuromojiAnalyzer({ dictPath: dictionaryPath })
: new KuromojiAnalyzer();
kuroshiroInstance = new KuroshiroClass();
initPromise = kuroshiroInstance.init(new KuromojiAnalyzer());
initPromise = kuroshiroInstance.init(analyzer);
await initPromise;
initPromise = null;
@@ -0,0 +1,13 @@
import isElectron from 'is-electron';
import * as browserLyricsApi from '../../../../main/features/core/lyrics/furigana';
const lyricsApi = isElectron() ? window.api.lyrics : browserLyricsApi;
export const {
convertFurigana,
convertFuriganaFragment,
convertRomaji,
convertRomajiTokens,
parseLyricsTextTokens,
} = lyricsApi;
@@ -0,0 +1,7 @@
export const {
convertFurigana,
convertFuriganaFragment,
convertRomaji,
convertRomajiTokens,
parseLyricsTextTokens,
} = window.api.lyrics;
@@ -372,7 +372,6 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
description: t('setting.enableFurigana', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableFurigana'),
},
{
@@ -386,7 +385,6 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
description: t('setting.enableRomaji', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableRomaji'),
},
{
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import isElectron from 'is-electron';
import * as lyricsApi from '/@/lyrics-conversion-api';
import {
alignFuriganaToWordCues,
alignRomajiTokensToWordCues,
@@ -10,15 +10,9 @@ import {
import { normalizeLyrics } from '/@/renderer/features/lyrics/api/lyrics-utils';
import { LyricsResponse, SyncedCueLine, SynchronizedLyrics } from '/@/shared/types/domain-types';
const lyricsApi = isElectron() ? window.api.lyrics : null;
const convertSyncedLyricsFurigana = async (
lyrics: SynchronizedLyrics,
): Promise<SynchronizedLyrics> => {
if (!lyricsApi) {
return lyrics;
}
return Promise.all(
normalizeLyrics(lyrics).map(async (line) => ({
...line,
@@ -76,9 +70,9 @@ const convertSyncedLyricsRomaji = async (
export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => {
return useQuery({
enabled: enabled && !!lyrics && !!lyricsApi,
enabled: enabled && !!lyrics,
queryFn: async () => {
if (!lyrics || !lyricsApi || !enabled) return lyrics;
if (!lyrics || !enabled) return lyrics;
if (typeof lyrics === 'string') {
return await lyricsApi.convertFurigana(lyrics);
@@ -97,9 +91,9 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => {
return useQuery({
enabled: enabled && !!lyrics && !!lyricsApi,
enabled: enabled && !!lyrics,
queryFn: async () => {
if (!lyrics || !lyricsApi || !enabled) return lyrics;
if (!lyrics || !enabled) return lyrics;
if (typeof lyrics === 'string') {
return await lyricsApi.convertRomaji(lyrics);
@@ -129,10 +123,6 @@ const buildSyncedRomajiLine = async (
continue;
}
if (!lyricsApi) {
return cueLines.map(() => null);
}
const tokens = (await lyricsApi.convertRomajiTokens(cueLine.value)) as RomajiToken[];
if (!tokens.length) {
romajiCueLines.push(null);
@@ -160,9 +150,9 @@ export const useSyncedRomajiLyrics = (
enabled: boolean,
) => {
return useQuery({
enabled: enabled && !!lyrics && !!lyricsApi,
enabled: enabled && !!lyrics,
queryFn: async (): Promise<null | SyncedRomajiLyrics> => {
if (!lyrics || !lyricsApi || !enabled) {
if (!lyrics || !enabled) {
return null;
}
@@ -104,7 +104,6 @@ export const LyricSettings = memo(() => {
description: t('setting.enableFurigana', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableFurigana'),
},
{
@@ -118,7 +117,6 @@ export const LyricSettings = memo(() => {
description: t('setting.enableRomaji', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableRomaji'),
},
{
+9
View File
@@ -0,0 +1,9 @@
export const join = (...parts: string[]): string =>
parts
.filter(Boolean)
.map((part, index) =>
index === 0 ? part.replace(/\/+$/u, '') : part.replace(/^\/+|\/+$/gu, ''),
)
.join('/');
export default { join };
+1
View File
@@ -2,6 +2,7 @@
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"electron.vite.config.*",
"vite.kuromoji-plugin.ts",
"vite.react-plugin.ts",
"src/main/**/*",
"src/preload/**/*",
+4
View File
@@ -6,6 +6,7 @@
"src/renderer/**/*.tsx",
"src/preload/*.d.ts",
"src/i18n/**/*",
"src/main/features/core/lyrics/furigana.ts",
"src/shared/**/*",
"src/remote/**/*",
"package.json"
@@ -18,6 +19,9 @@
"/@/renderer/*": [
"src/renderer/*"
],
"/@/lyrics-conversion-api": [
"src/main/features/core/lyrics/furigana.ts"
],
"/@/shared/*": [
"src/shared/*"
],
+54
View File
@@ -0,0 +1,54 @@
import type { Connect, Plugin } from 'vite';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
const require = createRequire(import.meta.url);
const analyzerRequire = createRequire(require.resolve('kuroshiro-analyzer-kuromoji/package.json'));
const dictionaryDirectory = path.join(
path.dirname(analyzerRequire.resolve('kuromoji/package.json')),
'dict',
);
const dictionaryFiles = fs
.readdirSync(dictionaryDirectory)
.filter((fileName) => fileName.endsWith('.dat.gz'));
const serveDictionary: Connect.NextHandleFunction = (request, response, next) => {
const fileName = path.basename(new URL(request.url ?? '/', 'http://localhost').pathname);
if (!dictionaryFiles.includes(fileName)) {
next();
return;
}
response.setHeader('Content-Type', 'application/gzip');
fs.createReadStream(path.join(dictionaryDirectory, fileName)).pipe(response);
};
type KuromojiDictionaryPluginOptions = {
emitDictionary?: boolean;
};
export const kuromojiDictionaryPlugin = ({
emitDictionary = true,
}: KuromojiDictionaryPluginOptions = {}): Plugin => ({
configurePreviewServer(server) {
server.middlewares.use('/assets/kuromoji', serveDictionary);
},
configureServer(server) {
server.middlewares.use('/assets/kuromoji', serveDictionary);
},
generateBundle() {
if (!emitDictionary) return;
for (const fileName of dictionaryFiles) {
this.emitFile({
fileName: `assets/kuromoji/${fileName}`,
source: fs.readFileSync(path.join(dictionaryDirectory, fileName)),
type: 'asset',
});
}
},
name: 'kuromoji-dictionary',
});
+8
View File
@@ -3,6 +3,7 @@ import { defineConfig, normalizePath } from 'vite';
import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { VitePWA } from 'vite-plugin-pwa';
import { kuromojiDictionaryPlugin } from './vite.kuromoji-plugin';
import { createReactPlugin } from './vite.react-plugin';
export default defineConfig({
@@ -65,6 +66,7 @@ export default defineConfig({
},
plugins: [
createReactPlugin(),
kuromojiDictionaryPlugin(),
ViteEjsPlugin({
root: normalizePath(path.resolve(__dirname, './src/renderer')),
web: true,
@@ -134,6 +136,7 @@ export default defineConfig({
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
globIgnores: ['**/kuromoji/**'],
maximumFileSizeToCacheInBytes: 1000000 * 5, // 5 MB
skipWaiting: true,
},
@@ -142,9 +145,14 @@ export default defineConfig({
resolve: {
alias: {
'/@/i18n': path.resolve(__dirname, './src/i18n'),
'/@/lyrics-conversion-api': path.resolve(
__dirname,
'./src/main/features/core/lyrics/furigana.ts',
),
'/@/remote': path.resolve(__dirname, './src/remote'),
'/@/renderer': path.resolve(__dirname, './src/renderer'),
'/@/shared': path.resolve(__dirname, './src/shared'),
path: path.resolve(__dirname, './src/renderer/shims/path.ts'),
},
},
root: path.resolve(__dirname, './src/renderer'),