본 문서는 @barocss/editor-view-dom 패키지의 기술 스펙이다. 구현과 테스트는 본 문서를 기준으로 한다.
- 아키텍처 개요
- 레이어 시스템
- renderer-dom 통합
- 이벤트 핸들러 시스템
- Decorator 시스템
- skipNodes 기능
- Keymap 시스템
- Native Commands
- Selection 관리
- 생명주기
- 오류 처리
- 성능 요구사항
EditorViewDOM은 editor-core와 브라우저 DOM 사이의 브리지 역할을 한다.
주요 책임:
editor-core의 모델 데이터를 DOM으로 렌더링- 사용자 입력(DOM 이벤트)을 모델 변경으로 변환
- Selection 관리 (DOM ↔ Model)
- Decorator 시스템 관리
- 레이어 시스템 관리 (5개 레이어)
┌─────────────────────────────────────────────────────────────┐
│ EditorViewDOM │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Editor (editor-core) │ │
│ │ - getDocumentProxy() → Proxy<INode> │ │
│ │ - exportDocument() → INode │ │
│ │ - dataStore.getAllDecorators() → Decorator[] │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Event Handlers │ │
│ │ - InputHandler (input, beforeinput, composition) │ │
│ │ - SelectionHandler (DOM ↔ Model) │ │
│ │ - MutationObserverManager (DOM 변경 감지) │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ renderer-dom Integration │ │
│ │ - DOMRenderer (Content 레이어) │ │
│ │ - DOMRenderer (Decorator/Selection/Context/Custom)│ │
│ │ - RendererRegistry │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Layer System (5 layers) │ │
│ │ - content (contentEditable) │ │
│ │ - decorator, selection, context, custom │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Editor.getDocumentProxy() 또는 외부 ModelData
│
├─ ModelData 형식 (sid, stype 사용)
│
▼
EditorViewDOM.render()
│
├─ Decorator 데이터 수집 (dataStore.getAllDecorators())
│
▼
DOMRenderer.render(container, modelData, decorators, skipNodes)
│
├─ VNodeBuilder: ModelData → VNode Tree
├─ Reconciler: VNode Tree → DOM diff (skipNodes 적용)
└─ DOMOperations: DOM 업데이트
│
▼
layers.content (contentEditable)
사용자 입력 (DOM 이벤트)
│
├─ InputHandler.handleInput()
│ └─ beforeInput 이벤트 처리
│
├─ MutationObserverManager
│ └─ DOM 변경 감지
│
▼
InputHandler.handleTextContentChange()
│
├─ SmartTextAnalyzer: DOM 변경 → TextChange
│
▼
Editor.executeTransaction()
│
├─ 모델 업데이트
│
▼
editor:content.change 이벤트
│
▼
EditorViewDOM.render() (skipNodes 적용)
- 레이어 분리: 5개의 독립적인 레이어로 UI 요소를 분리
- renderer-dom 통합: 모든 렌더링은
renderer-dom을 통해 수행 - 이벤트 기반: DOM 이벤트를 모델 변경으로 변환
- skipNodes 보호: 사용자 입력 중인 노드는 외부 변경으로부터 보호
- 모델 우선: 모델이 항상 단일 소스 오브 트루스 (Single Source of Truth)
EditorViewDOM은 5개의 독립적인 레이어를 사용한다:
Container (position: relative)
├─ Layer 1: Content (z-index: 1)
│ └─ contentEditable = true
│ └─ renderer-dom이 여기에 렌더링
│
├─ Layer 2: Decorator (z-index: 10)
│ └─ layer 카테고리 decorator들
│
├─ Layer 3: Selection (z-index: 100)
│ └─ 선택 영역 표시
│
├─ Layer 4: Context (z-index: 200)
│ └─ 툴팁, 컨텍스트 메뉴
│
└─ Layer 5: Custom (z-index: 1000)
└─ 커스텀 오버레이
| Layer | Z-Index | Position | Pointer Events | Purpose | Diff Included |
|---|---|---|---|---|---|
| Content | 1 | relative |
✅ Enabled | Editable content, text input | ✅ Yes |
| Decorator | 10 | absolute |
❌ Disabled* | Highlights, annotations, widgets | Layer: ✅ / Widget: ❌ |
| Selection | 100 | absolute |
❌ Disabled | Selection indicators, cursor | ❌ No |
| Context | 200 | absolute |
❌ Disabled | Context menus, tooltips | ❌ No |
| Custom | 1000 | absolute |
❌ Disabled | User-defined overlays | ❌ No |
*일부 decorator 요소(inline/block widgets)는 pointer events를 활성화할 수 있음
레이어는 EditorViewDOM 생성자에서 자동으로 생성된다:
const view = new EditorViewDOM(editor, {
container: document.getElementById('editor-container'),
layers: {
contentEditable: {
className: 'my-editor-content',
attributes: { 'data-testid': 'editor' }
},
decorator: {
className: 'my-decorators'
},
// ... 기타 레이어 설정
}
});view.layers.content // HTMLElement - contentEditable layer
view.layers.decorator // HTMLElement - decorator overlay layer
view.layers.selection // HTMLElement - selection UI layer
view.layers.context // HTMLElement - context UI layer
view.layers.custom // HTMLElement - custom overlay layerEditorViewDOM은 여러 개의 DOMRenderer 인스턴스를 사용한다:
_domRenderer: Content 레이어용 (Selection 보존 활성화)_decoratorRenderer: Decorator 레이어용_selectionRenderer: Selection 레이어용_contextRenderer: Context 레이어용_customRenderer: Custom 레이어용
각 DOMRenderer는 독립적인 prevVNodeTree를 유지한다.
// EditorViewDOM.render()
render(tree?: ModelData, options?: { sync?: boolean }): void {
// 1. 모델 데이터 가져오기
const modelData = tree || this.editor.getDocumentProxy();
// 2. Decorator 데이터 수집
const allDecorators = this.editor.dataStore.getAllDecorators();
const decoratorData = allDecorators.map(d => convertToDecoratorData(d));
// 3. Selection 컨텍스트 준비
const selectionContext = this.selectionHandler.getSelectionContext();
// 4. Content 레이어 렌더링 (동기)
this._domRenderer?.render(
this.layers.content,
modelData,
decoratorData,
undefined,
selectionContext,
{ skipNodes: this._editingNodes.size > 0 ? this._editingNodes : undefined }
);
// 5. 다른 레이어들 렌더링 (requestAnimationFrame 이후)
// ...
}모든 데이터는 ModelData 형식 (sid, stype 사용):
{
sid: 'doc-1', // 노드 식별자 (필수)
stype: 'document', // 노드 타입 (필수)
content: [...], // 자식 노드 배열
text: '...', // 텍스트 내용 (선택적)
marks: [...], // 텍스트 마크
attributes: {...} // 노드 속성
}변환 없이 직접 사용: 모든 데이터는 이미 ModelData 형식이므로 변환 없이 renderer-dom에 전달한다.
function convertToDecoratorData(decorator: any): DecoratorData {
return {
sid: decorator.sid || decorator.id,
stype: decorator.stype || decorator.type,
category: decorator.category || 'inline',
position: decorator.position, // 'before' | 'after' | 'inside'
target: {
sid: decorator.target.sid || decorator.target.nodeId,
startOffset: decorator.target.startOffset,
endOffset: decorator.target.endOffset
},
data: decorator.data || {}
};
}역할: 사용자 입력 처리 (텍스트 입력, IME 조합)
주요 메서드:
handleInput(event: InputEvent): input 이벤트 처리handleBeforeInput(event: InputEvent): beforeInput 이벤트 처리handleTextContentChange(oldValue, newValue, target): MutationObserver에서 호출, 모델 업데이트handleCompositionStart/Update/End(): IME 조합 처리
동작 흐름:
DOM 변경 (MutationObserver)
│
▼
InputHandler.handleTextContentChange()
│
├─ SmartTextAnalyzer: DOM 변경 → TextChange
│
▼
Editor.executeTransaction()
│
├─ 모델 업데이트
│
▼
editor:content.change 이벤트
│
▼
EditorViewDOM.render() (skipNodes 적용)
역할: DOM Selection ↔ Model Selection 변환
주요 메서드:
convertDOMSelectionToModel(sel: Selection): DOM → Model 변환convertModelSelectionToDOM(sel: ModelSelection): Model → DOM 변환
동작:
selectionchange이벤트 발생 시 DOM Selection을 Model Selection으로 변환editor:selection.model이벤트 발생 시 Model Selection을 DOM Selection으로 변환
역할: DOM 변경 감지
주요 기능:
- 텍스트 변경 감지 (
onTextChange) - 구조 변경 감지 (
onStructureChange) - 속성 변경 감지 (
onAttributeChange)
보호 메커니즘:
_isRendering플래그로 렌더링 중 발생하는 DOM 변경은 무시 (무한루프 방지)
private setupEventListeners(): void {
// 입력 이벤트
this.contentEditableElement.addEventListener('input', this.handleInput.bind(this));
this.contentEditableElement.addEventListener('beforeinput', this.handleBeforeInput.bind(this));
this.contentEditableElement.addEventListener('keydown', this.handleKeydown.bind(this));
this.contentEditableElement.addEventListener('paste', this.handlePaste.bind(this));
this.contentEditableElement.addEventListener('drop', this.handleDrop.bind(this));
// 조합 이벤트 (IME)
this.contentEditableElement.addEventListener('compositionstart', this.handleCompositionStart.bind(this));
this.contentEditableElement.addEventListener('compositionupdate', this.handleCompositionUpdate.bind(this));
this.contentEditableElement.addEventListener('compositionend', this.handleCompositionEnd.bind(this));
// 선택 이벤트
document.addEventListener('selectionchange', this.handleSelectionChange.bind(this));
// 포커스 이벤트
this.contentEditableElement.addEventListener('focus', this.handleFocus.bind(this));
this.contentEditableElement.addEventListener('blur', this.handleBlur.bind(this));
}- Layer Decorator: CSS/overlay-only representation (diff에 포함)
- Inline Decorator: 텍스트 내부에 삽입되는 DOM 위젯 (diff에서 제외)
- Block Decorator: 블록 레벨에서 삽입되는 DOM 위젯 (diff에서 제외)
DecoratorRegistry: Decorator 타입과 renderer 등록DecoratorManager: Decorator CRUD 작업RemoteDecoratorManager: 원격 Decorator 관리PatternDecoratorConfigManager: 패턴 기반 Decorator 설정 관리DecoratorGeneratorManager: 함수 기반 Decorator 생성 관리
// Decorator 추가
view.decoratorManager.add({
id: 'highlight-1',
category: 'layer',
type: 'highlight',
target: { nodeId: 'text-1', startOffset: 0, endOffset: 5 },
data: { backgroundColor: 'yellow' }
});
// Decorator 렌더링은 render() 호출 시 자동으로 수행됨
view.render();사용자 입력 중인 노드를 외부 변경(AI, 동시편집)으로부터 보호한다.
// 입력 시작 시
private _onInputStart(): void {
const sids = this._getEditingNodeSids();
sids.forEach(sid => this._editingNodes.add(sid));
}
// 입력 종료 시 (디바운싱)
private _onInputEnd(): void {
if (this._inputEndDebounceTimer) {
clearTimeout(this._inputEndDebounceTimer);
}
this._inputEndDebounceTimer = window.setTimeout(() => {
this._editingNodes.clear();
// skipNodes 없이 재렌더링하여 최신 모델 반영
this.render();
}, 300); // 300ms 디바운싱
}this._domRenderer?.render(
this.layers.content,
modelData,
allDecorators,
undefined,
selectionContext,
{ skipNodes: this._editingNodes.size > 0 ? this._editingNodes : undefined }
);1. 사용자 입력 시작
→ _onInputStart() → editingNodes에 추가
2. 외부 변경 발생 (AI, 동시편집)
→ 모델 업데이트 (항상 수행)
→ render({ skipNodes: editingNodes })
→ DOM 업데이트 스킵 (입력 중인 노드 보호)
3. 사용자 입력 종료
→ _onInputEnd() → editingNodes 제거 (300ms 디바운싱)
→ render() (skipNodes 없음)
→ DOM 업데이트 (최신 모델 반영)
핵심: skipNodes는 렌더링 단계의 개념이며, 핸들러들은 모델 업데이트를 담당하므로 skipNodes와 무관하다.
- InputHandler: 모델 업데이트는 항상 수행 (skipNodes와 무관)
- SelectionHandler: Selection 변환만 담당 (skipNodes와 무관)
- MutationObserverManager: DOM 변경 감지만 담당 (이미
_isRendering으로 보호)
// 포맷팅
Ctrl+B / Cmd+B → toggleBold()
Ctrl+I / Cmd+I → toggleItalic()
Ctrl+U / Cmd+U → toggleUnderline()
// 편집
Enter → insertParagraph()
Shift+Enter → insertLineBreak()
// 히스토리
Ctrl+Z / Cmd+Z → historyUndo()
Ctrl+Y / Cmd+Y → historyRedo()
Ctrl+Shift+Z / Cmd+Shift+Z → historyRedo()
// 선택
Ctrl+A / Cmd+A → selectAll()view.keymapManager.register('Ctrl+Shift+h', () => {
editor.executeCommand('heading.insert', { level: 2 });
});
view.keymapManager.register('Ctrl+/', () => {
editor.executeCommand('comment.toggle');
});// 텍스트 삽입/삭제
view.insertText('Hello world');
view.insertParagraph();
view.deleteSelection();
// 히스토리
view.historyUndo();
view.historyRedo();
// 포맷팅
view.toggleBold();
view.toggleItalic();
view.toggleUnderline();모든 Native Command는 editor-core의 명령 시스템을 통해 모델을 업데이트하고, 이후 render()가 자동으로 호출된다.
// DOM Selection → Model Selection
const modelSelection = view.selectionHandler.convertDOMSelectionToModel(
window.getSelection()
);
// Model Selection → DOM Selection
view.selectionHandler.convertModelSelectionToDOM({
nodeId: 'text-1',
startOffset: 0,
endOffset: 5
});// DOM Selection 변경 시
view.on('editor:selection.change', (data) => {
console.log('Model selection:', data.selection);
});
// Model Selection 변경 시
editor.on('editor:selection.model', (sel) => {
// DOM Selection으로 변환하여 적용
});const view = new EditorViewDOM(editor, {
container: document.getElementById('editor-container'),
registry: getGlobalRegistry(),
autoRender: true,
initialTree: { ... } // 선택적
});초기화 순서:
- 레이어 구조 생성
- Decorator 시스템 초기화
- 이벤트 핸들러 초기화
- Keymap 설정
- 이벤트 리스너 설정
- MutationObserver 설정
- 렌더러 설정
autoRender가 true이고initialTree가 있으면 자동 렌더링
// 전체 문서 렌더링
view.render();
// 특정 모델 데이터로 렌더링
view.render({
sid: 'doc1',
stype: 'document',
content: [...]
});view.destroy();정리 작업:
- 이벤트 리스너 제거
- MutationObserver 해제
- Decorator 정리
- 키맵 정리
- 렌더러 정리
try {
this._domRenderer?.render(...);
} catch (error) {
console.error('[EditorViewDOM] Error rendering content:', error);
// Content 렌더링 실패해도 decorator는 렌더링 시도
}stype필드가 없으면 에러 발생 (필수 필드)sid필드가 없으면 에러 발생 (필수 필드)- 템플릿이 등록되지 않은
stype은 에러 발생
Decorator 변환 실패는 경고만 출력하고 계속 진행한다.
- 대용량 문서(5000+ 노드)에서도 렌더링 시간 < 100ms
skipNodes를 통한 부분 업데이트로 입력 중 성능 유지
- 입력 이벤트 처리 < 1ms
- Selection 변경 처리 < 16ms (60fps)
- Proxy 기반 lazy evaluation으로 초기 로딩 시간 및 메모리 사용량 최적화
- 레이어별 독립적인
prevVNodeTree로 메모리 사용량 증가 (필요한 트레이드오프)