add dictionary UI

This commit is contained in:
mii
2025-03-05 22:04:32 +09:00
parent 89b688176b
commit 75210c08c9
7 changed files with 904 additions and 7 deletions

142
src-tauri/src/dictionary.rs Normal file
View 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!()
}
}

View File

@ -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 {

View File

@ -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
View 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;

View File

@ -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) {

View File

@ -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
View 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
};
}