asyouplz_SpeechNote/src/application/EditorService.ts
asyouplz 07d8f3cdce chore(format): run prettier
Apply repository formatting to satisfy CI format check.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 00:10:37 +09:00

698 lines
19 KiB
TypeScript

import { App, MarkdownView, Editor, EditorPosition, EditorRange, Notice } from 'obsidian';
import { Logger } from '../infrastructure/logging/Logger';
import { EventManager } from './EventManager';
/**
* 에디터 서비스 - 옵시디언 에디터 통합 관리
*
* 주요 기능:
* - 활성 에디터 감지 및 관리
* - 커서 위치 추적
* - 텍스트 삽입/수정
* - 에디터 상태 관리
* - Undo/Redo 지원
*/
export class EditorService {
private activeEditor: Editor | null = null;
private activeView: MarkdownView | null = null;
private readonly logger: Logger;
private readonly undoHistory: EditorAction[] = [];
private readonly redoHistory: EditorAction[] = [];
private readonly maxHistorySize = 50;
private destroyed = false;
private readonly eventRefs: Array<{ event: string; callback: (...args: unknown[]) => void }> =
[];
constructor(
private app: App,
private eventManager: EventManager = EventManager.getInstance(),
logger?: Logger
) {
this.logger = logger || new Logger('EditorService');
this.setupEventListeners();
}
private normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error('Unknown error');
}
private isEditor(value: unknown): value is Editor {
if (!value || typeof value !== 'object') {
return false;
}
return (
typeof Reflect.get(value, 'getCursor') === 'function' &&
typeof Reflect.get(value, 'replaceRange') === 'function' &&
typeof Reflect.get(value, 'setValue') === 'function'
);
}
/**
* 이벤트 리스너 설정
*/
private setupEventListeners(): void {
const workspace = this.app.workspace;
if (!workspace || typeof workspace.on !== 'function') {
this.logger.warn('Workspace event API not available');
return;
}
// 활성 뷰 변경 감지
const activeLeafCallback = () => {
if (this.destroyed) {
return;
}
this.updateActiveEditor();
};
workspace.on('active-leaf-change', activeLeafCallback);
this.eventRefs.push({ event: 'active-leaf-change', callback: activeLeafCallback });
// 에디터 변경 감지
const editorChangeCallback = (...args: unknown[]) => {
const [editor] = args;
if (!this.isEditor(editor)) {
return;
}
if (this.destroyed) {
return;
}
this.activeEditor = editor;
this.eventManager.emit('editor:changed', { editor });
};
workspace.on('editor-change', editorChangeCallback);
this.eventRefs.push({ event: 'editor-change', callback: editorChangeCallback });
}
/**
* 활성 에디터 업데이트
*/
private updateActiveEditor(): void {
if (this.destroyed) {
return;
}
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (view) {
this.activeView = view;
this.activeEditor = view.editor;
this.logger.debug('Active editor updated', {
file: view.file?.path,
});
this.eventManager.emit('editor:active', {
view,
editor: this.activeEditor,
});
} else {
this.activeView = null;
this.activeEditor = null;
this.logger.debug('No active markdown view');
}
}
/**
* 활성 에디터 가져오기
*/
getActiveEditor(): Editor | null {
if (this.destroyed) {
return null;
}
if (!this.activeEditor) {
this.updateActiveEditor();
}
return this.activeEditor;
}
/**
* 활성 뷰 가져오기
*/
getActiveView(): MarkdownView | null {
if (this.destroyed) {
return null;
}
if (!this.activeView) {
this.updateActiveEditor();
}
return this.activeView;
}
/**
* 에디터가 활성화되어 있는지 확인
*/
hasActiveEditor(): boolean {
return this.getActiveEditor() !== null;
}
/**
* 현재 커서 위치 가져오기
*/
getCursorPosition(): EditorPosition | null {
const editor = this.getActiveEditor();
if (!editor) return null;
return editor.getCursor();
}
/**
* 선택 영역 가져오기
*/
getSelection(): string {
const editor = this.getActiveEditor();
if (!editor) return '';
return editor.getSelection();
}
/**
* 선택 영역 범위 가져오기
*/
getSelectionRange(): EditorRange | null {
const editor = this.getActiveEditor();
if (!editor) return null;
const from = editor.getCursor('from');
const to = editor.getCursor('to');
return { from, to };
}
/**
* 커서 위치에 텍스트 삽입
*/
async insertAtCursor(text: string, recordHistory = true): Promise<boolean> {
const editor = this.getActiveEditor();
if (!editor) {
this.logger.warn('No active editor for text insertion');
return false;
}
// Maintain async contract for upstream callers
await Promise.resolve();
try {
const cursor = editor.getCursor();
if (recordHistory) {
this.recordAction({
type: 'insert',
position: cursor,
text,
timestamp: Date.now(),
});
}
editor.replaceRange(text, cursor);
// 커서를 삽입된 텍스트 끝으로 이동
const newCursor = {
line: cursor.line,
ch: cursor.ch + text.length,
};
editor.setCursor(newCursor);
this.logger.debug('Text inserted at cursor', {
position: cursor,
textLength: text.length,
});
this.eventManager.emit('editor:text-inserted', {
text,
position: cursor,
});
return true;
} catch (error) {
this.logger.error('Failed to insert text at cursor', this.normalizeError(error));
return false;
}
}
/**
* 선택 영역 대체
*/
async replaceSelection(text: string, recordHistory = true): Promise<boolean> {
const editor = this.getActiveEditor();
if (!editor) {
this.logger.warn('No active editor for text replacement');
return false;
}
await Promise.resolve();
try {
const selection = editor.getSelection();
const range = this.getSelectionRange();
if (recordHistory && range) {
this.recordAction({
type: 'replace',
range,
oldText: selection,
newText: text,
timestamp: Date.now(),
});
}
editor.replaceSelection(text);
this.logger.debug('Selection replaced', {
oldLength: selection.length,
newLength: text.length,
});
this.eventManager.emit('editor:text-replaced', {
oldText: selection,
newText: text,
});
return true;
} catch (error) {
this.logger.error('Failed to replace selection', this.normalizeError(error));
return false;
}
}
/**
* 간단한 텍스트 삽입 헬퍼
*/
async insertText(
text: string,
options?: { position?: 'cursor' | 'beginning' | 'end' }
): Promise<boolean> {
const position = options?.position ?? 'cursor';
if (position === 'beginning') {
return this.prependToDocument(text);
}
if (position === 'end') {
return this.appendToDocument(text);
}
return this.replaceSelection(text);
}
/**
* 특정 위치에 텍스트 삽입
*/
async insertAtPosition(
text: string,
position: EditorPosition,
recordHistory = true
): Promise<boolean> {
const editor = this.getActiveEditor();
if (!editor) {
this.logger.warn('No active editor for text insertion');
return false;
}
await Promise.resolve();
try {
if (recordHistory) {
this.recordAction({
type: 'insert',
position,
text,
timestamp: Date.now(),
});
}
editor.replaceRange(text, position);
this.logger.debug('Text inserted at position', {
position,
textLength: text.length,
});
return true;
} catch (error) {
this.logger.error('Failed to insert text at position', this.normalizeError(error));
return false;
}
}
/**
* 라인 끝에 텍스트 추가
*/
async appendToLine(lineNumber: number, text: string): Promise<boolean> {
const editor = this.getActiveEditor();
if (!editor) return false;
try {
const line = editor.getLine(lineNumber);
if (line === undefined) {
this.logger.warn('Invalid line number', { lineNumber });
return false;
}
const position: EditorPosition = {
line: lineNumber,
ch: line.length,
};
return await this.insertAtPosition(text, position);
} catch (error) {
this.logger.error('Failed to append to line', this.normalizeError(error));
return false;
}
}
/**
* 문서 끝에 텍스트 추가
*/
async appendToDocument(text: string, addNewLine = true): Promise<boolean> {
const editor = this.getActiveEditor();
if (!editor) return false;
try {
const lastLine = editor.lastLine();
const lastLineText = editor.getLine(lastLine);
const position: EditorPosition = {
line: lastLine,
ch: lastLineText.length,
};
const textToInsert = addNewLine ? `\n${text}` : text;
return await this.insertAtPosition(textToInsert, position);
} catch (error) {
this.logger.error('Failed to append to document', this.normalizeError(error));
return false;
}
}
/**
* 문서 시작에 텍스트 추가
*/
async prependToDocument(text: string, addNewLine = true): Promise<boolean> {
const editor = this.getActiveEditor();
if (!editor) return false;
try {
const position: EditorPosition = { line: 0, ch: 0 };
const textToInsert = addNewLine ? `${text}\n` : text;
return await this.insertAtPosition(textToInsert, position);
} catch (error) {
this.logger.error('Failed to prepend to document', this.normalizeError(error));
return false;
}
}
/**
* 현재 라인 가져오기
*/
getCurrentLine(): string | null {
const editor = this.getActiveEditor();
if (!editor) return null;
const cursor = editor.getCursor();
return editor.getLine(cursor.line);
}
/**
* 현재 라인 번호 가져오기
*/
getCurrentLineNumber(): number | null {
const cursor = this.getCursorPosition();
return cursor ? cursor.line : null;
}
/**
* 전체 문서 내용 가져오기
*/
getDocumentContent(): string | null {
const editor = this.getActiveEditor();
if (!editor) return null;
return editor.getValue();
}
/**
* 전체 문서 내용 설정
*/
async setDocumentContent(content: string): Promise<boolean> {
const editor = this.getActiveEditor();
if (!editor) return false;
await Promise.resolve();
try {
const oldContent = editor.getValue();
this.recordAction({
type: 'set-content',
oldText: oldContent,
newText: content,
timestamp: Date.now(),
});
editor.setValue(content);
this.logger.debug('Document content updated');
return true;
} catch (error) {
this.logger.error('Failed to set document content', this.normalizeError(error));
return false;
}
}
/**
* 액션 기록 (Undo/Redo용)
*/
private recordAction(action: EditorAction): void {
this.undoHistory.push(action);
// 히스토리 크기 제한
if (this.undoHistory.length > this.maxHistorySize) {
this.undoHistory.shift();
}
// Redo 히스토리 초기화
this.redoHistory.length = 0;
}
/**
* Undo 실행
*/
async undo(): Promise<boolean> {
if (this.undoHistory.length === 0) {
this.logger.debug('No actions to undo');
return false;
}
await Promise.resolve();
const editor = this.getActiveEditor();
if (!editor) return false;
const action = this.undoHistory.pop();
if (!action) {
return false;
}
this.redoHistory.push(action);
try {
switch (action.type) {
case 'insert':
// 삽입된 텍스트 제거
if (action.position && action.text) {
const from = action.position;
const to = {
line: from.line,
ch: from.ch + action.text.length,
};
editor.replaceRange('', from, to);
}
break;
case 'replace':
// 이전 텍스트로 복원
if (action.range && action.oldText !== undefined) {
editor.replaceRange(action.oldText, action.range.from, action.range.to);
}
break;
case 'set-content':
// 이전 내용으로 복원
if (action.oldText !== undefined) {
editor.setValue(action.oldText);
}
break;
}
this.logger.debug('Undo executed', { actionType: action.type });
return true;
} catch (error) {
this.logger.error('Failed to execute undo', this.normalizeError(error));
return false;
}
}
/**
* Redo 실행
*/
async redo(): Promise<boolean> {
if (this.redoHistory.length === 0) {
this.logger.debug('No actions to redo');
return false;
}
await Promise.resolve();
const editor = this.getActiveEditor();
if (!editor) return false;
const action = this.redoHistory.pop();
if (!action) {
return false;
}
this.undoHistory.push(action);
try {
switch (action.type) {
case 'insert':
// 텍스트 다시 삽입
if (action.position && action.text) {
editor.replaceRange(action.text, action.position);
}
break;
case 'replace':
// 새 텍스트로 다시 대체
if (action.range && action.newText !== undefined) {
editor.replaceRange(action.newText, action.range.from, action.range.to);
}
break;
case 'set-content':
// 새 내용으로 다시 설정
if (action.newText !== undefined) {
editor.setValue(action.newText);
}
break;
}
this.logger.debug('Redo executed', { actionType: action.type });
return true;
} catch (error) {
this.logger.error('Failed to execute redo', this.normalizeError(error));
return false;
}
}
/**
* 히스토리 초기화
*/
clearHistory(): void {
this.undoHistory.length = 0;
this.redoHistory.length = 0;
this.logger.debug('Editor history cleared');
}
/**
* 새 노트 생성 및 열기
*/
async createAndOpenNote(fileName: string, content: string, folder = ''): Promise<boolean> {
try {
const path = folder ? `${folder}/${fileName}` : fileName;
const file = await this.app.vault.create(path, content);
await this.app.workspace.openLinkText(file.path, '', true);
this.logger.debug('New note created and opened', { path });
this.eventManager.emit('editor:note-created', {
file,
content,
});
return true;
} catch (error) {
this.logger.error('Failed to create and open note', this.normalizeError(error));
new Notice('Failed to create new note');
return false;
}
}
/**
* 커서 위치 설정
*/
setCursorPosition(position: EditorPosition): boolean {
const editor = this.getActiveEditor();
if (!editor) return false;
try {
editor.setCursor(position);
return true;
} catch (error) {
this.logger.error('Failed to set cursor position', this.normalizeError(error));
return false;
}
}
/**
* 텍스트 선택
*/
selectRange(from: EditorPosition, to: EditorPosition): boolean {
const editor = this.getActiveEditor();
if (!editor) return false;
try {
editor.setSelection(from, to);
return true;
} catch (error) {
this.logger.error('Failed to select range', this.normalizeError(error));
return false;
}
}
/**
* 에디터 포커스
*/
focus(): boolean {
const editor = this.getActiveEditor();
if (!editor) return false;
try {
editor.focus();
return true;
} catch (error) {
this.logger.error('Failed to focus editor', this.normalizeError(error));
return false;
}
}
/**
* 클린업
*/
destroy(): void {
this.destroyed = true;
// Remove all event listeners
for (const ref of this.eventRefs) {
if (typeof this.app.workspace.off === 'function') {
this.app.workspace.off(ref.event, ref.callback);
}
}
this.eventRefs.length = 0;
this.clearHistory();
this.activeEditor = null;
this.activeView = null;
this.logger.debug('EditorService destroyed');
}
}
/**
* 에디터 액션 타입 (Undo/Redo용)
*/
interface EditorAction {
type: 'insert' | 'replace' | 'set-content';
position?: EditorPosition;
range?: EditorRange;
text?: string;
oldText?: string;
newText?: string;
timestamp: number;
}