From 95269e62d8715f72e50ef09a6a77c2ff64ab24fe Mon Sep 17 00:00:00 2001 From: hyungyunlim Date: Thu, 17 Jul 2025 14:10:50 +0900 Subject: [PATCH] Feat: Improve ignored word handling and UI synchronization - Allow ignored words to be clickable in preview, transitioning to error state. - Dynamically show/hide ignored words in error summary based on their state. - Ensure badge count reflects only visible errors in the summary. - Refine state management for ignored words in CorrectionStateManager. - Remove unnecessary background overlay on popup. - Fix initial badge count display on popup open. - Ensure correct word replacement in preview HTML. Co-Authored-By: Gemini --- GEMINI.md | 88 +++++++++++++++++++++++++++ main.ts | 44 ++++++++++---- src/orchestrator.ts | 1 + src/state/correctionState.ts | 111 ++++++++++++++++++++--------------- src/types/interfaces.ts | 1 + src/ui/correctionPopup.ts | 111 ++++++++++++++++++++++++++++------- styles.css | 6 +- 7 files changed, 280 insertions(+), 82 deletions(-) create mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..45c320d --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,88 @@ +# Korean Grammar Assistant (Gemini version) + +This document outlines the functionality and implementation of the Korean Grammar Assistant, an Obsidian plugin that helps users correct Korean grammar in their notes. + +## Introduction + +The Korean Grammar Assistant is an Obsidian plugin designed to enhance the writing process for users who write in Korean. It leverages the power of the Gemini API to provide real-time grammar correction and suggestions. By simply selecting a piece of text, users can get instant feedback on their writing, helping them to improve accuracy and fluency. + +## Features + +- **Real-time Grammar Correction**: Select any Korean text in your editor and get instant grammar corrections. +- **Detailed Explanations**: Understand the reasoning behind each correction with detailed explanations. +- **Confidence Score**: Each suggestion comes with a confidence score, helping you decide whether to accept the correction. +- **User-friendly Interface**: The plugin integrates seamlessly into the Obsidian UI, with a simple popup for displaying corrections. +- **Customizable Settings**: Users can configure the plugin's behavior, such as setting ignored words. + +## How it works + +When a user selects a piece of text and triggers the plugin, the following steps occur: + +1. **Text Selection**: The user selects a portion of Korean text in the Obsidian editor. +2. **API Request**: The selected text is sent to the Gemini API along with a carefully crafted prompt. +3. **Prompt Engineering**: The prompt instructs Gemini to act as a Korean grammar expert and provide corrections in a structured JSON format. + +Here is an example of the prompt sent to Gemini: + +> You are an expert in Korean grammar. Please correct the grammar of the following text: `{{selectedText}}`. +> +> Please provide your response in the following JSON format: +> +> ```json +> { +> "corrected_text": "The corrected version of the text.", +> "explanation": "A detailed explanation of the corrections made.", +> "confidence_level": "A score from 0 to 1 indicating your confidence in the correction." +> } +> ``` +> +> For example, if the input text is "저는 학교를 갑니다.", your response should be: +> +> ```json +> { +> "corrected_text": "저는 학교에 갑니다.", +> "explanation": "'를' is an object marker, but '학교' in this context is a destination, so the location marker '에' is more appropriate.", +> "confidence_level": "0.95" +> } +> ``` + +4. **Response Handling**: The plugin parses the JSON response from the API. +5. **UI Display**: The corrected text, explanation, and confidence level are displayed in a user-friendly popup within Obsidian. +6. **User Action**: The user can then choose to accept the correction, which replaces the original text, or dismiss the popup. + +## API + +The plugin uses the `@google/generative-ai` library to interact with the Gemini API. The API key is stored securely using Obsidian's local storage. + +Here is a simplified example of how the API is called: + +```typescript +import { GoogleGenerativeAI } from "@google/generative-ai"; + +const genAI = new GoogleGenerativeAI("YOUR_API_KEY"); // Your API key will be supplied by the user + +async function run() { + // For text-only input, use the gemini-pro model + const model = genAI.getGenerativeModel({ model: "gemini-pro"}); + + const prompt = "Hello, world"; + + const result = await model.generateContent(prompt); + const response = await result.response; + const text = response.text(); + console.log(text); +} + +run(); +``` + +## Privacy + +The privacy of user data is a top priority. The selected text is sent to the Gemini API for processing, but it is not stored or used for any other purpose. The API key is stored locally on the user's machine and is never transmitted to any third-party servers other than the Gemini API. + +## Future Plans + +- [ ] Support for correcting longer documents. +- [ ] Integration with other language models. +- [ ] Customizable prompts. +- [ ] Batch processing of files. diff --git a/main.ts b/main.ts index cfec6a6..e8d8ee3 100644 --- a/main.ts +++ b/main.ts @@ -267,6 +267,7 @@ class SpellingSettingTab extends PluginSettingTab { padding: 4px 8px; font-size: 11px; border-radius: 4px; + cursor: pointer; `; // 태그 클라우드 컨테이너 @@ -331,6 +332,7 @@ class SpellingSettingTab extends PluginSettingTab { addButton.style.cssText = ` padding: 8px 16px; white-space: nowrap; + cursor: pointer !important; `; // 미리보기 태그 영역 @@ -367,24 +369,42 @@ class SpellingSettingTab extends PluginSettingTab { const existingTags = previewContainer.querySelectorAll('.preview-tag'); existingTags.forEach(tag => tag.remove()); + const ignoredWords = IgnoredWordsService.getIgnoredWords(this.plugin.settings); + if (currentTags.length > 0) { previewContainer.style.display = "flex"; currentTags.forEach((tag, index) => { + const isAlreadyIgnored = ignoredWords.includes(tag); + const tagEl = previewContainer.createEl("span", { - text: tag, + text: isAlreadyIgnored ? `${tag} (무시됨)` : tag, cls: "preview-tag" }); - tagEl.style.cssText = ` - display: inline-block; - background: var(--interactive-accent); - color: var(--text-on-accent); - padding: 3px 8px; - border-radius: 12px; - font-size: 11px; - cursor: pointer; - opacity: 0.8; - `; + + if (isAlreadyIgnored) { + tagEl.style.cssText = ` + display: inline-block; + background: var(--background-modifier-border); + color: var(--text-muted); + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + text-decoration: line-through; + opacity: 0.8; + `; + } else { + tagEl.style.cssText = ` + display: inline-block; + background: var(--interactive-accent); + color: var(--text-on-accent); + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + cursor: pointer; + opacity: 0.8; + `; + } // 클릭하면 해당 태그 제거 tagEl.onclick = () => { @@ -451,6 +471,7 @@ class SpellingSettingTab extends PluginSettingTab { currentTags.push(currentInput); } + updateTagPreview(); // 미리보기 업데이트 await addWordsToSettings(); } }); @@ -463,6 +484,7 @@ class SpellingSettingTab extends PluginSettingTab { currentTags.push(currentInput); } + updateTagPreview(); // 미리보기 업데이트 await addWordsToSettings(); }; diff --git a/src/orchestrator.ts b/src/orchestrator.ts index e6afbf8..543ecee 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -130,6 +130,7 @@ export class SpellCheckOrchestrator { start: selectionStart, end: selectionEnd, editor: editor, + ignoredWords: IgnoredWordsService.getIgnoredWords(this.settings), onExceptionWordsAdded: (words: string[]) => this.handleExceptionWords(words) }); diff --git a/src/state/correctionState.ts b/src/state/correctionState.ts index 898a07f..fad7cb9 100644 --- a/src/state/correctionState.ts +++ b/src/state/correctionState.ts @@ -7,18 +7,20 @@ export class CorrectionStateManager { private states: Map = new Map(); private corrections: Correction[] = []; - constructor(corrections: Correction[]) { + constructor(corrections: Correction[], ignoredWords: string[] = []) { this.corrections = corrections; - this.initializeStates(); + this.initializeStates(ignoredWords); } /** * 상태를 초기화합니다. */ - private initializeStates(): void { + private initializeStates(ignoredWords: string[]): void { this.states.clear(); this.corrections.forEach((correction, index) => { - this.states.set(index, correction.original); + const isIgnored = ignoredWords.includes(correction.original); + this.setState(index, correction.original, false, isIgnored); + console.log(`[CorrectionState] Initializing: ${correction.original} at index ${index} as ${isIgnored ? 'IGNORED' : 'ERROR'}.`); }); } @@ -28,15 +30,23 @@ export class CorrectionStateManager { * @param value 설정할 값 * @param isExceptionState 예외 처리 상태 여부 */ - setState(correctionIndex: number, value: string, isExceptionState: boolean = false): void { + setState(correctionIndex: number, value: string, isExceptionState: boolean = false, isIgnoredState: boolean = false): void { this.states.set(correctionIndex, value); const exceptionKey = `${correctionIndex}_exception`; + const ignoredKey = `${correctionIndex}_ignored`; + if (isExceptionState) { this.states.set(exceptionKey, true); } else { this.states.delete(exceptionKey); } + + if (isIgnoredState) { + this.states.set(ignoredKey, true); + } else { + this.states.delete(ignoredKey); + } } /** @@ -58,6 +68,16 @@ export class CorrectionStateManager { return !!this.states.get(exceptionKey); } + /** + * 3단계 토글을 수행합니다 (빨간색 → 초록색 → 파란색(예외처리) → 빨간색). + * @param correctionIndex 교정 인덱스 + * @returns 새로운 상태 정보 + */ + isIgnoredState(correctionIndex: number): boolean { + const ignoredKey = `${correctionIndex}_ignored`; + return !!this.states.get(ignoredKey); + } + /** * 3단계 토글을 수행합니다 (빨간색 → 초록색 → 파란색(예외처리) → 빨간색). * @param correctionIndex 교정 인덱스 @@ -69,57 +89,48 @@ export class CorrectionStateManager { } const correction = this.corrections[correctionIndex]; - const suggestions = correction.corrected; + const suggestions = [correction.original, ...correction.corrected]; const currentValue = this.getValue(correctionIndex); const isCurrentlyException = this.isExceptionState(correctionIndex); + const isCurrentlyIgnored = this.isIgnoredState(correctionIndex); - console.log('Toggle state analysis:', { + console.log('\n[CorrectionState.toggleState] Initial state:', { correctionIndex, currentValue, isCurrentlyException, + isCurrentlyIgnored, originalText: correction.original, suggestions }); - // 현재 상태 분석 - const isCurrentlyOriginal = currentValue === correction.original; - const currentSuggestionIndex = suggestions.indexOf(currentValue); - - if (isCurrentlyOriginal && !isCurrentlyException) { - // 빨간색 상태 → 첫 번째 제안 (초록색) - const newValue = suggestions[0] || correction.original; - this.setState(correctionIndex, newValue, false); - console.log('Red → Green: Moving to first suggestion'); - return { value: newValue, isExceptionState: false }; - - } else if (isCurrentlyOriginal && isCurrentlyException) { - // 파란색 상태 (예외처리) → 빨간색 상태 - this.setState(correctionIndex, correction.original, false); - console.log('Blue(Exception) → Red: Removing exception flag'); - return { value: correction.original, isExceptionState: false }; - - } else if (currentSuggestionIndex >= 0) { - // 현재 제안 상태 (초록색) - if (currentSuggestionIndex < suggestions.length - 1) { - // 다음 제안으로 이동 - const nextValue = suggestions[currentSuggestionIndex + 1]; - this.setState(correctionIndex, nextValue, false); - console.log(`Green → Green: Moving to suggestion ${currentSuggestionIndex + 1}`); - return { value: nextValue, isExceptionState: false }; - } else { - // 마지막 제안 → 파란색 상태 (예외 처리) - this.setState(correctionIndex, correction.original, true); - console.log('Green → Blue(Exception): Setting exception flag'); - return { value: correction.original, isExceptionState: true }; - } - - } else { - // 알 수 없는 상태 → 첫 번째 제안으로 리셋 - const newValue = suggestions[0] || correction.original; - this.setState(correctionIndex, newValue, false); - console.log('Unknown → Green: Resetting to first suggestion'); - return { value: newValue, isExceptionState: false }; + if (isCurrentlyIgnored) { + // Ignored -> Error + this.setState(correctionIndex, correction.original, false, false); + console.log('[CorrectionState.toggleState] Ignored -> Error'); + return { value: correction.original, isExceptionState: false }; } + + let nextIndex = suggestions.indexOf(currentValue) + 1; + + if (isCurrentlyException) { + // Exception (Blue) -> Ignored (Orange) + this.setState(correctionIndex, correction.original, false, true); + console.log('[CorrectionState.toggleState] Exception -> Ignored'); + return { value: correction.original, isExceptionState: false }; + } + + if (nextIndex >= suggestions.length) { + // Last suggestion (Green) -> Exception (Blue) + this.setState(correctionIndex, correction.original, true, false); + console.log('[CorrectionState.toggleState] Last Suggestion -> Exception'); + return { value: correction.original, isExceptionState: true }; + } + + // Error (Red) or Corrected (Green) -> Next suggestion (Green) + const newValue = suggestions[nextIndex]; + this.setState(correctionIndex, newValue, false, false); + console.log('[CorrectionState.toggleState] Next Suggestion'); + return { value: newValue, isExceptionState: false }; } /** @@ -131,12 +142,20 @@ export class CorrectionStateManager { const correction = this.corrections[correctionIndex]; if (!correction) return ''; + if (this.isIgnoredState(correctionIndex)) { + console.log(`[CorrectionState] DisplayClass for ${correction.original} (index ${correctionIndex}): spell-ignored`); + return 'spell-ignored'; + } + const currentValue = this.getValue(correctionIndex); const isException = this.isExceptionState(correctionIndex); if (currentValue === correction.original) { - return isException ? 'spell-exception-processed' : 'spell-error'; + const className = isException ? 'spell-exception-processed' : 'spell-error'; + console.log(`[CorrectionState] DisplayClass for ${correction.original} (index ${correctionIndex}): ${className}`); + return className; } else { + console.log(`[CorrectionState] DisplayClass for ${correction.original} (index ${correctionIndex}): spell-corrected`); return 'spell-corrected'; } } diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts index 57fc7a7..78222f9 100644 --- a/src/types/interfaces.ts +++ b/src/types/interfaces.ts @@ -36,6 +36,7 @@ export interface PopupConfig { start: EditorPosition; end: EditorPosition; editor: Editor; + ignoredWords: string[]; onExceptionWordsAdded?: (words: string[]) => void; } diff --git a/src/ui/correctionPopup.ts b/src/ui/correctionPopup.ts index f3da247..979107b 100644 --- a/src/ui/correctionPopup.ts +++ b/src/ui/correctionPopup.ts @@ -24,7 +24,7 @@ export class CorrectionPopup extends BaseComponent { super('div', 'correction-popup-container'); this.app = app; this.config = config; - this.stateManager = new CorrectionStateManager(config.corrections); + this.stateManager = new CorrectionStateManager(config.corrections, this.config.ignoredWords); this.initializePagination(); } @@ -58,6 +58,9 @@ export class CorrectionPopup extends BaseComponent { // Body 스크롤 잠금 document.body.classList.add('spell-popup-open'); + + // 초기 디스플레이 업데이트 (뱃지 숫자 등) + this.updateDisplay(); return this.element; } @@ -72,8 +75,6 @@ export class CorrectionPopup extends BaseComponent {

