이 글에서 다루는 것
블록 기반 리치텍스트 에디터를 만들다 보면 순서, 동시 편집, undo, IME, 테스트, scoped UI가 하루에 겹친다. 겉보기 주제는 다르지만 공통 목표는 하나다: 편집 의도를 안정적으로 기록·되돌리·검증하는 것. 각 작업이 어떤 상황에서 왜 필요했고, 무엇을 선택했으며, 무엇을 배웠는지를 작업별로 정리한다.
하루 요약
| 작업 | 무엇을 하고 싶었나 | 한 일 | 결과 |
|---|---|---|---|
| 순서 | concurrent insert에도 결정적 순서 — renumber 없이 | fractional position key + neighbor anchor(after/before) + (position, clientId, opId) tie-break | 중간·동시 삽입에서 renumber 충돌 없이 정렬 유지 |
| 협업 | optimistic + remote 도착 시 rollback → re-apply | outbox에 inverse patch 저장, merge 권위는 서버 seq | remote 도착 시 rebase 가능, 재적용 실패는 silent drop 안 함 |
| undo | guest가 처리 못 할 때 document undo로 명시 위임 | chainCommands(inlineUndo, delegateDocumentUndo) + Playwright 검증 | preventDefault로 막히던 undo가 document로 위임됨 |
| IME | composing 중 commit defer | composing 플래그 + guest mount-once | 조합 중 CJK 깨짐 해소 |
| Portal | scoped CSS 유지 — 작은 메뉴는 inline render 검토 | 작은 toolbar/image control은 Portal off inline | Portal 밖으로 새던 scoped 스타일 유지 |
| Jest | ESM-only 의존성을 CJS Jest에서 로드 | vendor TS shim + moduleNameMapper | nested install path에서도 안정적으로 import |
1. Fractional ordering
배경지식 (개념)
- 블록 에디터: 문단·제목·리스트가 각각 “블록”인 문서 UI (Notion·Google Docs 스타일).
어떤 상황이었나
형제 블록 순서를 배열 index로 저장하면, 중간 삽입·동시 삽입·offline replay 때 renumber 충돌이 잦다.
핵심 작업
정렬 가능한 문자열 position을 storage SoT로 두고, patch는 “몇 번째”가 아니라 이웃 anchor(after/before) 로 의도를 표현했다. 동시 insert는 (position, clientId, opId) tie-break로 결정적 순서를 만든다. subtree move/promote 시 position reuse는 sibling sort를 깨므로 bounds 기준 재할당이 필요하다. 결과적으로 중간·동시 삽입에서 renumber 충돌 없이 정렬이 유지됐다.
교훈
- ordering은 결국 anchor·origin 같은 계약 문제다.
- subtree move 시 position reuse는 sibling sort를 깨므로 bounds 기준 재할당이 필요하다.
2. Optimistic outbox rebase
배경지식 (개념)
- Optimistic UI: 서버 응답 전에 로컬에 먼저 반영해 빠르게 보이게 하는 패턴.
어떤 상황이었나
로컬에 먼저 보여 주는 optimistic apply 후, 서버/다른 클라이언트 변경이 도착하면 되돌렸다가 다시 적용(rebase) 해야 한다.
핵심 작업
outbox의 각 pending entry는 inverse patch를 함께 저장해야 remote 도착 시 rollback→re-apply rebase가 가능하다. merge 권위는 wall-clock이 아니라 서버 seq에 두는 편이 낫다. 재적용 실패는 silent drop하지 않는다. 결과적으로 remote 도착 시 rebase가 가능해졌고, 재적용 실패를 조용히 버리지 않게 됐다.
교훈
- undo·collab은 모두 inverse·origin 같은 계약 문제다.
- 재적용 실패는 silent drop하지 않는다.
3. Keyboard undo delegation
배경지식 (개념)
- contenteditable: 브라우저 기본 편집 가능 영역. guest 리치텍스트 엔진이 focus 중 Ctrl/Cmd+Z에 preventDefault를 걸어 native undo를 막는다.
어떤 상황이었나
guest 리치텍스트 엔진이 focus 중 Ctrl/Cmd+Z에 preventDefault를 걸어 native undo를 막는다. 인라인 히스토리가 비어 있어도 이벤트는 “소비됨”처럼 동작하므로, 상위 handler의 defaultPrevented 가드만으로는 document undo에 도달하지 못한다.
핵심 작업
chainCommands(inlineUndo, delegateDocumentUndo) 로 명시 fallback하고, Playwright로 검증했다. 결과적으로 preventDefault로 막히던 undo가 document로 위임됐다.
교훈
- undo는 inverse·origin 같은 계약 문제다.
- jsdom green은 preventDefault 근거가 될 수 없어 실브라우저 E2E가 필요하다.
→ Contenteditable keyboard history delegation · Two-stack inverse undo
4. IME composition
배경지식 (개념)
- IME: 한글·일본어 등 조합 입력.
compositionstart~compositionend동안 commit을 미뤄야 한다.
어떤 상황이었나
조합 중에 commit·serialize·re-render가 끼어들면 한글이 깨진다. jsdom 테스트는 composition을 충실히 재현하지 않는다.
핵심 작업
커스텀 composition 이벤트보다 composing 플래그로 commit defer + guest mount-once가 먼저다. 결과적으로 조합 중 CJK 깨짐이 해소됐다.
교훈
- jsdom은 composition을 충실히 재현하지 않으므로 IME 근거는 실브라우저 E2E로 확인한다.
→ IME composition in contenteditable
5. Radix Portal vs scoped CSS
배경지식 (개념)
- Radix Popover Portal: 팝오버 content를 DOM 트리 밖으로 렌더하는 패턴. scoped CSS ancestor를 벗어난다.
어떤 상황이었나
[data-scope] .popover 스타일이 Portal content에는 적용되지 않는다.
핵심 작업
작은 toolbar/image control popover는 Portal off inline render로 scope를 유지하고, 테스트는 trigger open 후 내부 control을 query했다. 결과적으로 Portal 밖으로 새던 scoped 스타일이 유지됐다.
교훈
- scoped CSS를 쓰는 작은 에디터 메뉴는 inline 렌더를 먼저 검토한다.
6. Jest + ESM-only dependency
배경지식 (개념)
- ESM-only 패키지: CommonJS 없이 ES Module만 제공하는 npm 패키지. CJS Jest에서 import 에러를 낸다.
어떤 상황이었나
ESM-only npm 패키지가 CJS Jest에서 import 에러를 낸다.
핵심 작업
vendor TS shim + moduleNameMapper가 transformIgnorePatterns보다 nested install path에서 안정적일 수 있다. 결과적으로 nested install path에서도 안정적으로 import됐다.
교훈
- test green과 tsc green은 별개다.