mirror of
https://github.com/mii443/vrclipboard-ime-gui.git
synced 2025-08-22 16:15:32 +00:00
add dictionary UI
This commit is contained in:
142
src-tauri/src/dictionary.rs
Normal file
142
src-tauri/src/dictionary.rs
Normal file
@ -0,0 +1,142 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use tracing::{debug, error, info, trace};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
converter::converter::{get_custom_converter, Converter},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub enum ConversionMethod {
|
||||
Replace,
|
||||
None,
|
||||
Converter(char),
|
||||
}
|
||||
|
||||
impl Default for ConversionMethod {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct DictionaryEntry {
|
||||
pub input: String,
|
||||
pub method: ConversionMethod,
|
||||
pub output: Option<String>,
|
||||
pub use_regex: bool,
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
impl Default for DictionaryEntry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input: String::new(),
|
||||
method: ConversionMethod::None,
|
||||
output: None,
|
||||
use_regex: false,
|
||||
priority: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Dictionary {
|
||||
pub entries: Vec<DictionaryEntry>,
|
||||
}
|
||||
|
||||
impl Default for Dictionary {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dictionary {
|
||||
pub fn load() -> Result<Dictionary> {
|
||||
debug!("Loading dictionary");
|
||||
std::fs::create_dir_all(Config::get_path())?;
|
||||
|
||||
let dict_path = Self::get_path();
|
||||
if !dict_path.exists() {
|
||||
info!("Dictionary file not found, generating default");
|
||||
Self::generate_default_dictionary()?;
|
||||
}
|
||||
let mut file = File::open(&dict_path)?;
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
trace!("Raw dictionary contents: {}", contents);
|
||||
let dictionary: Dictionary = serde_yaml::from_str(&contents)?;
|
||||
debug!("Dictionary loaded successfully with {} entries", dictionary.entries.len());
|
||||
Ok(dictionary)
|
||||
}
|
||||
|
||||
pub fn save(&self, state: State<AppState>) -> Result<(), String> {
|
||||
debug!("Saving dictionary");
|
||||
std::fs::create_dir_all(Config::get_path()).unwrap();
|
||||
|
||||
let dict_path = Self::get_path();
|
||||
let mut file = match File::create(&dict_path) {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
error!("Failed to create dictionary file: {}", e);
|
||||
return Err(format!("Failed to create dictionary file: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
match serde_yaml::to_string(&self) {
|
||||
Ok(yaml) => {
|
||||
trace!("Dictionary to be saved: {}", yaml);
|
||||
if let Err(e) = file.write_all(yaml.as_bytes()) {
|
||||
error!("Failed to write dictionary: {}", e);
|
||||
return Err(format!("Failed to write dictionary: {}", e));
|
||||
}
|
||||
let mut app_dictionary = state.dictionary.lock().unwrap();
|
||||
*app_dictionary = self.clone();
|
||||
info!("Dictionary saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to serialize dictionary: {}", e);
|
||||
Err(format!("Failed to serialize dictionary: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_default_dictionary() -> Result<()> {
|
||||
debug!("Generating default dictionary");
|
||||
let dict_path = Self::get_path();
|
||||
let mut file = File::create(&dict_path)?;
|
||||
let default_dict = Dictionary::default();
|
||||
let yaml = serde_yaml::to_string(&default_dict).unwrap();
|
||||
file.write_all(yaml.as_bytes())?;
|
||||
file.flush()?;
|
||||
info!("Default dictionary generated successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_path() -> PathBuf {
|
||||
let path = Config::get_path().join("dictionary.yaml");
|
||||
trace!("Dictionary path: {:?}", path);
|
||||
path
|
||||
}
|
||||
|
||||
pub fn apply_conversion(&self, text: &str) -> Result<String> {
|
||||
debug!("Applying dictionary conversions to: {}", text);
|
||||
|
||||
|
||||
|
||||
todo!()
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ mod tsf;
|
||||
mod tsf_conversion;
|
||||
mod tauri_emit_subscriber;
|
||||
mod tsf_availability;
|
||||
mod dictionary;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
@ -27,6 +28,7 @@ use tauri_emit_subscriber::TauriEmitSubscriber;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use tsf_availability::check_tsf_availability;
|
||||
use tracing::debug;
|
||||
use dictionary::Dictionary;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
struct Log {
|
||||
@ -37,9 +39,11 @@ struct Log {
|
||||
|
||||
struct AppState {
|
||||
config: Mutex<Config>,
|
||||
dictionary: Mutex<Dictionary>,
|
||||
}
|
||||
|
||||
static STATE: Lazy<Mutex<Config>> = Lazy::new(|| Mutex::new(Config::load().unwrap()));
|
||||
static DICTIONARY: Lazy<Mutex<Dictionary>> = Lazy::new(|| Mutex::new(Dictionary::load().unwrap()));
|
||||
|
||||
#[tauri::command]
|
||||
fn load_settings(state: State<AppState>) -> Result<Config, String> {
|
||||
@ -71,6 +75,24 @@ fn check_tsf_availability_command() -> Result<bool, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_dictionary(state: State<AppState>) -> Result<Dictionary, String> {
|
||||
match Dictionary::load() {
|
||||
Ok(dictionary) => {
|
||||
let mut app_dictionary = state.dictionary.lock().unwrap();
|
||||
*app_dictionary = dictionary.clone();
|
||||
Ok(dictionary)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to load dictionary: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_dictionary(dictionary: Dictionary, state: State<AppState>) -> Result<(), String> {
|
||||
*DICTIONARY.lock().unwrap() = dictionary.clone();
|
||||
dictionary.save(state).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_ms_settings_regionlanguage_jpnime() -> Result<(), String> {
|
||||
let _ = std::process::Command::new("cmd")
|
||||
@ -89,11 +111,16 @@ fn main() {
|
||||
Config::generate_default_config().expect("Failed to generate default config");
|
||||
Config::load().expect("Failed to load default config")
|
||||
})),
|
||||
dictionary: Mutex::new(Dictionary::load().unwrap_or_else(|_| {
|
||||
Dictionary::generate_default_dictionary().expect("Failed to generate default dictionary");
|
||||
Dictionary::load().expect("Failed to load default dictionary")
|
||||
})),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![load_settings, save_settings, check_tsf_availability_command, open_ms_settings_regionlanguage_jpnime])
|
||||
.invoke_handler(tauri::generate_handler![load_settings, save_settings, check_tsf_availability_command, open_ms_settings_regionlanguage_jpnime, load_dictionary, save_dictionary])
|
||||
.setup(|app| {
|
||||
let _span = tracing::span!(tracing::Level::INFO, "main");
|
||||
app.manage(STATE.lock().unwrap().clone());
|
||||
app.manage(DICTIONARY.lock().unwrap().clone());
|
||||
let app_handle = app.app_handle().clone();
|
||||
|
||||
let registry = tracing_subscriber::registry().with(TauriEmitSubscriber {
|
||||
|
30
src/App.tsx
30
src/App.tsx
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { List, Settings, Terminal, Bug, Info } from 'lucide-react';
|
||||
import { List, Settings, Terminal, Bug, Info, Check, Book } from 'lucide-react';
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import "./App.css";
|
||||
@ -11,6 +11,7 @@ import TerminalComponent from "./TerminalComponent";
|
||||
import AboutComponent from "./AboutComponent";
|
||||
import TsfSettingsModal from "./TsfSettingsModal";
|
||||
import { Config } from "./SettingsComponent";
|
||||
import DictionaryComponent from "./DictionaryComponent";
|
||||
|
||||
interface Log {
|
||||
time: string;
|
||||
@ -24,6 +25,7 @@ const AppContent = () => {
|
||||
const [showTsfModal, setShowTsfModal] = useState(false);
|
||||
const [currentSettings, setCurrentSettings] = useState<Config | null>(null);
|
||||
const [isTsfAvailable, setIsTsfAvailable] = useState<boolean | null>(null);
|
||||
const [showTsfSuccessMessage, setShowTsfSuccessMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen<Log>('addLog', (event) => {
|
||||
@ -119,11 +121,17 @@ const AppContent = () => {
|
||||
</div>
|
||||
);
|
||||
case 'settings':
|
||||
return <SettingsComponent setShowTsfModal={setShowTsfModal} />;
|
||||
return <SettingsComponent
|
||||
setShowTsfModal={setShowTsfModal}
|
||||
currentSettings={currentSettings}
|
||||
onSaveSettings={saveSettings}
|
||||
/>;
|
||||
case 'terminal':
|
||||
return <TerminalComponent />;
|
||||
case 'about':
|
||||
return <AboutComponent />;
|
||||
case 'dictionary':
|
||||
return <DictionaryComponent />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@ -138,6 +146,7 @@ const AppContent = () => {
|
||||
<div className="space-y-1 flex-grow">
|
||||
<MenuItem icon={<List size={16} />} label="ログ" id="home" />
|
||||
<MenuItem icon={<Settings size={16} />} label="設定" id="settings" />
|
||||
<MenuItem icon={<Book size={16} />} label="辞書" id="dictionary" />
|
||||
</div>
|
||||
{/* 下側にデバッグタブを配置 */}
|
||||
<div className="pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
@ -159,8 +168,25 @@ const AppContent = () => {
|
||||
onClose={() => setShowTsfModal(false)}
|
||||
onSaveSettings={saveSettings}
|
||||
currentSettings={currentSettings}
|
||||
onTsfEnabled={() => {
|
||||
setShowTsfSuccessMessage(true);
|
||||
setTimeout(() => setShowTsfSuccessMessage(false), 5000); // 5秒後に非表示
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TSF有効化成功メッセージ */}
|
||||
{showTsfSuccessMessage && (
|
||||
<div className="fixed bottom-4 right-4 bg-green-100 dark:bg-green-900 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200 px-4 py-3 rounded shadow-lg flex items-center">
|
||||
<div className="mr-3 text-green-500 dark:text-green-400">
|
||||
<Check size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">TSF再変換が有効化されました</p>
|
||||
<p className="text-xs text-green-700 dark:text-green-300">文字変換機能が拡張されました</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
532
src/DictionaryComponent.tsx
Normal file
532
src/DictionaryComponent.tsx
Normal file
@ -0,0 +1,532 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Book, Save, Plus, Trash, ChevronUp, ChevronDown, AlertCircle, Check, Edit, AlignLeft, Info, X } from 'lucide-react';
|
||||
import {
|
||||
Dictionary,
|
||||
DictionaryEntry,
|
||||
ConversionMethod,
|
||||
availableConverters,
|
||||
getDefaultDictionaryEntry,
|
||||
getConverterInfo,
|
||||
convertToRustEntry,
|
||||
convertFromRustEntry
|
||||
} from './types/dictionary';
|
||||
|
||||
const DictionaryComponent: React.FC = () => {
|
||||
const [dictionary, setDictionary] = useState<Dictionary>({ entries: [] });
|
||||
const [selectedEntry, setSelectedEntry] = useState<DictionaryEntry | null>(null);
|
||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [isMethodDropdownOpen, setIsMethodDropdownOpen] = useState(false);
|
||||
const [isConverterDropdownOpen, setIsConverterDropdownOpen] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle');
|
||||
|
||||
const methodDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const converterDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 辞書データの読み込み
|
||||
useEffect(() => {
|
||||
loadDictionary();
|
||||
}, []);
|
||||
|
||||
// ドロップダウン外のクリックを検知して閉じる
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (methodDropdownRef.current && !methodDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsMethodDropdownOpen(false);
|
||||
}
|
||||
if (converterDropdownRef.current && !converterDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsConverterDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadDictionary = async () => {
|
||||
try {
|
||||
const loadedDictionary: any = await invoke('load_dictionary');
|
||||
|
||||
// Rust形式からTypeScript形式に変換
|
||||
const entriesWithPriority = loadedDictionary.entries.map((entry: any, index: number) => {
|
||||
const convertedEntry = convertFromRustEntry(entry);
|
||||
// priorityが設定されていない場合はデフォルト値を設定
|
||||
if (convertedEntry.priority === undefined) {
|
||||
convertedEntry.priority = index;
|
||||
}
|
||||
return convertedEntry;
|
||||
});
|
||||
|
||||
setDictionary({ entries: entriesWithPriority });
|
||||
} catch (error) {
|
||||
console.error('Failed to load dictionary:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDictionary = async (dict: Dictionary) => {
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
// TypeScript形式からRust形式に変換
|
||||
const rustDict = {
|
||||
entries: dict.entries.map(entry => convertToRustEntry(entry))
|
||||
};
|
||||
|
||||
await invoke('save_dictionary', { dictionary: rustDict });
|
||||
setSaveStatus('success');
|
||||
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to save dictionary:', error);
|
||||
setSaveStatus('error');
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEntry = () => {
|
||||
setSelectedEntry(getDefaultDictionaryEntry());
|
||||
setEditIndex(null);
|
||||
setIsEditing(true);
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleEditEntry = (entry: DictionaryEntry, index: number) => {
|
||||
setSelectedEntry({...entry});
|
||||
setEditIndex(index);
|
||||
setIsEditing(true);
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleDeleteEntry = (index: number) => {
|
||||
const newEntries = [...dictionary.entries];
|
||||
newEntries.splice(index, 1);
|
||||
const newDict = { ...dictionary, entries: newEntries };
|
||||
setDictionary(newDict);
|
||||
saveDictionary(newDict);
|
||||
};
|
||||
|
||||
const handleSaveEntry = () => {
|
||||
if (!selectedEntry) return;
|
||||
|
||||
const newEntries = [...dictionary.entries];
|
||||
|
||||
// 新規追加の場合
|
||||
if (editIndex === null) {
|
||||
newEntries.push(selectedEntry);
|
||||
} else {
|
||||
// 編集の場合
|
||||
newEntries[editIndex] = selectedEntry;
|
||||
}
|
||||
|
||||
const newDict = { ...dictionary, entries: newEntries };
|
||||
setDictionary(newDict);
|
||||
saveDictionary(newDict);
|
||||
setShowDialog(false);
|
||||
setIsEditing(false);
|
||||
setSelectedEntry(null);
|
||||
};
|
||||
|
||||
const handleChangePriority = (index: number, direction: 'up' | 'down') => {
|
||||
if (dictionary.entries.length <= 1) return;
|
||||
|
||||
const newEntries = [...dictionary.entries];
|
||||
const entry = newEntries[index];
|
||||
|
||||
if (direction === 'up' && index > 0) {
|
||||
const prevEntry = newEntries[index - 1];
|
||||
const tempPriority = prevEntry.priority;
|
||||
prevEntry.priority = entry.priority;
|
||||
entry.priority = tempPriority;
|
||||
|
||||
// 実際の配列の順序も変更
|
||||
newEntries[index] = prevEntry;
|
||||
newEntries[index - 1] = entry;
|
||||
} else if (direction === 'down' && index < newEntries.length - 1) {
|
||||
const nextEntry = newEntries[index + 1];
|
||||
const tempPriority = nextEntry.priority;
|
||||
nextEntry.priority = entry.priority;
|
||||
entry.priority = tempPriority;
|
||||
|
||||
// 実際の配列の順序も変更
|
||||
newEntries[index] = nextEntry;
|
||||
newEntries[index + 1] = entry;
|
||||
}
|
||||
|
||||
const newDict = { ...dictionary, entries: newEntries };
|
||||
setDictionary(newDict);
|
||||
saveDictionary(newDict);
|
||||
};
|
||||
|
||||
const handleChangeEntryField = (field: keyof DictionaryEntry, value: any) => {
|
||||
if (!selectedEntry) return;
|
||||
|
||||
const updatedEntry = { ...selectedEntry, [field]: value };
|
||||
|
||||
// 変換方法がReplace以外の場合はoutputをnullに
|
||||
if (field === 'method' && value !== ConversionMethod.Replace) {
|
||||
updatedEntry.output = undefined;
|
||||
}
|
||||
|
||||
// 変換方法がConverterの場合はconverter_charをデフォルト値に
|
||||
if (field === 'method' && value === ConversionMethod.Converter && !updatedEntry.converter_char) {
|
||||
updatedEntry.converter_char = 'r';
|
||||
}
|
||||
|
||||
setSelectedEntry(updatedEntry);
|
||||
};
|
||||
|
||||
const handleSelectMethod = (method: ConversionMethod) => {
|
||||
handleChangeEntryField('method', method);
|
||||
setIsMethodDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleSelectConverter = (converterId: string) => {
|
||||
handleChangeEntryField('converter_char', converterId);
|
||||
setIsConverterDropdownOpen(false);
|
||||
};
|
||||
|
||||
const getMethodLabel = (method: ConversionMethod, converterChar?: string) => {
|
||||
switch(method) {
|
||||
case ConversionMethod.Replace:
|
||||
return '置き換え';
|
||||
case ConversionMethod.None:
|
||||
return '無変換';
|
||||
case ConversionMethod.Converter:
|
||||
if (converterChar) {
|
||||
const converter = getConverterInfo(converterChar);
|
||||
return converter ? `変換: ${converter.name}` : '変換';
|
||||
}
|
||||
return '変換';
|
||||
default:
|
||||
return method;
|
||||
}
|
||||
};
|
||||
|
||||
const SaveStatusIndicator = () => {
|
||||
switch (saveStatus) {
|
||||
case 'saving':
|
||||
return <span className="flex items-center text-indigo-500 text-xs animate-pulse"><Save size={10} className="mr-0.5" /> 保存中</span>;
|
||||
case 'success':
|
||||
return <span className="flex items-center text-green-500 text-xs"><Check size={10} className="mr-0.5" /> 保存完了</span>;
|
||||
case 'error':
|
||||
return <span className="flex items-center text-red-500 text-xs"><AlertCircle size={10} className="mr-0.5" /> 保存失敗</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 辞書エントリダイアログ
|
||||
const EntryDialog = () => {
|
||||
if (!showDialog || !selectedEntry) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 max-w-xl w-full max-h-[90vh] overflow-y-auto transition-colors">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-800 dark:text-gray-200">
|
||||
{editIndex !== null ? '辞書エントリの編集' : '新しい辞書エントリ'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDialog(false);
|
||||
setIsEditing(false);
|
||||
setSelectedEntry(null);
|
||||
setIsMethodDropdownOpen(false);
|
||||
setIsConverterDropdownOpen(false);
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
変換対象文字列
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedEntry.input}
|
||||
onChange={(e) => handleChangeEntryField('input', e.target.value)}
|
||||
className="w-full p-1.5 text-sm border rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 focus:border-indigo-400 outline-none"
|
||||
placeholder="例: こんにちは"
|
||||
/>
|
||||
<div className="flex items-center mt-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="use_regex"
|
||||
checked={selectedEntry.use_regex}
|
||||
onChange={(e) => handleChangeEntryField('use_regex', e.target.checked)}
|
||||
className="h-3.5 w-3.5 text-indigo-500 border-gray-300 dark:border-gray-600 rounded dark:bg-gray-700"
|
||||
/>
|
||||
<label htmlFor="use_regex" className="ml-2 text-xs text-gray-700 dark:text-gray-300">
|
||||
正規表現を使用
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
変換方法
|
||||
</label>
|
||||
<div
|
||||
ref={methodDropdownRef}
|
||||
className="w-full p-1.5 text-sm border rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 flex justify-between items-center cursor-pointer hover:border-indigo-300 dark:hover:border-indigo-500 transition-colors"
|
||||
onClick={() => setIsMethodDropdownOpen(!isMethodDropdownOpen)}
|
||||
>
|
||||
<span>{getMethodLabel(selectedEntry.method as ConversionMethod, selectedEntry.converter_char)}</span>
|
||||
<ChevronDown size={14} className={`transition-transform ${isMethodDropdownOpen ? 'transform rotate-180' : ''}`} />
|
||||
</div>
|
||||
{isMethodDropdownOpen && (
|
||||
<div className="absolute z-10 mt-0.5 w-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded shadow-sm overflow-hidden text-sm transition-colors">
|
||||
<div
|
||||
className="p-1.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/50 cursor-pointer"
|
||||
onClick={() => handleSelectMethod(ConversionMethod.Replace)}
|
||||
>
|
||||
<div className={`flex items-center ${selectedEntry.method === ConversionMethod.Replace ? 'text-indigo-600 dark:text-indigo-400 font-medium' : 'dark:text-gray-300'}`}>
|
||||
{selectedEntry.method === ConversionMethod.Replace && <Check size={12} className="mr-1.5" />}
|
||||
<span className={selectedEntry.method === ConversionMethod.Replace ? 'ml-0' : 'ml-4'}>
|
||||
置き換え
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="p-1.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/50 cursor-pointer"
|
||||
onClick={() => handleSelectMethod(ConversionMethod.None)}
|
||||
>
|
||||
<div className={`flex items-center ${selectedEntry.method === ConversionMethod.None ? 'text-indigo-600 dark:text-indigo-400 font-medium' : 'dark:text-gray-300'}`}>
|
||||
{selectedEntry.method === ConversionMethod.None && <Check size={12} className="mr-1.5" />}
|
||||
<span className={selectedEntry.method === ConversionMethod.None ? 'ml-0' : 'ml-4'}>
|
||||
無変換
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="p-1.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/50 cursor-pointer"
|
||||
onClick={() => handleSelectMethod(ConversionMethod.Converter)}
|
||||
>
|
||||
<div className={`flex items-center ${selectedEntry.method === ConversionMethod.Converter ? 'text-indigo-600 dark:text-indigo-400 font-medium' : 'dark:text-gray-300'}`}>
|
||||
{selectedEntry.method === ConversionMethod.Converter && <Check size={12} className="mr-1.5" />}
|
||||
<span className={selectedEntry.method === ConversionMethod.Converter ? 'ml-0' : 'ml-4'}>
|
||||
変換
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEntry.method === ConversionMethod.Converter && (
|
||||
<div className="relative mb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
変換器
|
||||
</label>
|
||||
<div
|
||||
ref={converterDropdownRef}
|
||||
className="w-full p-1.5 text-sm border rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 flex justify-between items-center cursor-pointer hover:border-indigo-300 dark:hover:border-indigo-500 transition-colors"
|
||||
onClick={() => setIsConverterDropdownOpen(!isConverterDropdownOpen)}
|
||||
>
|
||||
<span>
|
||||
{selectedEntry.converter_char
|
||||
? getConverterInfo(selectedEntry.converter_char)?.name || 'ローマ字→漢字'
|
||||
: 'ローマ字→漢字'}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`transition-transform ${isConverterDropdownOpen ? 'transform rotate-180' : ''}`} />
|
||||
</div>
|
||||
{isConverterDropdownOpen && (
|
||||
<div className="absolute z-10 mt-0.5 w-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded shadow-sm overflow-hidden text-sm transition-colors">
|
||||
{availableConverters.map(converter => (
|
||||
<div
|
||||
key={converter.id}
|
||||
className="p-1.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/50 cursor-pointer"
|
||||
onClick={() => handleSelectConverter(converter.id)}
|
||||
>
|
||||
<div className={`flex items-center ${selectedEntry.converter_char === converter.id ? 'text-indigo-600 dark:text-indigo-400 font-medium' : 'dark:text-gray-300'}`}>
|
||||
{selectedEntry.converter_char === converter.id && <Check size={12} className="mr-1.5" />}
|
||||
<span className={selectedEntry.converter_char === converter.id ? 'ml-0' : 'ml-4'}>
|
||||
{converter.name} - {converter.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEntry.method === ConversionMethod.Replace && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
置き換え後の文字列
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedEntry.output || ''}
|
||||
onChange={(e) => handleChangeEntryField('output', e.target.value)}
|
||||
className="w-full p-1.5 text-sm border rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 focus:border-indigo-400 outline-none"
|
||||
placeholder="例: Hello"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
優先順位
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={selectedEntry.priority}
|
||||
onChange={(e) => handleChangeEntryField('priority', parseInt(e.target.value) || 0)}
|
||||
className="w-full p-1.5 text-sm border rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 focus:border-indigo-400 outline-none"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
数値が大きいほど優先度が高くなります。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDialog(false);
|
||||
setIsEditing(false);
|
||||
setSelectedEntry(null);
|
||||
setIsMethodDropdownOpen(false);
|
||||
setIsConverterDropdownOpen(false);
|
||||
}}
|
||||
className="mr-2 px-3 py-1.5 rounded text-sm bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEntry}
|
||||
className="px-3 py-1.5 rounded text-sm bg-indigo-500 hover:bg-indigo-600 text-white dark:bg-indigo-600 dark:hover:bg-indigo-700"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-base font-medium text-gray-700 dark:text-gray-200 flex items-center transition-colors">
|
||||
<Book size={16} className="mr-1.5" />
|
||||
辞書
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<SaveStatusIndicator />
|
||||
<button
|
||||
onClick={handleAddEntry}
|
||||
className="flex items-center text-xs text-white bg-indigo-500 hover:bg-indigo-600 dark:bg-indigo-600 dark:hover:bg-indigo-700 px-2 py-1 rounded"
|
||||
>
|
||||
<Plus size={12} className="mr-1" />
|
||||
新規追加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-100 dark:border-gray-700 p-3 transition-colors">
|
||||
{dictionary.entries.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-700 transition-colors">
|
||||
<th className="px-3 py-2 text-left text-xs text-gray-500 dark:text-gray-400 font-medium">優先度</th>
|
||||
<th className="px-3 py-2 text-left text-xs text-gray-500 dark:text-gray-400 font-medium">変換対象</th>
|
||||
<th className="px-3 py-2 text-left text-xs text-gray-500 dark:text-gray-400 font-medium">変換方法</th>
|
||||
<th className="px-3 py-2 text-left text-xs text-gray-500 dark:text-gray-400 font-medium">変換後</th>
|
||||
<th className="px-3 py-2 text-left text-xs text-gray-500 dark:text-gray-400 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dictionary.entries.map((entry, index) => (
|
||||
<tr key={index} className="border-t border-gray-100 dark:border-gray-700 transition-colors">
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{entry.priority}</span>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
onClick={() => handleChangePriority(index, 'up')}
|
||||
className="text-gray-500 hover:text-indigo-500 mb-0.5 disabled:opacity-30"
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleChangePriority(index, 'down')}
|
||||
className="text-gray-500 hover:text-indigo-500 disabled:opacity-30"
|
||||
disabled={index === dictionary.entries.length - 1}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">
|
||||
<div className="flex items-center">
|
||||
{entry.use_regex && (
|
||||
<span className="mr-1 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 px-1 py-0.5 rounded">正規表現</span>
|
||||
)}
|
||||
<span className="font-mono">{entry.input}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">
|
||||
{getMethodLabel(entry.method as ConversionMethod, entry.converter_char)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300 font-mono">
|
||||
{entry.method === ConversionMethod.Replace ? entry.output : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => handleEditEntry(entry, index)}
|
||||
className="text-indigo-500 hover:text-indigo-600 dark:text-indigo-400 dark:hover:text-indigo-300 p-1"
|
||||
title="編集"
|
||||
>
|
||||
<Edit size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteEntry(index)}
|
||||
className="text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 p-1"
|
||||
title="削除"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<AlignLeft size={32} strokeWidth={1.5} className="mb-2" />
|
||||
<p className="mb-1">辞書エントリがありません</p>
|
||||
<p className="text-xs mb-4">「新規追加」ボタンから辞書エントリを追加してください</p>
|
||||
<button
|
||||
onClick={handleAddEntry}
|
||||
className="flex items-center text-xs bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1.5 rounded"
|
||||
>
|
||||
<Plus size={12} className="mr-1.5" />
|
||||
新規エントリを追加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* エントリ編集ダイアログ */}
|
||||
<EntryDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DictionaryComponent;
|
@ -90,9 +90,15 @@ const CheckboxField: React.FC<CheckboxFieldProps> = ({ id, name, label, checked,
|
||||
|
||||
interface SettingsComponentProps {
|
||||
setShowTsfModal: (show: boolean) => void;
|
||||
currentSettings: Config | null;
|
||||
onSaveSettings: (config: Config) => Promise<void>;
|
||||
}
|
||||
|
||||
const SettingsComponent: React.FC<SettingsComponentProps> = ({ setShowTsfModal }) => {
|
||||
const SettingsComponent: React.FC<SettingsComponentProps> = ({
|
||||
setShowTsfModal,
|
||||
currentSettings,
|
||||
onSaveSettings
|
||||
}) => {
|
||||
const [settings, setSettings] = useState<Config>({
|
||||
prefix: ';',
|
||||
split: '/',
|
||||
@ -115,6 +121,13 @@ const SettingsComponent: React.FC<SettingsComponentProps> = ({ setShowTsfModal }
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 親コンポーネントから新しい設定が渡されたら更新する
|
||||
useEffect(() => {
|
||||
if (currentSettings) {
|
||||
setSettings(currentSettings);
|
||||
}
|
||||
}, [currentSettings]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const loadedSettings: Config = await invoke('load_settings');
|
||||
@ -127,7 +140,12 @@ const SettingsComponent: React.FC<SettingsComponentProps> = ({ setShowTsfModal }
|
||||
const saveSettings = async (newSettings: Config) => {
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
await invoke('save_settings', { config: newSettings });
|
||||
if (onSaveSettings) {
|
||||
await onSaveSettings(newSettings);
|
||||
} else {
|
||||
// 後方互換性のために残す
|
||||
await invoke('save_settings', { config: newSettings });
|
||||
}
|
||||
setSaveStatus('success');
|
||||
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
} catch (error) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { AlertCircle, Settings, Check, X, ExternalLink } from 'lucide-react';
|
||||
import { Config } from './SettingsComponent';
|
||||
@ -9,14 +9,65 @@ interface TsfSettingsModalProps {
|
||||
onClose: () => void;
|
||||
onSaveSettings: (config: Config) => Promise<void>;
|
||||
currentSettings: Config;
|
||||
onTsfEnabled?: () => void;
|
||||
}
|
||||
|
||||
const TsfSettingsModal: React.FC<TsfSettingsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSaveSettings,
|
||||
currentSettings
|
||||
currentSettings,
|
||||
onTsfEnabled
|
||||
}) => {
|
||||
const [checkingStatus, setCheckingStatus] = useState<'idle' | 'checking'>('idle');
|
||||
|
||||
// TSFが利用可能かどうかを定期的にチェックする
|
||||
useEffect(() => {
|
||||
if (!isOpen) return; // モーダルが閉じている場合は処理しない
|
||||
|
||||
let intervalId: number | null = null;
|
||||
|
||||
const checkTsfAvailability = async () => {
|
||||
try {
|
||||
const available: boolean = await invoke('check_tsf_availability_command');
|
||||
|
||||
if (available) {
|
||||
// TSFが利用可能になったら
|
||||
const newSettings = { ...currentSettings, use_tsf_reconvert: true };
|
||||
await onSaveSettings(newSettings);
|
||||
|
||||
// 成功コールバックを呼び出し
|
||||
if (onTsfEnabled) {
|
||||
onTsfEnabled();
|
||||
}
|
||||
|
||||
// モーダルを閉じる
|
||||
onClose();
|
||||
|
||||
// インターバルをクリア
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('TSF利用可能性チェックに失敗しました:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 初回チェック
|
||||
checkTsfAvailability();
|
||||
|
||||
// 1秒ごとにチェック(標準的なsetIntervalを使用)
|
||||
intervalId = window.setInterval(checkTsfAvailability, 1000);
|
||||
|
||||
// クリーンアップ関数
|
||||
return () => {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [isOpen, currentSettings, onSaveSettings, onClose, onTsfEnabled]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const openWindowsSettings = async () => {
|
||||
@ -89,6 +140,10 @@ const TsfSettingsModal: React.FC<TsfSettingsModalProps> = ({
|
||||
<span className="text-xs text-red-500 ml-1.5">(機能制限あり)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
設定が完了すると、このウィンドウは自動的に閉じます
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
97
src/types/dictionary.ts
Normal file
97
src/types/dictionary.ts
Normal file
@ -0,0 +1,97 @@
|
||||
export enum ConversionMethod {
|
||||
Replace = 'Replace',
|
||||
None = 'None',
|
||||
Converter = 'Converter'
|
||||
}
|
||||
|
||||
export interface DictionaryEntry {
|
||||
input: string;
|
||||
method: ConversionMethod;
|
||||
output?: string;
|
||||
use_regex: boolean;
|
||||
priority: number;
|
||||
converter_char?: string;
|
||||
}
|
||||
|
||||
export interface RustDictionaryEntry {
|
||||
input: string;
|
||||
method: string | { Converter: string };
|
||||
output?: string;
|
||||
use_regex: boolean;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface RustDictionary {
|
||||
entries: RustDictionaryEntry[];
|
||||
}
|
||||
|
||||
export interface Dictionary {
|
||||
entries: DictionaryEntry[];
|
||||
}
|
||||
|
||||
export interface ConverterInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const availableConverters: ConverterInfo[] = [
|
||||
{ id: 'r', name: 'ローマ字→漢字', description: 'ローマ字を漢字に変換します' },
|
||||
{ id: 'h', name: 'ひらがな変換', description: '入力をひらがなに変換します' },
|
||||
{ id: 'k', name: 'カタカナ変換', description: '入力をカタカナに変換します' },
|
||||
{ id: 'c', name: '計算', description: '数式を計算します' },
|
||||
{ id: 'n', name: '無変換', description: '入力をそのまま出力します' },
|
||||
];
|
||||
|
||||
export function getConverterInfo(id: string): ConverterInfo | undefined {
|
||||
return availableConverters.find(converter => converter.id === id);
|
||||
}
|
||||
|
||||
export function getDefaultDictionaryEntry(): DictionaryEntry {
|
||||
return {
|
||||
input: '',
|
||||
method: ConversionMethod.Replace,
|
||||
output: '',
|
||||
use_regex: false,
|
||||
priority: 0
|
||||
};
|
||||
}
|
||||
|
||||
export function convertToRustEntry(entry: DictionaryEntry): RustDictionaryEntry {
|
||||
let method: string | { Converter: string };
|
||||
|
||||
if (entry.method === ConversionMethod.Converter && entry.converter_char) {
|
||||
method = { Converter: entry.converter_char };
|
||||
} else {
|
||||
method = entry.method;
|
||||
}
|
||||
|
||||
return {
|
||||
input: entry.input,
|
||||
method: method,
|
||||
output: entry.method === ConversionMethod.Replace ? entry.output : undefined,
|
||||
use_regex: entry.use_regex,
|
||||
priority: entry.priority
|
||||
};
|
||||
}
|
||||
|
||||
export function convertFromRustEntry(entry: any): DictionaryEntry {
|
||||
let method: ConversionMethod;
|
||||
let converter_char: string | undefined;
|
||||
|
||||
if (typeof entry.method === 'object' && entry.method.Converter) {
|
||||
method = ConversionMethod.Converter;
|
||||
converter_char = entry.method.Converter;
|
||||
} else {
|
||||
method = entry.method as ConversionMethod;
|
||||
}
|
||||
|
||||
return {
|
||||
input: entry.input,
|
||||
method: method,
|
||||
output: entry.output,
|
||||
use_regex: entry.use_regex,
|
||||
priority: entry.priority,
|
||||
converter_char: converter_char
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user