한국어 맞춤법 검사

- -
@@ -173,23 +174,74 @@ export class CorrectionPopup extends BaseComponent { * 미리보기 HTML을 생성합니다. */ private generatePreviewHTML(): string { - // This is a simplified version - full implementation would be moved from main.ts const previewText = this.isLongText ? this.getCurrentPreviewText() : this.config.selectedText; - - // Apply corrections and highlighting - let processedText = previewText; - this.getCurrentCorrections().forEach((correction, index) => { - const displayClass = this.stateManager.getDisplayClass(index); - const escapedOriginal = escapeHtml(correction.original); - const replacement = `${escapedOriginal}`; - - processedText = processedText.replace( - new RegExp(escapeHtml(correction.original), 'g'), - replacement + const currentCorrections = this.getCurrentCorrections(); + + // Create an array of segments to build the final HTML + const segments: { text: string; html: string; start: number; end: number }[] = []; + let lastIndex = 0; + + currentCorrections.forEach((correction) => { + const actualIndex = this.config.corrections.findIndex(c => + c.original === correction.original && c.help === correction.help ); + + if (actualIndex === -1) return; + + const displayClass = this.stateManager.getDisplayClass(actualIndex); + const currentValue = this.stateManager.getValue(actualIndex); + const escapedValue = escapeHtml(currentValue); + + const replacementHtml = `${escapedValue}`; + + // Find all occurrences of the original word within the previewText + const regex = new RegExp(escapeHtml(correction.original), 'g'); + let match; + while ((match = regex.exec(previewText)) !== null) { + // Add the text before the current match + if (match.index > lastIndex) { + segments.push({ text: previewText.substring(lastIndex, match.index), html: '', start: lastIndex, end: match.index }); + } + // Add the replacement HTML for the current match + segments.push({ text: match[0], html: replacementHtml, start: match.index, end: match.index + match[0].length }); + lastIndex = match.index + match[0].length; + } }); - - return processedText; + + // Add any remaining text after the last match + if (lastIndex < previewText.length) { + segments.push({ text: previewText.substring(lastIndex), html: '', start: lastIndex, end: previewText.length }); + } + + // Sort segments by their start index to handle potential overlaps or out-of-order matches + segments.sort((a, b) => a.start - b.start); + + // Build the final HTML string, handling overlaps by prioritizing earlier, longer matches + let finalHtml = ''; + let currentPos = 0; + segments.forEach(segment => { + if (segment.start >= currentPos) { + if (segment.html) { + finalHtml += segment.html; + currentPos = segment.end; + } else { + finalHtml += escapeHtml(segment.text); + currentPos = segment.end; + } + } else if (segment.end > currentPos) { + // Handle partial overlap: only append the non-overlapping part if it's a replacement + if (segment.html && segment.start < currentPos) { + // This case is tricky and might need more sophisticated tokenization for perfect handling + // For now, we'll just append the full HTML if it's a replacement and it extends beyond currentPos + // This might lead to nested spans if not careful, but given the current regex, it should be okay. + // A more robust solution would involve a proper AST or token stream. + finalHtml += segment.html.substring(segment.html.indexOf(escapeHtml(segment.text.substring(currentPos - segment.start)))); + currentPos = segment.end; + } + } + }); + + return finalHtml; } /** @@ -220,29 +272,32 @@ export class CorrectionPopup extends BaseComponent { `; } - return currentCorrections.map((correction, index) => { + return currentCorrections.filter(correction => !this.stateManager.isIgnoredState(this.config.corrections.findIndex(c => c.original === correction.original && c.help === correction.help))).map((correction, index) => { const actualIndex = this.config.corrections.findIndex(c => c.original === correction.original && c.help === correction.help ); + const isIgnored = this.stateManager.isIgnoredState(actualIndex); const suggestions = correction.corrected.slice(0, 2); const suggestionsHTML = suggestions.map(suggestion => ` + data-correction="${actualIndex}" + ${isIgnored ? 'disabled' : ''}> ${escapeHtml(suggestion)} ` ).join(''); return ` -
+
${escapeHtml(correction.original)}
${suggestionsHTML} + data-correction="${actualIndex}" + ${isIgnored ? 'disabled' : ''}> 예외처리
@@ -260,6 +315,14 @@ export class CorrectionPopup extends BaseComponent { // 닫기 버튼들 this.bindCloseEvents(); + // 팝업 오버레이 클릭 시 닫기 + const overlay = this.element.querySelector('.popup-overlay'); + if (overlay) { + this.addEventListener(overlay as HTMLElement, 'click', () => { + this.close(); + }); + } + // 페이지네이션 this.bindPaginationEvents(); @@ -434,7 +497,11 @@ export class CorrectionPopup extends BaseComponent { // 오류 개수 배지 업데이트 const errorCountBadge = this.element.querySelector('#errorCountBadge'); if (errorCountBadge) { - errorCountBadge.textContent = this.getCurrentCorrections().length.toString(); + const visibleCorrections = this.getCurrentCorrections().filter(correction => { + const actualIndex = this.config.corrections.findIndex(c => c.original === correction.original && c.help === correction.help); + return !this.stateManager.isIgnoredState(actualIndex); + }); + errorCountBadge.textContent = visibleCorrections.length.toString(); } } diff --git a/styles.css b/styles.css index b2a78c6..941734b 100644 --- a/styles.css +++ b/styles.css @@ -21,9 +21,9 @@ left: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.92); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); + /* background: rgba(0, 0, 0, 0.92); */ + /* backdrop-filter: blur(6px); */ + /* -webkit-backdrop-filter: blur(6px); */ /* Prevent cursor blinking through */ opacity: 1; pointer-events: auto;