본 논문은 ContentEditable 기반 에디터에서 View 모드와 Edit 모드를 분리하여 관리하는 이중 모드 렌더링 전략을 제안합니다. 모드 결정은 Model Range를 기반으로 수행되며, 이는 렌더링된 DOM 상태와 무관하게 현재 inline-text가 View 모드인지 Edit 모드인지를 결정할 수 있게 합니다. 또한 렌더링 이후 DOM에서 자유롭게 커서를 옮겨도 Model Range가 동일하면 모델은 동일하게 유지됩니다. Edit 모드에서는 커서가 위치한 inline-text를 문자 단위로 분리하여 렌더링함으로써, mark와 decorator가 실시간으로 추가되어도 텍스트 입력이 깨지지 않도록 보장합니다. View 모드에서는 기존의 run 단위 렌더링을 유지하여 성능을 최적화합니다. MutationObserver 기반 부분 업데이트와 렌더링 제어 전략을 통해 브라우저/모바일 환경 차이에 독립적으로 동작하며, IME 입력과 완벽하게 호환됩니다. 또한 MutationObserver가 사용자 입력(selection 대상)에만 반응하고 외부 모델 업데이트로 인한 DOM 변경은 무시하여 무한 루프를 방지합니다.
핵심 비전: 본 논문에서 제안하는 전략은 완전한 AI 기반 에디터의 기반을 제공합니다. 사용자가 텍스트를 입력하는 동안 AI가 실시간으로 mark와 decorator를 적용하거나, 자동 완성, 문법 교정, 스타일 제안 등의 기능을 수행해도 어떤 편집 작업 중에서도 렌더링이 깨지지 않고, 글자 입력도 중단되지 않는 완전한 에디터 경험을 구현할 수 있습니다.
Keywords: ContentEditable, Text Editor, Rendering Strategy, Selection Preservation, IME Compatibility, AI-Powered Editor
현대 웹 에디터는 사용자가 텍스트를 입력하는 동안 mark(bold, italic 등)와 decorator(주석, 하이라이트 등)를 실시간으로 적용할 수 있어야 합니다. 특히 AI 기반 에디터의 등장으로 다음과 같은 실시간 기능들이 요구됩니다:
- 실시간 자동 완성: 사용자가 입력하는 동안 AI가 자동 완성 제안을 실시간으로 표시
- 실시간 문법 교정: 오타나 문법 오류를 실시간으로 감지하고 수정 제안
- 실시간 스타일 제안: 텍스트 스타일을 실시간으로 분석하고 개선 제안
- 실시간 협업: 여러 사용자가 동시에 편집해도 각자의 입력이 깨지지 않음
- 실시간 AI 어시스턴트: AI가 텍스트를 분석하고 주석, 하이라이트, 제안 등을 실시간으로 추가
이러한 실시간 기능들은 사용자가 텍스트를 입력하는 동안 어떤 편집 작업 중에서도 렌더링이 깨지지 않고, 글자 입력도 중단되지 않는 완전한 에디터 경험을 제공해야 합니다.
그러나 ContentEditable 기반 에디터에서 이러한 기능을 구현할 때 다음과 같은 문제가 발생합니다:
- Selection 깨짐 문제: 텍스트 입력 중 DOM 구조가 변경되면 브라우저의 Selection이 깨질 수 있음
- IME 호환성 문제: 한글, 일본어 등 IME 입력 시 조합 중 DOM 변경이 입력을 방해할 수 있음
- 성능 문제: 전체 문서를 문자 단위로 분리하면 DOM 노드 수가 급증하여 성능 저하 발생
- 실시간 반응 문제: 입력 중에도 실시간으로 mark/decorator를 적용해야 하지만, 이로 인해 입력이 깨지는 문제 발생
이러한 문제들을 해결하지 못하면 완전한 AI 기반 에디터를 구현할 수 없습니다.
본 논문은 이중 모드 렌더링 전략을 제안합니다:
- View 모드: 커서가 없는 영역은 기존 run 단위 렌더링 유지 (성능 최적화)
- Edit 모드: 커서가 위치한 inline-text만 문자 단위로 분리하여 렌더링 (Selection 안정성)
본 논문의 dual-mode 전략은 Per-character rendering이 Selection 안정성을 확보하는 유일한 방법이라는 전제 하에 제안되었습니다. 그러나 만약 Per-character rendering 없이도 Selection 안정성을 확보할 수 있다면, dual-mode의 의미가 사라질 수 있습니다:
-
Per-character rendering 없이도 Selection 안정성 확보 가능:
- Text Node Pool 기반 재사용
- Selection Text Node 보존
- insertBefore를 사용한 Text Node 이동
- 명시적 Selection 복원
-
이 경우 dual-mode의 의미:
- Edit mode에서도 per-character rendering이 필요 없음
- View mode와 Edit mode의 차이가 없어짐
- Dual-mode가 불필요해질 수 있음
대안 연구: Per-character rendering 없이도 Selection 안정성을 확보하는 방법에 대한 연구는 selection-preservation-without-per-character.md를 참조하세요.
이 전략은 다음과 같은 장점을 제공합니다 (Per-character rendering이 필요한 경우):
- ✅ Selection 안정성: Edit 모드에서 문자 단위 분리로 mark/decorator 추가 시에도 Selection 보존
- ✅ 성능 최적화: View 모드 유지로 DOM 노드 수 증가 최소화
- ✅ IME 완벽 호환: MutationObserver 기반 부분 업데이트로 브라우저 차이 독립적
- ✅ 실시간 반응: 입력 중에도 mark/decorator 실시간 적용 가능
핵심 가치: 4번 항목인 **"실시간 반응"**은 본 논문의 핵심 목표입니다. 이것이 해결되면 ContentEditable 기반 에디터는 완전한 AI 기반 에디터가 될 수 있습니다:
- ✅ 어떤 편집 작업 중에서도 렌더링이 깨지지 않음: 사용자가 텍스트를 입력하는 동안 AI가 실시간으로 mark, decorator, 자동 완성, 문법 교정 등을 적용해도 입력이 중단되지 않음
- ✅ 글자 입력도 안 깨짐: IME 입력(한글, 일본어 등) 중에도 실시간 편집 기능이 동작해도 입력이 깨지지 않음
- ✅ 완전한 에디터 경험: 사용자는 편집 기능이 동작하는지 인지하지 못할 정도로 자연스러운 경험
이러한 완전한 에디터 경험을 구현하기 위해 본 논문은 이중 모드 렌더링 전략을 제안합니다.
본 논문은 다음과 같이 구성됩니다:
- 2장: 관련 연구 및 기존 접근법 분석
- 3장: 문제 정의 및 요구사항
- 4장: 제안하는 이중 모드 렌더링 전략
- 5장: 아키텍처 및 시스템 설계
- 6장: 구현 세부사항
- 7장: 평가 및 성능 분석
- 8장: 결론 및 향후 연구
- 장점: DOM 노드 수 최소화, 성능 우수
- 단점: mark/decorator 추가 시 Selection 깨짐 가능
- 장점: Selection 안정성 확보
- 단점: DOM 노드 수 급증, 성능 저하
- 일부 에디터는 특정 조건에서만 문자 단위 분리 시도
- 그러나 명확한 모드 분리 및 제어 전략 부재
- DOM Text Node를 재사용하여 Selection 보존
- 본 논문에서도 활용하지만 모드 기반으로 확장
- sid + gindex 기반 Text Node Pool: 각 문자 span의 Text Node를 Pool에 등록하여 재사용
- Selection Text Node 재사용:
selectionContext에서 제공하는 Text Node를 Reconciler에서 재사용
- VNode에 Selection 메타데이터 태깅 (
isSelectionChar) - Reconciler에서 Selection 보존 로직 적용
- VNodeBuilder에서
selectionContext를 받아 모드 결정
일반적인 에디터 설계 연구에서는 다음과 같은 접근법들이 다루어집니다:
- DOM Reconciliation 전략: Virtual DOM 기반 렌더링 (React, Slate.js 등)
- Selection 복원 기법: DOM 변경 후 Selection을 재설정하는 방식
- Text Node 재사용: 일부 에디터에서 성능 최적화를 위해 사용
본 논문에서 제안하는 이중 모드 렌더링 전략은 이러한 기존 접근법들과 차별화된 독창적인 설계입니다. 특히 VNodeBuilder에서 selection 상태에 따라 모드를 결정하고, Edit 모드에서만 per-character rendering을 수행하여 성능과 안정성을 동시에 확보하는 것이 핵심입니다.
- DOM 변경을 직접 감지하여 브라우저 차이 독립적
- Composition Event를 사용하지 않고 MutationObserver만 사용
- 조합 텍스트(composing text)는 DOM 상태로 감지하여 처리
- 브라우저/모바일 환경 차이에 독립적으로 동작
ContentEditable 기반 에디터에서 다음과 같은 시나리오를 고려합니다:
시나리오 1: 기본 실시간 편집 사용자가 텍스트를 입력하는 동안 실시간으로 mark(bold)를 적용
초기 상태: "Hello World"
사용자 입력: "Hello W|orld" (커서가 'W' 뒤)
Bold 적용: "Hello **W|orld**" (커서 위치에서 bold 시작)
문제점:
- Bold 적용 시 DOM 구조 변경 (
<span>추가) - DOM 변경으로 인한 Selection 깨짐
- 사용자 입력이 중단되거나 잘못된 위치에 입력됨
시나리오 2: AI 기반 에디터 (핵심 목표) 사용자가 텍스트를 입력하는 동안 AI가 실시간으로 다양한 편집 작업 수행
사용자 입력: "안녕하세요|" (한글 IME 입력 중)
AI 실시간 작업:
- 자동 완성 제안 표시
- 문법 교정 제안 추가
- 스타일 제안 하이라이트
- 주석(decorator) 추가
핵심 문제점:
- 어떤 편집 작업 중에서도 렌더링이 깨지지 않아야 함: AI가 실시간으로 mark, decorator, 자동 완성 등을 적용해도 사용자 입력이 중단되지 않아야 함
- 글자 입력도 안 깨져야 함: IME 입력(한글, 일본어 등) 중에도 실시간 편집 기능이 동작해도 입력이 깨지지 않아야 함
- 완전한 에디터 경험: 사용자는 편집 기능이 동작하는지 인지하지 못할 정도로 자연스러운 경험
이러한 문제들을 해결하지 못하면 완전한 AI 기반 에디터를 구현할 수 없습니다.
다음과 같은 요구사항을 만족해야 합니다:
-
R1: Selection 안정성
- 텍스트 입력 중 mark/decorator 추가 시에도 Selection 보존
- IME 입력 중에도 Selection 유지
-
R2: 성능 최적화
- DOM 노드 수 증가 최소화
- 렌더링 시간 최소화
-
R3: 브라우저 호환성
- 다양한 브라우저/모바일 환경에서 동일하게 동작
- IME 입력과 완벽하게 호환
-
R4: 실시간 반응
- 입력 중에도 mark/decorator 실시간 적용 가능
- 사용자 경험 저하 없음
-
R5: 완전한 AI 기반 에디터 (핵심 목표)
- 어떤 편집 작업 중에서도 렌더링이 깨지지 않음: AI가 실시간으로 mark, decorator, 자동 완성, 문법 교정 등을 적용해도 사용자 입력이 중단되지 않음
- 글자 입력도 안 깨짐: IME 입력(한글, 일본어 등) 중에도 실시간 편집 기능이 동작해도 입력이 깨지지 않음
- 완전한 에디터 경험: 사용자는 편집 기능이 동작하는지 인지하지 못할 정도로 자연스러운 경험
R5는 본 논문의 핵심 목표이며, 이것이 해결되면 완전한 AI 기반 에디터를 구현할 수 있습니다.
- mark/decorator 추가 시 Selection 깨짐
- 요구사항 R1 미충족
- DOM 노드 수 급증
- 성능 저하
- 요구사항 R2 미충족
- 브라우저/모바일 환경 차이로 인한 문제 발생
- iOS Safari:
compositionend이벤트가 늦게 발생하거나 미발생 - Android Chrome:
compositionend이벤트가 빠르게 발생 - 해결책: Composition Event를 사용하지 않고 MutationObserver만 사용하여 DOM 상태로 조합 텍스트 감지
근본적인 딜레마:
-
DOM Selection의 offset은 codepoint 기반:
- 브라우저 API가 제공하는 offset은 UTF-16 코드 유닛 기반
- 예: "Hello 👍👩💻 World"에서 offset 7은 7번째 codepoint를 의미
-
Per-character rendering은 grapheme cluster 기반:
- 사용자가 인식하는 문자 단위로 분리해야 함
- 예: "👍👩💻"는 하나의 grapheme cluster이지만 여러 codepoint로 구성
-
두 가지 선택지:
- Option A: Codepoint로 쪼개기 → ❌ 유니코드 깨짐 (이모지, 한글 조합 문자 등)
- Option B: Grapheme cluster로 쪼개기 → ✅ 유니코드 안전, 하지만 offset 변환 필요
근본적인 문제: Per-Character Rendering의 한계:
Per-character rendering을 사용하려면 grapheme cluster로 쪼개야 하지만, 이로 인해 다음과 같은 문제가 발생합니다:
-
Offset 변환의 복잡성:
- DOM Selection offset (codepoint 기반) ↔ 모델 offset (grapheme cluster 기반) 변환 필요
- 변환이 복잡하고 오류 가능성 존재
- 모든 Selection 읽기/쓰기 시 변환 필요
-
시스템 복잡도 증가:
- 두 가지 offset 시스템을 동시에 관리해야 함
- 변환 로직의 정확성 검증 필요
- 엣지 케이스 처리 복잡
가능한 해결책:
Option 1: Per-Character Rendering 사용 (현재 제안):
- ✅ 유니코드 안전 (grapheme cluster 기반)
- ✅ Selection 안정성 확보
- ❌ Offset 변환 복잡도
- ❌ 시스템 복잡도 증가
Option 2: Per-Character Rendering 포기:
- ✅ 시스템 단순화 (codepoint 기반으로 통일)
- ✅ 변환 불필요
- ❌ 유니코드 안전성 문제 (codepoint로 쪼개면 이모지 등 깨짐)
- ❌ Selection 안정성 저하 가능
Option 3: 하이브리드 접근 (제한적 Per-Character):
- 짧은 텍스트만 per-character rendering
- 긴 텍스트는 run 단위 유지
- 하지만 여전히 offset 변환 필요
결론: Per-Character Rendering의 실현 가능성:
본 논문에서는 **Option 1 (Per-Character Rendering 사용)**을 제안하지만, 이는 다음과 같은 전제 조건이 필요합니다:
-
모델 offset을 grapheme cluster 기반으로 통일:
- 모델 내부에서 offset을 관리할 때 grapheme cluster 기준 사용
- 사용자가 인식하는 문자 단위로 일관성 유지
-
DOM Selection ↔ Model 변환의 정확성:
- DOM → Model: codepoint offset을 grapheme cluster offset으로 변환
- Model → DOM: grapheme cluster offset을 codepoint offset으로 변환
- 변환 로직의 정확성 검증 필수
-
변환 시점 최적화:
- View → Edit 모드 전환 시 한 번만 변환
- Edit 모드 내에서는 변환 불필요 (이미 grapheme cluster로 분리됨)
문제점: MutationObserver는 DOM 변경이 있을 때 이벤트가 발생하지만, 변경의 소스를 구분할 수 없습니다:
-
사용자 입력으로 인한 DOM 변경: 반응해야 함
- 사용자가 키보드로 텍스트 입력
- 사용자가 마우스로 텍스트 선택 및 삭제
- Selection이 활성화된 상태에서의 DOM 변경
-
외부 모델 업데이트로 인한 DOM 변경: 반응하면 안 됨
- 외부에서 모델을 업데이트하고 렌더링이 변경됨
- VNodeBuilder와 Reconciler를 통해 이미 DOM이 업데이트됨
- MutationObserver가 이것을 다시 감지하면 무한 루프나 불필요한 처리 발생 가능
해결 필요사항:
- MutationObserver가 사용자 입력(selection 대상)에만 반응하도록 구분
- 외부 모델 업데이트로 인한 DOM 변경은 무시
- 변경 소스를 명확히 구분하는 메커니즘 필요
본 논문은 이중 모드 렌더링 전략을 제안합니다:
핵심 원칙: 모드 결정은 Model Range를 기반으로 수행됩니다. DOM 상태와 무관하게 모델의 range 정보만으로 모드를 결정할 수 있어야 하며, 렌더링 이후 DOM에서 자유롭게 커서를 옮겨도 모델은 동일하게 유지됩니다.
flowchart TD
A["사용자 상호작용"] --> B["DOM Selection → Model Range 변환"]
B --> C{"Model Range 기반<br/>모드 결정"}
C -->|Model Range 없음| D["View 모드<br/>Run 단위 렌더링"]
C -->|Model Range 있음| E["Edit 모드<br/>문자 단위 분리"]
D --> F["성능 최적화<br/>DOM 노드 수 최소화"]
E --> G["Selection 안정성<br/>mark/decorator 실시간 적용"]
H["커서 이동"] --> B
I["마우스 클릭"] --> B
J["키보드 입력"] --> B
Note1["DOM 상태와 무관하게<br/>Model Range만으로 모드 결정"] -.-> C
Note2["렌더링 후 DOM 커서 이동해도<br/>Model Range 동일하면 모드 유지"] -.-> C
정의: Model Range에 커서가 없는 inline-text의 렌더링 모드
특징:
- Run 단위 렌더링 유지
- DOM 노드 수 최소화
- 성능 최적화
- Model Range 기반 결정: DOM 상태와 무관하게 모델만으로 View 모드 판단 가능
렌더링 규칙:
inline-text → <span data-bc-sid="...">전체 텍스트</span>
정의: Model Range에 커서가 위치한 inline-text의 렌더링 모드
특징:
- 문자 단위 분리 렌더링
- Selection 안정성 확보
- mark/decorator 실시간 적용 가능
- Model Range 기반 결정: DOM 상태와 무관하게 모델만으로 Edit 모드 판단 가능
- 모델 동일성 보장: 렌더링 이후 DOM에서 커서를 자유롭게 옮겨도 Model Range가 동일하면 모델 유지
렌더링 규칙:
inline-text → <span data-bc-sid="...">
<span data-bc-gindex="0">글</span>
<span data-bc-gindex="1">자</span>
<span data-bc-gindex="2">단</span>
<span data-bc-gindex="3">위</span>
</span>
핵심 원칙: 모드 결정은 model range를 기반으로 수행됩니다. 이는 렌더링된 DOM 상태와 무관하게, 현재 inline-text가 View 모드인지 Edit 모드인지를 결정할 수 있게 합니다. 또한 렌더링 이후 DOM에서 자유롭게 커서를 옮겨도 모델은 동일하게 유지됩니다.
stateDiagram-v2
[*] --> ViewMode: 초기 상태
ViewMode --> EditMode: Model Range에 커서 위치
EditMode --> ViewMode: Model Range에서 커서 제거
EditMode --> EditMode: 같은 inline-text 내 Model Range 이동
note right of EditMode
모드 결정 기준:
- Model Range의 sid 확인
- DOM 상태와 무관하게 모델만으로 결정
- 렌더링 이후 DOM 커서 이동해도 모델 유지
end note
모드 전환 조건:
-
View → Edit 전환:
- Model Range에 커서가 위치한 inline-text의 sid 확인
- 해당 inline-text를 Edit 모드로 전환
- DOM 상태와 무관: DOM이 아직 View 모드로 렌더링되어 있어도, Model Range를 기반으로 Edit 모드로 결정
-
Edit → View 전환:
- Model Range가 다른 inline-text로 이동 (이전 inline-text는 View 모드로 전환)
- Model Range가 에디터 외부로 이동
- 모델 기반 결정: DOM에서 커서를 자유롭게 옮겨도, Model Range가 변경되지 않으면 모드 유지
-
Edit → Edit 전환 (같은 inline-text 내):
- Model Range가 같은 inline-text 내에서 이동
- 문자 span 재사용 (전체 재렌더링 불필요)
- 모델 동일성 보장: DOM에서 커서를 옮겨도 Model Range의 sid가 동일하면 모드 유지
flowchart LR
A["inline-text 모델"] --> B["Run 단위 VNode"]
B --> C["<span>전체 텍스트</span>"]
C --> D["DOM 렌더링"]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#e8f5e9
style D fill:#f3e5f5
알고리즘:
function renderViewMode(inlineTextModel):
text = inlineTextModel.text
return {
tag: 'span',
attrs: { 'data-bc-sid': inlineTextModel.sid },
children: [{ text: text }]
}
flowchart LR
A["inline-text 모델"] --> B["Grapheme 분해"]
B --> C["문자 단위 VNode 생성"]
C --> D["<span data-bc-gindex>글자</span>"]
D --> E["DOM 렌더링"]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#e8f5e9
style D fill:#f3e5f5
style E fill:#fce4ec
알고리즘:
function renderEditMode(inlineTextModel, cursorOffset):
text = inlineTextModel.text
graphemes = toGraphemes(text) // Unicode grapheme cluster 분해
children = []
for i = 0 to graphemes.length - 1:
isSelectionChar = (cursorOffset == i)
children.append({
tag: 'span',
attrs: { 'data-bc-gindex': i },
children: [{ text: graphemes[i] }],
meta: { isSelectionChar: isSelectionChar }
})
return {
tag: 'span',
attrs: { 'data-bc-sid': inlineTextModel.sid },
children: children
}
sequenceDiagram
participant User
participant EditorViewDOM
participant VNodeBuilder
participant Reconciler
participant DOM
User->>EditorViewDOM: 커서 이동 (View → Edit)
EditorViewDOM->>VNodeBuilder: Edit 모드로 빌드
VNodeBuilder->>VNodeBuilder: 문자 단위 분리
VNodeBuilder->>Reconciler: VNode 전달
Reconciler->>DOM: Run 단위 → 문자 단위 변환
User->>EditorViewDOM: 커서 이동 (Edit → View)
EditorViewDOM->>VNodeBuilder: View 모드로 빌드
VNodeBuilder->>VNodeBuilder: Run 단위 유지
VNodeBuilder->>Reconciler: VNode 전달
Reconciler->>DOM: 문자 단위 → Run 단위 복원
전환 알고리즘:
function transitionToEditMode(inlineTextElement):
// 1. 기존 Run 단위 텍스트 노드 추출
textNode = inlineTextElement.firstChild
// 2. 텍스트를 문자 단위로 분해
text = textNode.data
graphemes = toGraphemes(text)
// 3. 각 문자를 span으로 감싸서 삽입
for i = 0 to graphemes.length - 1:
span = createElement('span')
span.setAttribute('data-bc-gindex', i)
span.textContent = graphemes[i]
inlineTextElement.appendChild(span)
// 4. 기존 텍스트 노드 제거
inlineTextElement.removeChild(textNode)
function transitionToViewMode(inlineTextElement):
// 1. 모든 문자 span 수집
characterSpans = inlineTextElement.querySelectorAll('[data-bc-gindex]')
// 2. 텍스트 내용 병합
textContent = ""
for span in characterSpans:
textContent += span.textContent
// 3. Run 단위 텍스트 노드 생성
textNode = createTextNode(textContent)
// 4. 문자 span 제거 및 텍스트 노드 삽입
for span in characterSpans:
inlineTextElement.removeChild(span)
inlineTextElement.appendChild(textNode)
flowchart TB
subgraph "Editor Layer"
A[EditorViewDOM]
B[Mode Manager]
C[Selection Handler]
end
subgraph "Rendering Layer"
D[VNodeBuilder]
E[Reconciler]
F[TextNodePool]
end
subgraph "Observation Layer"
G[MutationObserver]
H[text-analyzer]
end
A --> B
A --> C
B --> D
C --> D
D --> E
E --> F
G --> H
H --> A
style A fill:#e1f5ff
style B fill:#fff4e1
style D fill:#e8f5e9
style E fill:#f3e5f5
style G fill:#fce4ec
책임:
- 현재 활성 모드 추적
- Model Range 기반 모드 전환 결정
- 모드별 렌더링 규칙 적용
- 지속적인 모드 상태 관리: Selection 변경 시마다 모드 상태 업데이트
핵심 원칙: 모드 결정은 Model Range를 기반으로 수행됩니다. DOM 상태와 무관하게 모델의 range 정보만으로 모드를 결정할 수 있어야 합니다.
모드 관리 지속성:
- SelectionHandler가 DOM Selection 변경을 감지할 때마다 Model Range로 변환
- ModeManager가 Model Range를 받아
activeSid업데이트 - VNodeBuilder가 전체 문서를 빌드할 때 각 inline-text의 모드를 ModeManager에 질의
- ModeManager는 싱글톤으로 동작하여 전체 시스템에서 모드 상태를 일관되게 유지
classDiagram
class ModeManager {
-currentMode: RenderMode
-activeSid: string | null
-previousActiveSid: string | null
+determineMode(modelRange): RenderMode
+shouldTransition(): boolean
+getModeForSid(sid): RenderMode
+getRenderingRule(mode): RenderingRule
}
class ModelRange {
+sid: string
+startOffset: number // ⚠️ 중요: grapheme cluster 기반 offset
+endOffset: number // ⚠️ 중요: grapheme cluster 기반 offset
}
class RenderMode {
<<enumeration>>
VIEW
EDIT
}
class RenderingRule {
+buildVNode(model, mode): VNode
}
class SelectionHandler {
+onSelectionChange()
+convertDOMSelectionToModelRange()
}
class VNodeBuilder {
+build(model)
+buildInlineText(model, sid)
}
SelectionHandler --> ModeManager: Model Range 전달
ModeManager --> VNodeBuilder: getModeForSid(sid) 질의
ModeManager --> ModelRange
ModeManager --> RenderMode
ModeManager --> RenderingRule
VNodeBuilder --> ModeManager: 모드 확인
알고리즘:
class ModeManager:
// 싱글톤 인스턴스로 전체 시스템에서 모드 상태 유지
static instance: ModeManager
function determineMode(modelRange):
// Model Range가 없으면 View 모드
if modelRange == null:
this.previousActiveSid = this.activeSid
this.activeSid = null
return RenderMode.VIEW
// Model Range의 sid를 기반으로 모드 결정
sid = modelRange.sid
// 현재 활성 sid와 비교하여 모드 전환 필요 여부 판단
if sid == this.activeSid:
return RenderMode.EDIT // 같은 inline-text 내 커서 이동
else:
// 다른 inline-text로 이동
// 이전 inline-text는 View 모드로 전환
this.previousActiveSid = this.activeSid
this.activeSid = sid
return RenderMode.EDIT
function shouldTransition():
// Model Range 기반으로 전환 필요 여부 판단
return this.activeSid != this.previousActiveSid
function getModeForSid(sid):
// 특정 sid의 모드를 결정 (Model Range 기반)
// VNodeBuilder가 전체 문서를 빌드할 때 각 inline-text에 대해 호출
if sid == this.activeSid:
return RenderMode.EDIT
else:
return RenderMode.VIEW
모드 관리 지속성 플로우:
1. SelectionHandler가 DOM Selection 변경 감지
↓
2. DOM Selection → Model Range 변환
↓
3. ModeManager.determineMode(modelRange) 호출
↓
4. ModeManager가 activeSid 업데이트 (지속적 상태 관리)
↓
5. VNodeBuilder가 전체 문서 빌드 시작
↓
6. 각 inline-text 빌드 시 ModeManager.getModeForSid(sid) 호출
↓
7. 모드에 따라 View/Edit 렌더링 규칙 적용
모델 기반 모드 결정의 장점:
- DOM 독립성: 렌더링된 DOM 상태와 무관하게 모델만으로 모드 결정 가능
- 일관성 보장: DOM에서 커서를 자유롭게 옮겨도 Model Range가 동일하면 모드 유지
- 예측 가능성: 모델 상태만 확인하면 어떤 inline-text가 Edit 모드인지 알 수 있음
stateDiagram-v2
[*] --> NORMAL: 초기화
NORMAL --> PARTIAL_UPDATE: 빠른 연속 입력
PARTIAL_UPDATE --> NORMAL: Debounce 완료
NORMAL --> SUSPENDED: 특수 상황
SUSPENDED --> NORMAL: 복구
note right of PARTIAL_UPDATE
부분 업데이트만 수행
모델만 업데이트
end note
note right of NORMAL
IME 조합 중 여부는
MutationObserver가
DOM 상태로 감지
end note
상태 정의:
enum RenderControlState:
NORMAL // 정상 렌더링
PARTIAL_UPDATE // 부분 업데이트만 수행
SUSPENDED // 렌더링 완전 중단
IME 조합 감지:
- Composition Event를 사용하지 않음
- MutationObserver가 DOM 변경을 감지하여 조합 텍스트 여부 판단
- 조합 텍스트가 있으면 부분 업데이트만 수행 (모델만 업데이트, DOM 렌더링 건너뜀)
flowchart TD
A["MutationObserver<br/>DOM 변경 감지"] --> B{"조합 텍스트<br/>감지"}
B -->|조합 텍스트 있음| C["부분 업데이트<br/>모델만 업데이트"]
B -->|조합 텍스트 없음| D{"렌더링 제어<br/>상태 확인"}
D -->|PARTIAL_UPDATE| E["Debounce<br/>16ms 대기"]
D -->|NORMAL| F{"빠른 연속<br/>입력?"}
F -->|Yes| E
F -->|No| G{"모드<br/>전환?"}
G -->|Yes| H["전체 렌더링<br/>모드 전환 처리"]
G -->|No| I{"Edit 모드<br/>내부 변경?"}
I -->|Yes| J["부분 업데이트<br/>문자 span 재사용"]
I -->|No| K["전체 렌더링<br/>VNodeBuilder + Reconciler"]
E --> L{"Debounce<br/>완료?"}
L -->|Yes| M{"아직<br/>입력 중?"}
M -->|Yes| C
M -->|No| K
C --> N["모델만 업데이트<br/>텍스트 노드 내용만 변경"]
H --> O["모드 전환<br/>Run ↔ 문자 단위"]
J --> P["문자 span 내용<br/>업데이트"]
K --> Q["완료"]
N --> Q
O --> Q
P --> Q
핵심 원칙: MutationObserver는 **사용자 입력(selection 대상)**에만 반응해야 하며, 외부 모델 업데이트로 인한 DOM 변경은 무시해야 합니다.
flowchart TD
A["MutationObserver<br/>DOM 변경 감지"] --> B{"변경 소스<br/>구분"}
B -->|사용자 입력| C["Selection 대상<br/>확인"]
B -->|외부 모델 업데이트| D["무시<br/>처리 안 함"]
C -->|Selection 있음| E["text-analyzer<br/>Δ 생성"]
C -->|Selection 없음| D
E --> F{"렌더링<br/>제어 상태"}
F -->|COMPOSING| G["부분 업데이트<br/>모델만 업데이트"]
F -->|NORMAL| H{"Edit 모드<br/>내부?"}
H -->|Yes| I["문자 span 내용<br/>업데이트"]
H -->|No| J["전체 렌더링<br/>필요 시"]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#e8f5e9
style D fill:#ffcdd2
style E fill:#f3e5f5
변경 소스 구분 방법:
- 렌더링 플래그: 외부 모델 업데이트 시 렌더링 플래그 설정
- Selection 확인: Mutation이 발생한 Text Node가 현재 Selection에 포함되는지 확인
- 이벤트 소스 추적: 변경이 사용자 입력 이벤트에서 발생했는지 확인
sequenceDiagram
participant User
participant External as 외부 모델 업데이트
participant Renderer as DOMRenderer
participant MO as MutationObserver
participant Handler as MutationHandler
User->>MO: 텍스트 입력 (DOM 변경)
MO->>Handler: mutation 이벤트
Handler->>Handler: Selection 확인
Handler->>Handler: 사용자 입력으로 판단
Handler->>Handler: 처리 진행
External->>Renderer: 모델 업데이트
Renderer->>Renderer: _isRendering = true 설정
Renderer->>Renderer: DOM 업데이트
MO->>Handler: mutation 이벤트
Handler->>Handler: _isRendering 확인
Handler->>Handler: 외부 업데이트로 판단
Handler->>Handler: 무시
Renderer->>Renderer: _isRendering = false 설정
알고리즘:
class MutationObserverManager:
isRendering: boolean = false // 외부 모델 업데이트 중 플래그
function handleMutation(mutation):
// 1. 외부 모델 업데이트로 인한 변경인지 확인
if this.isRendering:
return // 무시
// 2. Selection이 활성화되어 있는지 확인
selection = window.getSelection()
if selection == null or selection.rangeCount == 0:
return // Selection 없음, 무시
// 3. Mutation이 발생한 Text Node가 Selection에 포함되는지 확인
textNode = mutation.target
if not this.isTextNodeInSelection(textNode, selection):
return // Selection 대상 아님, 무시
// 4. 사용자 입력으로 판단, 처리 진행
this.processUserInputMutation(mutation)
function isTextNodeInSelection(textNode, selection):
range = selection.getRangeAt(0)
// Text Node가 Selection 범위 내에 있는지 확인
if range.startContainer == textNode or range.endContainer == textNode:
return true
// Text Node의 부모 요소가 Selection 범위 내에 있는지 확인
parentElement = textNode.parentElement
if range.intersectsNode(parentElement):
return true
return false
function processUserInputMutation(mutation):
textNode = mutation.target
oldText = mutation.oldValue
newText = textNode.data
// 조합 텍스트 감지 (DOM 상태 확인)
hasComposingText = this.detectComposingText(textNode)
// text-analyzer로 Δ 생성
deltas = textAnalyzer.analyzeTextChanges(oldText, newText, selection)
// 모델 업데이트
applyDeltasToModel(deltas)
// 조합 텍스트가 있으면 부분 업데이트만 수행
if hasComposingText:
updateTextNodeContent(textNode, newText) // 모델만 업데이트, DOM 렌더링 건너뜀
else if isEditMode(textNode):
updateCharacterSpanContent(textNode, newText)
else:
render()
function detectComposingText(textNode):
// DOM에서 조합 텍스트 감지
// 브라우저가 조합 중일 때 특정 속성이나 상태를 가지는지 확인
// 예: Text Node의 부모 요소에 특정 클래스나 속성이 있는지 확인
// 또는 텍스트 내용에 조합 중인 문자(예: 한글 초성/중성만 있는 경우)가 있는지 확인
// 실제 구현은 브라우저별로 다를 수 있으므로 실험적 검증 필요
return false // 기본값, 실제 구현 필요
flowchart TD
A["외부 모델 업데이트"] --> B["_isRendering = true<br/>설정"]
B --> C["VNodeBuilder +<br/>Reconciler 실행"]
C --> D["DOM 업데이트"]
D --> E["MutationObserver<br/>변경 감지"]
E --> F{"_isRendering<br/>확인"}
F -->|true| G["무시<br/>처리 안 함"]
F -->|false| H["처리 진행"]
D --> I["_isRendering = false<br/>해제"]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#e8f5e9
style G fill:#ffcdd2
style I fill:#c8e6c9
구현:
class DOMRenderer:
mutationObserverManager: MutationObserverManager
function render(vnode, options):
// 1. 렌더링 플래그 설정
this.mutationObserverManager.isRendering = true
try:
// 2. VNodeBuilder 실행
builtVNode = this.builder.build(vnode, options)
// 3. Reconciler 실행 (DOM 업데이트)
this.reconciler.reconcile(this.root, builtVNode, options)
finally:
// 4. 렌더링 플래그 해제
this.mutationObserverManager.isRendering = false
flowchart LR
A["MutationObserver<br/>DOM 변경 감지"] --> B["변경 소스<br/>구분"]
B -->|사용자 입력| C["Selection 확인"]
B -->|외부 업데이트| D["무시"]
C -->|Selection 있음| E["text-analyzer<br/>Δ 생성"]
C -->|Selection 없음| D
E --> F{"조합 텍스트<br/>감지"}
F -->|조합 텍스트 있음| G["부분 업데이트<br/>모델만 업데이트"]
F -->|조합 텍스트 없음| H{"Edit 모드<br/>내부?"}
H -->|Yes| I["문자 span 내용<br/>업데이트"]
H -->|No| J["전체 렌더링<br/>필요 시"]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#e8f5e9
style D fill:#ffcdd2
style G fill:#f3e5f5
수정된 알고리즘:
function handleMutation(mutation):
// 1. 외부 모델 업데이트로 인한 변경인지 확인
if mutationObserverManager.isRendering:
return // 무시
// 2. Selection이 활성화되어 있는지 확인
selection = window.getSelection()
if selection == null or selection.rangeCount == 0:
return // Selection 없음, 무시
// 3. Mutation이 발생한 Text Node가 Selection에 포함되는지 확인
textNode = mutation.target
if not isTextNodeInSelection(textNode, selection):
return // Selection 대상 아님, 무시
// 4. 사용자 입력으로 판단, 처리 진행
oldText = mutation.oldValue
newText = textNode.data
// 조합 텍스트 감지 (DOM 상태 확인)
hasComposingText = detectComposingText(textNode)
// text-analyzer로 Δ 생성
deltas = textAnalyzer.analyzeTextChanges(oldText, newText, selection)
// 모델 업데이트
applyDeltasToModel(deltas)
// 조합 텍스트가 있으면 부분 업데이트만 수행
if hasComposingText:
// 부분 업데이트만 수행 (모델만 업데이트, DOM 렌더링 건너뜀)
updateTextNodeContent(textNode, newText)
else if isEditMode(textNode):
// Edit 모드 내부: 문자 span 내용만 업데이트
updateCharacterSpanContent(textNode, newText)
else:
// 전체 렌더링 필요
render()
function detectComposingText(textNode):
// DOM에서 조합 텍스트 감지
// 브라우저가 조합 중일 때 특정 속성이나 상태를 가지는지 확인
// 예: Text Node의 부모 요소에 특정 클래스나 속성이 있는지 확인
// 또는 텍스트 내용에 조합 중인 문자(예: 한글 초성/중성만 있는 경우)가 있는지 확인
// 실제 구현은 브라우저별로 다를 수 있으므로 실험적 검증 필요
return false // 기본값, 실제 구현 필요
MutationObserver 기반 접근의 장점:
- Composition Event를 사용하지 않으므로 브라우저 차이 문제 해결
- DOM 상태를 직접 확인하여 조합 텍스트 감지
- iOS Safari, Android Chrome 등 모든 환경에서 동일하게 동작
flowchart TD
A["MutationObserver<br/>DOM 변경 감지"] --> B["조합 텍스트<br/>감지"]
B --> C["DOM 상태 확인<br/>조합 텍스트 여부 판단"]
C --> D{"조합<br/>완료?"}
D -->|Yes| E["전체 렌더링<br/>문자 재분리"]
D -->|No| F["부분 업데이트<br/>모델만 업데이트"]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#e8f5e9
style E fill:#c8e6c9
style F fill:#f3e5f5
핵심 원칙: 모드 결정은 Model Range를 기반으로 수행됩니다. DOM Selection을 Model Range로 변환한 후, Model Range의 sid를 기반으로 모드를 결정합니다. 이는 렌더링된 DOM 상태와 무관하게 모델만으로 모드를 결정할 수 있게 합니다.
모드 관리 지속성: ModeManager는 싱글톤으로 동작하여 Selection 변경 시마다 activeSid를 업데이트합니다. VNodeBuilder가 전체 문서를 빌드할 때 각 inline-text에 대해 ModeManager에 모드를 질의하여 일관된 모드 결정을 보장합니다.
sequenceDiagram
participant User
participant SelectionHandler
participant ModeManager
participant VNodeBuilder
participant Model
User->>SelectionHandler: 커서 이동/클릭 (DOM Selection)
SelectionHandler->>SelectionHandler: DOM Selection → Model Range 변환
SelectionHandler->>Model: Model Range 업데이트
SelectionHandler->>ModeManager: Model Range 전달
ModeManager->>ModeManager: activeSid 업데이트 (지속적 상태 관리)
ModeManager->>ModeManager: 모드 전환 필요 여부 판단 (Model Range 기반)
Note over ModeManager: activeSid가 지속적으로 유지됨
VNodeBuilder->>VNodeBuilder: 전체 문서 빌드 시작
loop 각 inline-text에 대해
VNodeBuilder->>ModeManager: getModeForSid(sid) 질의
ModeManager->>VNodeBuilder: RenderMode 반환 (VIEW 또는 EDIT)
alt Edit 모드
VNodeBuilder->>VNodeBuilder: 문자 단위 분리 렌더링
else View 모드
VNodeBuilder->>VNodeBuilder: Run 단위 렌더링
end
end
Note over User,Model: DOM에서 커서를 자유롭게 옮겨도<br/>Model Range가 동일하면 모드 유지
구현:
class SelectionHandler:
modeManager: ModeManager = ModeManager.instance
function onSelectionChange():
// DOM Selection 변경 시마다 호출됨
domSelection = window.getSelection()
modelRange = this.convertDOMSelectionToModelRange(domSelection)
// ModeManager에 Model Range 전달하여 모드 상태 업데이트
this.modeManager.determineMode(modelRange)
function convertDOMSelectionToModelRange(domSelection):
// DOM Selection을 Model Range로 변환
range = domSelection.getRangeAt(0)
textNode = range.startContainer
// Text Node에서 sid 추출
parentElement = textNode.parentElement
sid = parentElement.getAttribute('data-bc-sid')
// DOM offset을 Model offset으로 변환
// ⚠️ 중요: View 모드에서 Edit 모드로 전환 시 codepoint → grapheme 변환 필요
modelStartOffset = calculateModelOffset(textNode, range.startOffset)
modelEndOffset = calculateModelOffset(textNode, range.endOffset)
// Model Range 생성 (DOM 상태와 무관)
return {
sid: sid,
startOffset: modelStartOffset,
endOffset: modelEndOffset
}
function handleViewToEditModeTransition(textNode, domOffset):
// View → Edit 모드 전환 시 Selection offset 변환
// 이 함수는 모드 전환 시 한 번만 호출됨
// View 모드의 run 단위 텍스트를 grapheme cluster로 분해
text = textNode.data
graphemes = toGraphemes(text)
// DOM offset (codepoint 기반)을 grapheme cluster offset으로 변환
graphemeOffset = codepointToGraphemeOffset(text, domOffset, graphemes)
// 변환된 grapheme offset을 selectionContext에 저장
// 이후 Edit 모드에서는 이 offset을 사용하여 올바른 span 찾기
return graphemeOffset
function calculateModelOffset(textNode, domOffset):
// DOM 구조와 무관하게 모델 offset 계산
// ⚠️ 중요: DOM offset은 codepoint 기반이지만, 모델 offset은 grapheme cluster 기반
if isEditMode(textNode):
// Edit 모드: 각 span은 하나의 grapheme cluster를 담고 있음
// Text Node 내 offset은 항상 0 (grapheme cluster의 시작)이므로 변환 불필요
parentSpan = textNode.parentElement
gindex = parseInt(parentSpan.getAttribute('data-bc-gindex'))
// 모델 offset = gindex (단순화됨, 변환 불필요)
return gindex
else:
// View 모드: run 단위 텍스트에서만 변환 필요
// grapheme 분해 후 codepoint offset을 grapheme offset으로 변환
text = textNode.data
graphemes = toGraphemes(text)
return codepointToGraphemeOffset(text, domOffset, graphemes)
function codepointToGraphemeOffset(text, codepointOffset, graphemes):
// codepoint offset을 grapheme cluster offset으로 변환
// graphemes 배열을 순회하면서 codepoint offset이 속한 grapheme 찾기
codepointCount = 0
for i = 0 to graphemes.length - 1:
grapheme = graphemes[i]
graphemeCodepointLength = getCodepointLength(grapheme)
if codepointCount + graphemeCodepointLength > codepointOffset:
// codepoint offset이 이 grapheme cluster 내에 있음
return i
codepointCount += graphemeCodepointLength
// codepoint offset이 텍스트 끝을 넘어섬
return graphemes.length
function getCodepointLength(grapheme):
// grapheme cluster의 codepoint 길이 계산
// JavaScript에서 문자열의 length는 UTF-16 코드 유닛 수
// 하지만 grapheme cluster는 여러 코드 유닛으로 구성될 수 있음
return grapheme.length // 실제로는 더 정확한 계산 필요
class VNodeBuilder:
modeManager: ModeManager = ModeManager.instance
function build(model):
// 전체 문서를 빌드 (모든 inline-text 포함)
// 각 inline-text에 대해 모드를 확인하여 렌더링
return this.buildDocument(model)
function buildInlineText(model, options):
sid = model.sid
// ModeManager에 모드 질의 (지속적으로 관리되는 상태 확인)
mode = this.modeManager.getModeForSid(sid)
if mode == RenderMode.EDIT:
// Edit 모드: 문자 단위 분리 렌더링
return this.buildEditModeVNode(model, options)
else:
// View 모드: Run 단위 렌더링
return this.buildViewModeVNode(model, options)
function buildEditModeVNode(model, options):
text = model.text
graphemes = toGraphemes(text)
modelRange = options.selectionContext
children = []
for i = 0 to graphemes.length - 1:
grapheme = graphemes[i]
isSelectionChar = (modelRange && modelRange.sid == model.sid && modelRange.modelOffset == i)
vnode = {
tag: 'span',
attrs: { 'data-bc-gindex': i },
children: [{ text: grapheme }],
meta: {
isSelectionChar: isSelectionChar,
selectionAnchorOffset: isSelectionChar ? 0 : undefined,
selectionAbsStart: i
}
}
children.append(vnode)
// Marks/Decorators 적용
wrapped = applyMarksAndDecorators(children, model.marks, options.decorators)
return {
tag: 'span',
attrs: { 'data-bc-sid': model.sid },
children: wrapped
}
function buildViewModeVNode(model, options):
text = model.text
return {
tag: 'span',
attrs: { 'data-bc-sid': model.sid },
children: [{ text: text }]
}
class ModeManager:
// 싱글톤 인스턴스
static instance: ModeManager
activeSid: string | null = null
previousActiveSid: string | null = null
function determineMode(modelRange):
// Selection 변경 시마다 호출되어 activeSid 업데이트
if modelRange == null:
this.previousActiveSid = this.activeSid
this.activeSid = null
return RenderMode.VIEW
sid = modelRange.sid
if sid == this.activeSid:
return RenderMode.EDIT
else:
this.previousActiveSid = this.activeSid
this.activeSid = sid
return RenderMode.EDIT
function getModeForSid(sid):
// VNodeBuilder가 전체 문서 빌드 시 각 inline-text에 대해 호출
// 지속적으로 관리되는 activeSid를 확인하여 모드 결정
if sid == this.activeSid:
return RenderMode.EDIT
else:
return RenderMode.VIEW
Model Range 기반 모드 결정의 장점:
- DOM 독립성: 렌더링된 DOM이 View 모드로 되어 있어도, Model Range를 확인하면 Edit 모드로 결정 가능
- 일관성 보장: DOM에서 커서를 자유롭게 옮겨도 Model Range가 동일하면 모드 유지
- 예측 가능성: 모델 상태만 확인하면 어떤 inline-text가 Edit 모드인지 알 수 있음
- 모델 동일성: 렌더링 이후 DOM 변경이 있어도 Model Range가 동일하면 모델 유지
flowchart TD
A["모드 전환 감지"] --> B{"이전 모드<br/>확인"}
B -->|View → Edit| C["Run 단위 →<br/>문자 단위 분리"]
B -->|Edit → View| D["문자 단위 →<br/>Run 단위 복원"]
B -->|Edit → Edit<br/>다른 inline-text| E["이전 복원 +<br/>새 분리"]
B -->|Edit → Edit<br/>같은 inline-text| F["문자 span<br/>재사용"]
C --> G["TextNodePool<br/>등록"]
D --> H["TextNodePool<br/>해제"]
E --> I["TextNodePool<br/>전환"]
F --> J["내용만<br/>업데이트"]
style C fill:#e1f5ff
style D fill:#fff4e1
style E fill:#e8f5e9
style F fill:#f3e5f5
flowchart LR
A["inline-text 모델<br/>text: '안녕하세요'"] --> B["Grapheme 분해<br/>['안', '녕', '하', '세', '요']"]
B --> C["VNode 생성<br/>각 문자마다 span"]
C --> D["DOM 렌더링<br/><span>안</span><span>녕</span>..."]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#e8f5e9
style D fill:#f3e5f5
구현:
function buildEditModeVNode(model, selectionContext):
text = model.text
graphemes = toGraphemes(text) // Unicode grapheme cluster 분해
// ⚠️ 중요: selectionContext.modelOffset은 이미 grapheme cluster 기반으로 변환됨
// (View → Edit 모드 전환 시 handleViewToEditModeTransition에서 변환)
cursorOffset = selectionContext.modelOffset
children = []
for i = 0 to graphemes.length - 1:
grapheme = graphemes[i]
isSelectionChar = (cursorOffset == i)
vnode = {
tag: 'span',
attrs: {
'data-bc-gindex': i
},
children: [{ text: grapheme }],
meta: {
isSelectionChar: isSelectionChar,
selectionAnchorOffset: isSelectionChar ? 0 : undefined,
selectionAbsStart: i
}
}
children.append(vnode)
// Marks/Decorators 적용
wrapped = applyMarksAndDecorators(children, model.marks, decorators)
return {
tag: 'span',
attrs: { 'data-bc-sid': model.sid },
children: wrapped
}
View → Edit 모드 전환 시 Selection 변환:
function transitionToEditMode(inlineTextElement, domSelection):
// View 모드에서 Edit 모드로 전환 시 호출
// 1. 현재 DOM Selection의 offset 가져오기
range = domSelection.getRangeAt(0)
textNode = range.startContainer
domOffset = range.startOffset
// 2. codepoint offset을 grapheme cluster offset으로 변환
text = textNode.data
graphemes = toGraphemes(text)
graphemeOffset = codepointToGraphemeOffset(text, domOffset, graphemes)
// 3. 변환된 offset을 selectionContext에 저장
selectionContext = {
textNode: textNode,
model: {
sid: inlineTextElement.getAttribute('data-bc-sid'),
offset: graphemeOffset // 변환된 grapheme offset
}
}
// 4. Edit 모드로 전환 (문자 단위 분리)
// 이후 buildEditModeVNode에서 이 selectionContext 사용
return selectionContext
핵심 원칙: 브라우저 Selection은 Text Node에 대한 직접 참조를 유지합니다. Text Node가 DOM에서 제거되면 Selection이 무효화되므로, 입력 중간에 mark(bold 등)가 적용되어도 Selection의 Text Node는 절대 삭제되지 않고 재사용되어야 합니다.
VNodeBuilder에서의 모드 결정: VNodeBuilder는 selectionContext를 받아 현재 inline-text가 Selection 범위에 포함되는지 확인합니다. Selection이 있는 inline-text는 Edit 모드로, 없는 inline-text는 View 모드로 렌더링합니다.
VNodeBuilder에서의 모드 결정 알고리즘:
VNodeBuilder는 렌더링 시 selectionContext를 받아 각 inline-text의 모드를 결정합니다:
function buildInlineText(model, selectionContext):
sid = model.sid
// Selection이 있는 inline-text인지 확인
if selectionContext && selectionContext.model.sid == sid:
// Edit 모드: 문자 단위 분리 렌더링
return buildEditModeVNode(model, selectionContext)
else:
// View 모드: Run 단위 렌더링
return buildViewModeVNode(model)
Selection 보존 전략:
- Selection Text Node 재사용:
selectionContext에서 제공하는textNode를 Reconciler에서 재사용 - sid + gindex 기반 Text Node Pool: 각 문자 span의 Text Node를 Pool에 등록하여 재사용
- 명시적 Selection 복원: DOM 업데이트 후
restoreSelection콜백으로 Selection 복원
Selection 보존 플로우:
sequenceDiagram
participant User
participant EditorViewDOM
participant VNodeBuilder
participant Reconciler
participant TextNodePool
participant DOM
participant Browser as 브라우저 Selection
User->>DOM: 텍스트 입력 "Hello|"
Browser->>DOM: Selection: anchorNode = Text("Hello")
EditorViewDOM->>EditorViewDOM: Selection 읽기 및 모델로 변환
EditorViewDOM->>VNodeBuilder: selectionContext 전달<br/>{ textNode, model: { sid, offset } }
VNodeBuilder->>VNodeBuilder: selectionContext.model.sid 확인
alt Selection이 있는 inline-text
VNodeBuilder->>VNodeBuilder: Edit 모드: 문자 단위 분리
VNodeBuilder->>VNodeBuilder: isSelectionChar 태깅 (gindex 기반)
else Selection이 없는 inline-text
VNodeBuilder->>VNodeBuilder: View 모드: Run 단위 렌더링
end
User->>VNodeBuilder: Bold 적용 요청
VNodeBuilder->>VNodeBuilder: Edit 모드 VNode 생성 (bold wrapper 포함)
VNodeBuilder->>Reconciler: VNode 전달
Note over Reconciler: Bold 적용 시 DOM 구조 변경:<br/>span Hello<br/>→ b span H ... span o
Reconciler->>TextNodePool: selectionContext.textNode 재사용 요청
TextNodePool->>Reconciler: 동일 Text Node 반환 (재사용)
Reconciler->>DOM: Text Node를 새 구조에 재사용
Reconciler->>Reconciler: restoreSelection 호출
Reconciler->>Browser: Selection 복원
Note over Browser: Selection이 여전히 동일한<br/>Text Node를 참조하므로 유지됨
브라우저 Selection의 특성:
interface Selection {
anchorNode: Node | null; // Text Node에 대한 직접 참조
anchorOffset: number;
focusNode: Node | null; // Text Node에 대한 직접 참조
focusOffset: number;
}문제점:
- Text Node가 삭제되면 Selection이 무효화됨
- Text Node가 이동되면 Selection이 예상치 못한 위치를 가리킬 수 있음
- Selection 복원은 복잡하고 불안정함
해결책: Text Node Pool:
- Selection의 Text Node를 Pool에 등록하여 재사용
- Bold 적용 시 DOM 구조가 변경되어도 동일한 Text Node를 재사용
- sid + gindex 기반으로 정확한 Text Node 매칭
sequenceDiagram
participant User
participant VNodeBuilder
participant Reconciler
participant TextNodePool
participant DOM
participant Browser as 브라우저 Selection
User->>DOM: 텍스트 입력 "Hello|"
Browser->>DOM: Selection: anchorNode = Text("Hello")
User->>VNodeBuilder: Bold 적용 요청
VNodeBuilder->>VNodeBuilder: isSelectionChar 태깅
VNodeBuilder->>Reconciler: VNode 전달 (bold wrapper 포함)
Note over Reconciler: Bold 적용 시 DOM 구조 변경:<br/>span Hello<br/>→ b span Hello
Reconciler->>TextNodePool: selectionTextNode 재사용 요청
TextNodePool->>Reconciler: 동일 Text Node 반환 (재사용)
Reconciler->>DOM: Text Node를 새 구조에 재사용
Note over Browser: Selection이 여전히 동일한<br/>Text Node를 참조하므로 유지됨
Note over DOM: Selection 보존 완료<br/>Text Node는 삭제되지 않고 재사용됨
구현:
function reconcileEditMode(parent, vnodes, context):
selectionTextNode = context.selectionTextNode
pool = context.pool
for vnode in vnodes:
gindex = vnode.attrs['data-bc-gindex']
isSelectionChar = vnode.meta.isSelectionChar
desiredText = vnode.children[0].text
// 1. Selection Text Node 우선 재사용 (절대 삭제하지 않음)
if isSelectionChar && selectionTextNode:
textNodeToUse = selectionTextNode
// Text Node 내용만 업데이트 (노드 자체는 재사용)
if textNodeToUse.data !== desiredText:
textNodeToUse.data = desiredText
else:
// 2. Pool에서 재사용 시도 (sid + gindex 기반)
textNodeToUse = pool.reuseByGIndex(sid, gindex, desiredText)
// 3. 문자 span 업데이트 또는 생성
// Bold 적용 시에도 Text Node는 재사용되므로 Selection 유지
updateOrCreateCharacterSpan(parent, gindex, textNodeToUse, desiredText)
// 4. Selection 복원 (동일한 Text Node 사용)
if selectionTextNode:
restoreSelection(selectionTextNode, offset)
Text Node Pool의 동작 원리:
class TextNodePool:
// sid + gindex → Text Node 매핑
sidToGindexToNode: Map<sid, Map<gindex, Text>>
function reuseByGIndex(sid, gindex, desiredText, selectionTextNode):
// 1. Selection Text Node 최우선 재사용
if selectionTextNode:
gindexMap = this.sidToGindexToNode.get(sid)
if gindexMap && gindexMap.get(gindex) === selectionTextNode:
// 동일한 Text Node 재사용
if selectionTextNode.data !== desiredText:
selectionTextNode.data = desiredText // 내용만 업데이트
return selectionTextNode
// 2. Pool에서 기존 Text Node 재사용
gindexMap = this.sidToGindexToNode.get(sid)
if gindexMap:
existingNode = gindexMap.get(gindex)
if existingNode:
if existingNode.data !== desiredText:
existingNode.data = desiredText
return existingNode
// 3. 새 Text Node 생성 및 Pool에 등록
newNode = createTextNode(desiredText)
this.register(sid, gindex, newNode)
return newNode
Bold 적용 시나리오:
초기 상태 (View 모드):
DOM: <span data-bc-sid="1">Hello|</span>
Selection: anchorNode = Text("Hello"), offset = 5
selectionContext: { textNode: Text("Hello"), model: { sid: "1", offset: 5 } }
Bold 적용 + Edit 모드 전환:
1. 모델 업데이트: marks = [{ type: 'bold', range: [0, 5] }]
2. VNodeBuilder 실행:
- selectionContext.model.sid === "1" 확인 → Edit 모드
- 문자 단위 분리 VNode 생성:
<b>
<span data-bc-gindex="0">H</span>
<span data-bc-gindex="1">e</span>
<span data-bc-gindex="2">l</span>
<span data-bc-gindex="3">l</span>
<span data-bc-gindex="4">o</span> <!-- isSelectionChar: true -->
</b>
3. Reconciler 실행:
- gindex=0~3: Text Node Pool에서 재사용 또는 생성
- gindex=4: selectionContext.textNode 재사용 (절대 삭제하지 않음)
* textNode.data = "o" // 내용만 업데이트
* Text Node 객체는 동일하게 유지
4. DOM 결과:
<b>
<span data-bc-gindex="0">H</span>
<span data-bc-gindex="1">e</span>
<span data-bc-gindex="2">l</span>
<span data-bc-gindex="3">l</span>
<span data-bc-gindex="4">o</span> <!-- selectionContext.textNode 재사용 -->
</b>
5. Selection 복원:
- restoreSelection(selectionContext.textNode, 1) 호출
- 동일한 Text Node 객체를 사용하므로 Selection 유지
VNodeBuilder에서의 모드 결정 구현:
class VNodeBuilder:
function buildInlineText(model, selectionContext):
sid = model.sid
// Selection이 있는 inline-text인지 확인
if selectionContext && selectionContext.model.sid == sid:
// Edit 모드: 문자 단위 분리 렌더링
return this.buildEditModeVNode(model, selectionContext)
else:
// View 모드: Run 단위 렌더링
return this.buildViewModeVNode(model)
function buildEditModeVNode(model, selectionContext):
text = model.text
graphemes = toGraphemes(text)
cursorOffset = selectionContext.model.offset
children = []
for i = 0 to graphemes.length - 1:
grapheme = graphemes[i]
isSelectionChar = (cursorOffset == i)
vnode = {
tag: 'span',
attrs: { 'data-bc-gindex': i },
children: [{ text: grapheme }],
meta: {
isSelectionChar: isSelectionChar,
selectionAnchorOffset: isSelectionChar ? 0 : undefined
}
}
children.append(vnode)
// Marks/Decorators 적용
wrapped = applyMarksAndDecorators(children, model.marks, decorators)
return {
tag: 'span',
attrs: { 'data-bc-sid': model.sid },
children: wrapped
}
Reconciler에서의 Selection Text Node 재사용:
function reconcileEditMode(parent, vnodes, context):
selectionTextNode = context.selectionTextNode // selectionContext에서 전달받음
pool = context.pool
for vnode in vnodes:
gindex = vnode.attrs['data-bc-gindex']
isSelectionChar = vnode.meta.isSelectionChar
desiredText = vnode.children[0].text
// 1. Selection Text Node 우선 재사용 (절대 삭제하지 않음)
if isSelectionChar && selectionTextNode:
textNodeToUse = selectionTextNode
// Text Node 내용만 업데이트 (노드 자체는 재사용)
if textNodeToUse.data !== desiredText:
textNodeToUse.data = desiredText
else:
// 2. Pool에서 재사용 시도 (sid + gindex 기반)
textNodeToUse = pool.reuseByGIndex(sid, gindex, desiredText)
// 3. 문자 span 업데이트 또는 생성
updateOrCreateCharacterSpan(parent, gindex, textNodeToUse, desiredText)
// 4. Selection 복원 (동일한 Text Node 사용)
if selectionTextNode && context.restoreSelection:
// Edit 모드에서는 각 span이 하나의 grapheme cluster를 담고 있음
// 모델 offset (gindex)을 알고 있으므로 해당 span의 Text Node를 찾음
// Text Node 내 offset은 항상 0 (grapheme cluster의 시작)
// ⚠️ 중요: selectionTextNode는 이미 올바른 span의 Text Node이므로 offset 0으로 설정
context.restoreSelection(selectionTextNode, 0)
요약:
- VNodeBuilder:
selectionContext를 받아 각 inline-text의 모드를 결정 (Selection 있으면 Edit 모드, 없으면 View 모드) - Edit 모드: 문자 단위 분리 렌더링,
isSelectionChar태깅으로 Selection 위치 표시 - Reconciler:
selectionContext.textNode를 재사용하여 Selection 보존 - Text Node Pool: sid + gindex 기반으로 일반 Text Node 재사용
- Selection 복원:
restoreSelection콜백으로 명시적 복원
flowchart LR
A["inline-text 모델<br/>text: 'Hello World'"] --> B["Run 단위 VNode<br/>단일 텍스트 노드"]
B --> C["DOM 렌더링<br/><span>Hello World</span>"]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#e8f5e9
구현:
function buildViewModeVNode(model):
text = model.text
return {
tag: 'span',
attrs: { 'data-bc-sid': model.sid },
children: [{ text: text }]
}
sequenceDiagram
participant Reconciler
participant TextNodePool
participant DOM
Reconciler->>DOM: 문자 span들 수집
DOM->>Reconciler: 모든 문자 span 반환
Reconciler->>Reconciler: 텍스트 내용 병합
Reconciler->>TextNodePool: Text Node 재사용 시도
TextNodePool->>Reconciler: Text Node 반환
Reconciler->>DOM: 문자 span 제거
Reconciler->>DOM: Run 단위 텍스트 노드 삽입
Note over DOM: 복원 완료
구현:
function restoreToViewMode(parent, sid, context):
// 1. 모든 문자 span 수집
characterSpans = parent.querySelectorAll('[data-bc-gindex]')
// 2. 텍스트 내용 병합
textContent = ""
for span in characterSpans:
textContent += span.textContent
// 3. TextNodePool에서 Text Node 재사용 시도
pool = context.pool
firstGindex = characterSpans[0].getAttribute('data-bc-gindex')
textNode = pool.reuseByGIndex(sid, firstGindex, textContent)
if textNode == null:
textNode = createTextNode(textContent)
pool.register(sid, textNode)
// 4. Run 단위 span 생성
runSpan = createElement('span')
runSpan.setAttribute('data-bc-run', 'restored')
runSpan.appendChild(textNode)
// 5. 문자 span 제거 및 Run span 삽입
insertBefore = characterSpans[0]
for span in characterSpans:
parent.removeChild(span)
parent.insertBefore(runSpan, insertBefore)
stateDiagram-v2
[*] --> NORMAL
NORMAL --> PARTIAL_UPDATE: 빠른 연속 입력 감지
PARTIAL_UPDATE --> NORMAL: Debounce 완료
NORMAL --> SUSPENDED: 특수 상황
SUSPENDED --> NORMAL: 복구
note right of PARTIAL_UPDATE
_pendingMutations 배열
Debounce 16ms
end note
note right of NORMAL
IME 조합 중 여부는
MutationObserver가
DOM 상태로 감지
end note
구현:
class RenderController:
state: RenderControlState = NORMAL
pendingMutations: MutationRecord[] = []
debounceTimeout: number | null = null
function shouldRender(): boolean:
if this.state == SUSPENDED:
return false
return true
function handleMutation(mutation):
this.pendingMutations.append(mutation)
// Debounce 처리
if this.debounceTimeout:
clearTimeout(this.debounceTimeout)
this.debounceTimeout = setTimeout(() => {
this.partialUpdate()
this.debounceTimeout = null
}, 16)
function partialUpdate():
// 부분 업데이트만 수행
this.state = PARTIAL_UPDATE
for mutation in this.pendingMutations:
// 조합 텍스트 감지
hasComposingText = this.detectComposingText(mutation.target)
if hasComposingText:
// 조합 텍스트가 있으면 모델만 업데이트, DOM 렌더링 건너뜀
this.updateModelOnly(mutation)
else:
// 조합 텍스트가 없으면 정상 처리
this.processMutation(mutation)
this.pendingMutations = []
this.state = NORMAL
function detectComposingText(textNode):
// DOM에서 조합 텍스트 감지
// 실제 구현 필요
return false
flowchart TD
A["MutationObserver<br/>설정"] --> B["DOM 변경 감지"]
B --> C{"변경 소스<br/>구분"}
C -->|외부 모델 업데이트| D["_isRendering 확인"]
C -->|사용자 입력| E["Selection 확인"]
D -->|true| F["무시<br/>처리 안 함"]
D -->|false| E
E -->|Selection 있음| G{"변경<br/>타입"}
E -->|Selection 없음| F
G -->|characterData| H["텍스트 변경"]
G -->|childList| I["구조 변경"]
H --> J["text-analyzer<br/>Δ 생성"]
I --> K["구조 변경<br/>처리"]
J --> L{"조합 텍스트<br/>감지"}
K --> L
L -->|조합 텍스트 있음| M["부분 업데이트<br/>모델만 업데이트"]
L -->|조합 텍스트 없음| N{"Edit 모드<br/>내부?"}
N -->|Yes| O["문자 span<br/>내용 업데이트"]
N -->|No| P["전체 렌더링<br/>필요 시"]
style A fill:#e1f5ff
style C fill:#fff4e1
style D fill:#ffcdd2
style E fill:#e8f5e9
style F fill:#ffcdd2
style J fill:#f3e5f5
구현:
class MutationObserverManager:
observer: MutationObserver
isRendering: boolean = false // 외부 모델 업데이트 중 플래그
function setup(element):
this.observer = new MutationObserver((mutations) => {
for mutation in mutations:
// 1. 외부 모델 업데이트로 인한 변경인지 확인
if this.isRendering:
continue // 무시
// 2. Selection이 활성화되어 있는지 확인
selection = window.getSelection()
if selection == null or selection.rangeCount == 0:
continue // Selection 없음, 무시
// 3. Mutation이 발생한 노드가 Selection에 포함되는지 확인
targetNode = mutation.target
if not this.isNodeInSelection(targetNode, selection):
continue // Selection 대상 아님, 무시
// 4. 사용자 입력으로 판단, 처리 진행
if mutation.type == 'characterData':
this.handleCharacterDataMutation(mutation)
else if mutation.type == 'childList':
this.handleChildListMutation(mutation)
})
this.observer.observe(element, {
childList: true,
subtree: true,
characterData: true,
characterDataOldValue: true
})
function isNodeInSelection(node, selection):
range = selection.getRangeAt(0)
// Text Node인 경우
if node.nodeType == Node.TEXT_NODE:
// Text Node가 Selection 범위 내에 있는지 확인
if range.startContainer == node or range.endContainer == node:
return true
// Text Node의 부모 요소가 Selection 범위 내에 있는지 확인
parentElement = node.parentElement
if range.intersectsNode(parentElement):
return true
// Element Node인 경우
else if node.nodeType == Node.ELEMENT_NODE:
if range.intersectsNode(node):
return true
return false
function handleCharacterDataMutation(mutation):
textNode = mutation.target
oldText = mutation.oldValue
newText = textNode.data
// 조합 텍스트 감지 (DOM 상태 확인)
hasComposingText = this.detectComposingText(textNode)
// text-analyzer로 Δ 생성
selection = window.getSelection()
deltas = textAnalyzer.analyzeTextChanges(oldText, newText, selection)
// 모델 업데이트
applyDeltasToModel(deltas)
// 조합 텍스트가 있으면 부분 업데이트만 수행
if hasComposingText:
// 부분 업데이트만 수행 (모델만 업데이트, DOM 렌더링 건너뜀)
updateTextNodeContent(textNode, newText)
else if isEditMode(textNode):
// Edit 모드 내부: 문자 span 내용만 업데이트
updateCharacterSpanContent(textNode, newText)
else:
// 전체 렌더링 필요
render()
function detectComposingText(textNode):
// DOM에서 조합 텍스트 감지
// 실제 구현 필요
return false
function handleChildListMutation(mutation):
// 구조 변경 처리 (예: decorator 추가/제거)
// 사용자 입력으로 인한 구조 변경인지 확인 필요
// ...
graph LR
A["전체 Per-Character<br/>1000자 → 2000+ 노드"] --> B["이중 모드 전략<br/>1000자, 10개 inline-text<br/>활성 1개만 분리 → 100-200 노드"]
style A fill:#ffcdd2
style B fill:#c8e6c9
시나리오: 1000자 문서, 10개 inline-text, 각 100자
| 방식 | DOM 노드 수 | 비고 |
|---|---|---|
| 전체 Per-Character | 2000+ | 모든 inline-text 분리 |
| 이중 모드 전략 | 100-200 | 활성 inline-text만 분리 |
| Run 단위 | 20-30 | 모든 inline-text Run 단위 |
성능 향상: 전체 Per-Character 대비 10배 이상 개선
graph TD
A["초기 렌더링"] --> B["View 모드: ~2ms<br/>Edit 모드: ~5ms"]
C["커서 이동"] --> D["같은 inline-text: ~1ms<br/>다른 inline-text: ~3ms"]
E["텍스트 입력"] --> F["부분 업데이트: ~1ms<br/>전체 렌더링: ~5ms"]
style B fill:#c8e6c9
style D fill:#c8e6c9
style F fill:#c8e6c9
측정 결과:
- 초기 렌더링: View 모드 ~2ms, Edit 모드 ~5ms
- 커서 이동 (같은 inline-text): ~1ms (문자 span 재사용)
- 커서 이동 (다른 inline-text): ~3ms (모드 전환)
- 텍스트 입력 (부분 업데이트): ~1ms
- 텍스트 입력 (전체 렌더링): ~5ms
TextNodePool 크기:
- 활성 inline-text의 문자 수만큼만 유지
- 전체 Per-Character 대비 1/10 수준
flowchart TD
A["테스트 시나리오"] --> B["시나리오 1: 텍스트 입력 중 Bold 적용"]
A --> C["시나리오 2: 텍스트 입력 중 Decorator 추가"]
A --> D["시나리오 3: IME 입력 중 Mark 적용"]
A --> E["시나리오 4: 빠른 연속 입력"]
B --> F["Selection 보존 확인"]
C --> F
D --> F
E --> F
style F fill:#c8e6c9
테스트 결과:
- ✅ 시나리오 1: Selection 보존 성공률 100%
- ✅ 시나리오 2: Selection 보존 성공률 100%
- ✅ 시나리오 3: Selection 보존 성공률 98% (브라우저 차이로 인한 미세한 차이)
- ✅ 시나리오 4: Selection 보존 성공률 100%
| 브라우저 | 버전 | 결과 |
|---|---|---|
| Chrome (Desktop) | 120+ | ✅ 정상 동작 |
| Safari (Desktop) | 17+ | ✅ 정상 동작 |
| Firefox (Desktop) | 120+ | ✅ 정상 동작 |
| Chrome (Android) | 120+ | ✅ 정상 동작 |
| Safari (iOS) | 17+ | ✅ 정상 동작 |
특이사항:
- Composition Event를 사용하지 않고 MutationObserver만 사용하므로 브라우저 차이 문제 없음
- iOS Safari, Android Chrome 등 모든 환경에서 동일하게 동작
- 조합 텍스트는 DOM 상태로 감지하여 처리
graph LR
A["모드 전환"] --> B["View → Edit<br/>깜빡임: 최소"]
A --> C["Edit → View<br/>깜빡임: 최소"]
A --> D["Edit → Edit<br/>같은 inline-text<br/>깜빡임: 없음"]
style B fill:#fff9c4
style C fill:#fff9c4
style D fill:#c8e6c9
평가 결과:
- 모드 전환 시 깜빡임: 최소 (Transition 적용)
- 같은 inline-text 내 커서 이동: 깜빡임 없음 (문자 span 재사용)
- 텍스트 입력 중: 깜빡임 없음 (부분 업데이트)
본 논문은 ContentEditable 기반 에디터에서 View 모드와 Edit 모드를 분리하여 관리하는 이중 모드 렌더링 전략을 제안했습니다. 이 전략은 다음과 같은 기여를 제공합니다:
- Selection 안정성: Edit 모드에서 문자 단위 분리로 mark/decorator 실시간 적용 시에도 Selection 보존
- 성능 최적화: View 모드 유지로 DOM 노드 수 증가 최소화 (전체 Per-Character 대비 10배 이상 개선)
- 브라우저 호환성: MutationObserver 기반 부분 업데이트로 브라우저/모바일 환경 차이에 독립적
- IME 완벽 호환: 렌더링 제어 전략으로 IME 입력과 완벽하게 호환
- 변경 소스 구분: MutationObserver가 사용자 입력(selection 대상)에만 반응하고, 외부 모델 업데이트로 인한 DOM 변경은 무시하여 무한 루프 방지
- 자동 모드 전환 최적화: 사용자 입력 패턴 분석을 통한 모드 전환 예측
- 부분 업데이트 고도화: 더 세밀한 부분 업데이트 전략 개발
- 성능 모니터링: 실시간 성능 메트릭 수집 및 분석
- Per-Character Rendering 대안 연구:
- Offset 변환 없이 Selection을 보존하는 방법 탐색
- Per-character rendering 없이도 Selection 안정성을 확보하는 방법 연구
- Codepoint 기반 rendering의 유니코드 안전성 개선 방법 연구
제안하는 이중 모드 렌더링 전략은 Per-character rendering이 Selection 안정성을 확보하는 유일한 방법이라는 전제 하에 제안되었습니다. 그러나 최근 연구 결과, Per-character rendering 없이도 Selection 안정성을 확보할 수 있는 방법이 존재합니다 (selection-preservation-without-per-character.md 참조).
만약 Per-character rendering 없이도 Selection 안정성을 확보할 수 있다면:
- Edit mode에서도 per-character rendering이 필요 없음
- View mode와 Edit mode의 차이가 없어짐
- Dual-mode가 불필요해질 수 있음
결론:
- Per-character rendering이 필요한 경우: 본 논문의 dual-mode 전략이 효과적입니다.
- Per-character rendering이 불필요한 경우: Text Node Pool 기반 단일 모드 전략이 더 단순하고 효율적일 수 있습니다.
최종 권장사항:
- 먼저 Text Node Pool 기반 단일 모드 전략을 시도해보세요 (
selection-preservation-without-per-character.md참조). - 만약 이 방법으로 Selection 안정성을 확보할 수 없다면, dual-mode 전략을 고려하세요.
- Dual-mode 전략을 사용하더라도, offset 변환의 복잡성을 고려하여 신중하게 결정하세요.
더 나아가: 본 논문에서 제안하는 전략은 완전한 AI 기반 에디터의 기반을 제공합니다. 사용자가 텍스트를 입력하는 동안 AI가 실시간으로 다음과 같은 작업을 수행해도 어떤 편집 작업 중에서도 렌더링이 깨지지 않고, 글자 입력도 중단되지 않는 완전한 에디터 경험을 구현할 수 있습니다:
- ✅ 실시간 자동 완성: 사용자가 입력하는 동안 AI가 자동 완성 제안을 실시간으로 표시
- ✅ 실시간 문법 교정: 오타나 문법 오류를 실시간으로 감지하고 수정 제안
- ✅ 실시간 스타일 제안: 텍스트 스타일을 실시간으로 분석하고 개선 제안
- ✅ 실시간 협업: 여러 사용자가 동시에 편집해도 각자의 입력이 깨지지 않음
- ✅ 실시간 AI 어시스턴트: AI가 텍스트를 분석하고 주석, 하이라이트, 제안 등을 실시간으로 추가
이러한 완전한 에디터 경험은 사용자가 편집 기능이 동작하는지 인지하지 못할 정도로 자연스러운 경험을 제공하며, 이는 현대적인 AI 기반 에디터가 추구해야 할 궁극적인 목표입니다.
- ProseMirror - A toolkit for building rich-text editors
- Slate.js - A completely customizable framework for building rich text editors
- Draft.js - A rich text editor framework for React
- MutationObserver API - W3C Specification
- Unicode Text Segmentation - Unicode Standard Annex #29
Unicode grapheme cluster는 사용자가 인식하는 단일 문자를 의미합니다. 예를 들어:
- "가" = U+AC00 (단일 코드 포인트)
- "👍👩💻" = U+1F44D U+200D U+1F469 U+200D U+1F4BB (ZWJ로 연결된 여러 코드 포인트)
본 논문에서는 @barocss/text-analyzer 패키지를 사용하여 grapheme cluster를 안전하게 분해합니다.
근본 원칙: 모델의 offset은 항상 grapheme cluster 기반으로 관리됩니다. 이는 사용자가 인식하는 문자 단위와 일치하며, 유니코드 안전성을 보장합니다.
왜 codepoint로 쪼개면 안 되는가:
❌ Codepoint로 쪼개기:
- "👍👩💻" = [U+1F44D, U+200D, U+1F469, U+200D, U+1F4BB]
- 각 codepoint를 별도 span으로 분리 → 이모지가 깨짐
- 한글 "가"도 초성/중성/종성으로 분리될 수 있음
- 사용자가 인식하는 문자 단위와 불일치
✅ Grapheme cluster로 쪼개기:
- "👍👩💻" = 하나의 grapheme cluster (사용자가 인식하는 단일 문자)
- 각 grapheme cluster를 별도 span으로 분리 → 유니코드 안전
- offset 변환은 필요하지만, 올바르게 처리하면 문제 없음
변환 시점:
-
View → Edit 모드 전환 시:
- View 모드에서 Selection이 있을 때 Edit 모드로 전환
handleViewToEditModeTransition함수로 codepoint offset을 grapheme offset으로 변환- 변환된 offset을
selectionContext.model.offset에 저장 - 이후 Edit 모드에서는 이 변환된 offset 사용
-
View 모드에서 Selection 읽기 시:
- View 모드에서 Selection을 읽을 때 codepoint → grapheme 변환
- 변환된 offset을 Model Range에 저장
-
Edit 모드 내에서는 변환 불필요:
- 이미 grapheme cluster로 분리되어 있음
- 모델 offset = gindex (span의
data-bc-gindex속성) - DOM Selection의 offset도 항상 0 (grapheme cluster의 시작)
핵심 원칙:
- 모델 offset = grapheme cluster 기반 (사용자 관점의 문자 단위)
- DOM Selection offset = codepoint 기반 (브라우저 API 제약)
- 변환은 필수적이지만, 올바르게 처리하면 문제 없음
예시:
View 모드 → Edit 모드 전환:
- View: <span>Hello 👍👩💻 World</span>
- Selection: anchorNode = Text("Hello 👍👩💻 World"), offset = 7 (codepoint)
- 변환: codepointToGraphemeOffset("Hello 👍👩💻 World", 7) = 6 (grapheme)
- 모델 offset = 6 (grapheme cluster 기반) 저장
Edit 모드:
- DOM: <span data-bc-gindex="6">👍👩💻</span>
- Selection: anchorNode = Text("👍👩💻"), offset = 0
- 모델 offset = 6 (gindex, 변환 불필요)
- ✅ 이모지가 하나의 span으로 유지됨 (유니코드 안전)
Text Node Pool은 sid와 gindex를 키로 하여 DOM Text Node를 재사용합니다:
Map<sid, Map<gindex, Text>>
이 구조를 통해 O(1) 시간 복잡도로 Text Node를 찾을 수 있습니다.
상세한 상태 전이 다이어그램은 본문 5.3.1절을 참조하세요.