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 <noreply@google.com>
This commit is contained in:
hyungyunlim 2025-07-17 14:10:50 +09:00
parent 68213201c4
commit 95269e62d8
7 changed files with 280 additions and 82 deletions

88
GEMINI.md Normal file
View file

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

44
main.ts
View file

@ -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();
};

View file

@ -130,6 +130,7 @@ export class SpellCheckOrchestrator {
start: selectionStart,
end: selectionEnd,
editor: editor,
ignoredWords: IgnoredWordsService.getIgnoredWords(this.settings),
onExceptionWordsAdded: (words: string[]) => this.handleExceptionWords(words)
});

View file

@ -7,18 +7,20 @@ export class CorrectionStateManager {
private states: Map<string | number, any> = 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';
}
}

View file

@ -36,6 +36,7 @@ export interface PopupConfig {
start: EditorPosition;
end: EditorPosition;
editor: Editor;
ignoredWords: string[];
onExceptionWordsAdded?: (words: string[]) => void;
}

View file

@ -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 {
<div class="header">
<h2> </h2>
<div style="display: flex; align-items: center; gap: 8px;">
<button class="cancel-btn"></button>
<button class="apply-btn"></button>
<button class="close-btn-header">×</button>
</div>
</div>
@ -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 = `<span class="${displayClass} clickable-error" data-correction-index="${index}" style="cursor: pointer;">${escapedOriginal}</span>`;
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 = `<span class="${displayClass} clickable-error" data-correction-index="${actualIndex}" style="cursor: pointer;">${escapedValue}</span>`;
// 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 =>
`<span class="suggestion-compact ${this.stateManager.isSelected(actualIndex, suggestion) ? 'selected' : ''}"
data-value="${escapeHtml(suggestion)}"
data-correction="${actualIndex}">
data-correction="${actualIndex}"
${isIgnored ? 'disabled' : ''}>
${escapeHtml(suggestion)}
</span>`
).join('');
return `
<div class="error-item-compact" data-correction-index="${actualIndex}">
<div class="error-item-compact ${isIgnored ? 'spell-ignored' : ''}" data-correction-index="${actualIndex}">
<div class="error-row">
<div class="error-original-compact">${escapeHtml(correction.original)}</div>
<div class="error-suggestions-compact">
${suggestionsHTML}
<span class="suggestion-compact ${this.stateManager.isSelected(actualIndex, correction.original) ? 'selected' : ''} keep-original"
data-value="${escapeHtml(correction.original)}"
data-correction="${actualIndex}">
data-correction="${actualIndex}"
${isIgnored ? 'disabled' : ''}>
</span>
</div>
@ -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();
}
}

View file

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