정의
문단·이미지·표 같은 "블록"이 여러 개 쌓인 에디터에서, 사용자가 여러 블록을 가로질러 드래그로 선택한 뒤 그 범위를 지우거나·굵게 하거나·타이핑으로 대체하는 동작을 만들고 싶을 때 쓰는 패턴이다. 까다로운 이유는, 리치텍스트 편집 엔진이 지금 커서가 있는 한 블록에만 붙어 있어서 블록 경계를 넘는 선택을 처리할 주인이 없기 때문이다.
정확히 말하면, Cross-block text range operations는 문서를 블록 트리로 host(문서 전체 상태를 소유하는 상위 컴포넌트)가 소유하고 리치텍스트 엔진은 포커스된 한 블록에만 마운트되는 하이브리드 에디터에서, 사용자가 여러 블록에 걸쳐 만든 native 텍스트 선택(브라우저가 기본으로 관리하는, 드래그로 생긴 선택 영역)에 대해 삭제·마크(굵게·기울임 같은 서식)·입력을 적용하는 패턴이다. 범위를 React state에 저장하지 않고, 연산 시점에 window.getSelection()(현재 선택 영역을 반환하는 브라우저 API)에서 무상태로 파생한 뒤 host 패치(상태를 부분적으로 바꾸는 변경 조각)로 반영한다.
왜 필요한가
단일 ProseMirror/TipTap(대표적인 리치텍스트 에디터 엔진) document는 selection owner(선택 상태를 관리하는 주체)가 하나다. guest(한 블록에만 잠깐 마운트되는 편집 엔진 인스턴스)가 한 블록만 편집하면 블록 경계를 넘는 선택에 대해 owning editor instance가 없다. 그런데 브라우저 Selection API는 이미 크로스 노드 범위(여러 DOM 노드에 걸친 선택)를 표현한다. 범위를 미러링(React state로 따로 복제)하면 native selection과 drift(값이 서로 어긋남)하고, IME(한글·일본어 등을 조합해 입력하는 방식) 조합·리렌더 시 깨지기 쉽다.
동작 원리
- 사용자가 static 블록·guest 블록을 가로질러 드래그 선택한다.
selectionchange(선택 영역이 바뀔 때마다 발생하는 브라우저 이벤트) 또는 keydown 시점에 anchor/focus DOM node(선택의 시작·끝 지점)를 (blockId, inlineOffset)으로 변환한다.isCrossBlock이면 guestonKeyDown이 아닌 document-level handler가 실행된다.- 범위 연산 전 블록 경계에서 inline content split(선택 경계에서 텍스트를 두 조각으로 나누는 것) 로직을 한 모듈에 둔다 — delete·addMark·removeMark·slice(범위만 잘라 복사)가 재사용.
- 삭제(Backspace)는 흔히 병합: 첫 블록 prefix + 마지막 블록 suffix를 첫 블록에 합치고 중간 블록 제거.
- 패치 적용 후 static DOM이 리렌더되면 native selection이 사라질 수 있음 → offset을 DOM position으로 역매핑해
requestAnimationFrame(다음 화면 그리기 직전에 콜백을 실행하는 브라우저 API)에서 재선택. - 단일 블록 내부 선택은 기존 guest가 처리 — 크로스일 때만 가로챈다.
실무 적용
- DOM position ↔ logical offset 변환 헬퍼를 양방향으로 유지한다.
blockInlineContent(block)처럼text만 있는 블록의 파생 content를 읽는다 — rawcontentundefined 함정.- 플로팅 포맷 툴바는 단일블록 UI를 재사용하고, 크로스일 때만 콜백을 patch 연산으로 라우팅한다.
- Enter on cross-block selection: split 대신 병합만 하는 정책 등 제품 규칙을 명시한다.
- copy/cut/paste는 slice 직렬화 + 기존 clipboard 파이프라인 재사용이 자연스러운 다음 단계다.
트레이드오프
- 전역 selection 모델을 새로 만들지 않아 동기화 부담이 적다.
- 대신 블록별 patch·경계 split·selection 복원 로직이 복잡해진다.
- guest 트랜잭션과 patch 경로가 공존 — 단일블록 회귀를 가드로 엄격히 분리한다.
사용하면 안 되는 경우
- 문서 전체가 단일 contenteditable / 단일 PM doc — 기본 selection·commands로 충분.
- 블록이 항상 plain text 한 줄이고 마크가 없음 — 범위 연산 단순화 가능.
- 협업 OT/CRDT(여러 사용자의 동시 편집을 충돌 없이 합치는 알고리즘)가 selection state를 서버 권위로 들고 있는 경우 — 별도 프로토콜 필요.
흔한 실수
- 드래그 중 from/to를 React state에 저장해 native와 drift.
- 경계 split 수학을 delete·mark마다 복제.
- 크로스블록에서 guest keymap과 document handler 둘 다 실행.
- 마크 적용 후 selection 복원 없이 툴바가 사라짐.
for...of등 팀 lint와 충돌하는 범위 순회(reduce 등으로 통일).
관련 개념
- focused-guest-editor — guest 한 블록 lease
- selectionchange-floating-ui — 툴바 트리거·소유권
- two-stack-inverse-undo — 패치 역연산 undo
- fixed-position-floating-ui-scroll — 툴바 위치