Skip to content

Contenteditable Web Contract

유용태 edited this page Jun 24, 2026 · 3 revisions

Contenteditable Web 코드 계약

이 문서는 packages/contenteditable-web을 처음 읽는 사람이 public API와 내부 흐름을 연결해서 이해할 수 있도록 유지하는 코드 지도다.

이 repo는 json-document/packages/contenteditable-web의 이름과 패키지 모양을 따른다. contenteditable은 브라우저 표준 개념이고, web은 DOM adapter 레이어라는 뜻이다. source module, public contract, smoke route에는 도구 이름이 아니라 평이한 도메인 이름을 쓴다. smoke route는 /demo다.

한 문장 정의

packages/contenteditable-web@interactive-os/json-document의 문자열 path와 브라우저 contenteditable DOM 사이를 잇는 얇은 bridge다. 문서 모델, block schema, toolbar policy, markdown, overlay, 앱 UI는 소유하지 않는다.

공개 API

공개 표면은 packages/contenteditable-web/index.ts에서만 읽는다.

createJsonContentEditable(options): JsonContentEditable<T>
isJsonContentEditableFragment(value): value is JsonContentEditableFragment

공개 상수:

JSON_TEXT_ATTRIBUTE = "data-json-text"
JSON_ATOM_ATTRIBUTE = "data-json-atom"
JSON_ATOM_REPLACEMENT = "\uFFFC"
JSON_CONTENT_EDITABLE_FRAGMENT_SCHEMA = "json-document-contenteditable-fragment@1"
JSON_CONTENT_EDITABLE_MIME = "application/x-json-document-fragment"

Host DOM은 text surface마다 data-json-text를 가져야 한다.

<div contenteditable="plaintext-only">
  <p data-json-text="/blocks/0/text">Plain text</p>
</div>

Atom DOM은 data-json-atom을 가진다. Atom은 DOM에서는 element지만 model text에서는 \uFFFC 한 글자로 취급한다.

<span data-json-atom="mention-ada" contenteditable="false">@Ada</span>

옵션

type JsonContentEditableOptions<T> = {
  root: HTMLElement;
  document: JSONDocument<T>;
  atomsPath?: Pointer | ((textPath: Pointer) => Pointer | null) | null;
  rangesPath?: Pointer | ((textPath: Pointer) => Pointer | null) | null;
  atomAttribute?: string;
  textAttribute?: string;
};

root는 이벤트와 DOM selection을 읽을 contenteditable subtree다.

document는 canonical source of truth다. 이 package는 DOM을 document truth로 만들지 않고, DOM에서 읽은 변경을 document.commit(...), document.selection, document.clipboard, document.undo/redo로 환원한다.

atomsPath는 text path와 atom sidecar path를 연결한다. 예를 들어 /blocks/0/text의 atoms가 /blocks/0/atoms에 있다면 함수로 넘긴다.

const atomsPath = (textPath: Pointer) =>
  textPath === "/blocks/0/text" ? "/blocks/0/atoms" : null;

rangesPath도 같은 방식이다. bold, underline 같은 inline range metadata가 text와 분리된 sidecar record에 있을 때 사용한다.

반환 객체

createJsonContentEditable은 다음 object를 반환한다.

type JsonContentEditable<T> = {
  handle(event: Event): JsonContentEditableUpdate | ClipboardUpdate<T>;
  flush(options?: FlushOptions): JsonContentEditableUpdate;
  syncSelectionFromDOM(): SelectionSnap | null;
  restoreSelectionToDOM(selection?: SelectionSnap): boolean;
  copy(event?: ClipboardEvent): ClipboardUpdate<T>;
  cut(event?: ClipboardEvent): ClipboardUpdate<T>;
  paste(event?: ClipboardEvent): ClipboardUpdate<T>;
  pasteFragment(fragment, selection?): ClipboardUpdate<T>;
  pasteText(text, selection?): ClipboardUpdate<T>;
  undo(): JSONCapabilityResult;
  redo(): JSONCapabilityResult;
  reset(): void;
};

핵심 사용 방식은 host가 browser event를 그대로 handle에 넘기는 것이다.

const editable = createJsonContentEditable({
  root,
  document,
  atomsPath,
  rangesPath,
});

root.addEventListener("beforeinput", editable.handle);
root.addEventListener("input", editable.handle);
root.addEventListener("compositionstart", editable.handle);
root.addEventListener("compositionend", editable.handle);
root.addEventListener("copy", editable.handle);
root.addEventListener("cut", editable.handle);
root.addEventListener("paste", editable.handle);
root.addEventListener("keydown", editable.handle);
root.addEventListener("select", editable.handle);

React host에서는 /demo처럼 synthetic event에서 event.nativeEvent를 넘긴다.

결과 형태

Text/selection 결과:

type JsonContentEditableUpdate =
  | {
      ok: true;
      kind: "no-change" | "selection" | "text";
      selection: SelectionSnap | null;
      patch: readonly JSONPatchOperation[];
    }
  | {
      ok: false;
      code: "missing_root" | "missing_text_path" | "not_string" | "commit_failed";
      reason: string;
    };

Clipboard 결과:

type ClipboardUpdate<T> =
  | { ok: true; value: T }
  | {
      ok: false;
      code: "empty_selection" | "clipboard_unavailable" | "invalid_payload";
      reason: string;
    };

kind: "text"는 JSON Patch commit이 발생했다는 뜻이다. kind: "selection"은 document text는 그대로이고 document.selection만 갱신됐다는 뜻이다. kind: "no-change"는 event를 이 package가 관찰했지만 아직 canonical document를 바꾸지 않았다는 뜻이다.

DOM과 Model의 대응 규칙

Text path는 DOM attribute로 찾는다.

[data-json-text="/blocks/0/text"] <-> document.at("/blocks/0/text")

DOM text content는 다음 projection으로 model text가 된다.

Text node textContent -> 그대로 문자열
[data-json-atom] element -> "\uFFFC" 한 글자
다른 element -> child를 순서대로 순회

그래서 mention이 끝에 있는 DOM:

<span data-json-text="/blocks/0/text">
  Hello <span data-json-atom="ada">@Ada</span>
</span>

은 model에서는 다음 문자열이다.

"Hello \uFFFC"

Atom metadata는 text 안에 넣지 않고 sidecar record로 둔다.

{
  "text": "Hello \uFFFC",
  "atoms": {
    "ada": { "type": "mention", "label": "@Ada", "offset": 6 }
  }
}

Range metadata도 sidecar record다.

{
  "text": "Hello",
  "marks": {
    "bold-1": { "type": "bold", "start": 0, "end": 5 }
  }
}

이벤트 라우팅

createJsonContentEditable.ts가 event router다.

Event 처리
beforeinput historyUndo/historyRedo browser history를 막고 json-document history로 route한다. IME lease 중이면 history도 no-op으로 방어한다.
일반 beforeinput 현재 DOM selection을 읽어 native input lease를 시작한다. 아직 model commit은 하지 않는다.
input leased text surface의 DOM text를 읽고 JSON Patch로 commit한다.
compositionstart IME composition lease를 시작한다.
compositionend lease를 pending commit으로 바꾸고 flush한다.
selectionchange / select DOM selection을 SelectionSnap으로 변환해 document.selection에 저장한다.
copy selection을 structured fragment로 serialize하고 browser/document clipboard에 쓴다.
cut copy 후 selection textPatch, atom patch, range patch를 하나의 history transaction으로 commit한다.
paste browser clipboard JSON payload 또는 plain text를 selection 위치에 삽입한다.
keydown Cmd/Ctrl+Z/Y undo/redo를 json-document history로 route한다.
keydown Cmd+ArrowLeft/Right hard line start/end로 selection을 이동한다. IME 중에는 소유하지 않는다.

이 package는 모든 text editing key를 직접 구현하지 않는다. browser native mutation이 일어난 뒤 input에서 leased text surface만 읽는다.

네이티브 입력 Lease

BrowserLease는 브라우저가 잠깐 소유한 text surface를 추적한다.

type BrowserLease = {
  path: Pointer;
  phase: "native" | "composing" | "pending-commit";
};

beforeinput, compositionstart, input은 DOM selection에서 text path를 찾아 lease를 만든다. flush는 lease path 또는 현재 DOM selection path를 기준으로 DOM text와 document text를 비교한다.

previousText = document string at path
nextText = editableTextContent(textElement)
patch = replace path with nextText + atom/range sidecar patches
selectionAfter = chooseSelection(...)
document.commit(patch, { origin: "contenteditable", selectionAfter })

왜 lease가 필요한가:

  • IME 조합 중에는 DOM text가 document보다 앞서갈 수 있다.
  • toolbar/copy/paste/undo가 오기 전에 pending DOM text를 먼저 canonical document로 flush해야 한다.
  • composition target 외의 DOM을 읽으면 browser-owned transient DOM을 document truth로 오해할 수 있다.

선택 매핑

internal/selection.ts가 DOM Selection과 SelectionSnap을 변환한다.

DOM -> model:

Selection.anchorNode/offset
  -> closest [data-json-text]
  -> textOffsetInElement(...)
  -> { path, offset }

model -> DOM:

{ path, offset }
  -> find [data-json-text=path]
  -> textDOMPositionForOffset(...)
  -> Selection.collapse / extend

Atom은 하나의 model character다.

  • atom 앞 DOM position은 atom offset이다.
  • atom 뒤 DOM position은 atom offset + 1이다.
  • atom 내부 node selection도 offset > 0이면 뒤쪽으로 해석한다.

Cmd+ArrowRight 버그가 이 규칙에서 나왔다. 브라우저가 trailing atom 뒤 caret을 atom 앞 DOM position으로 표현할 수 있으므로, command line-end는 browser native movement에 맡기지 않고 model text hard line end offset으로 직접 복원한다.

클립보드 Fragment

구조화된 클립보드 payload:

type JsonContentEditableFragment = {
  schema: "json-document-contenteditable-fragment@1";
  text: string;
  atoms?: Record<string, { offset: number; [key: string]: unknown }>;
  ranges?: Record<string, { start: number; end: number; [key: string]: unknown }>;
};

브라우저 클립보드에는 두 format을 쓴다.

  • text/plain: 사람이 볼 fallback text
  • application/x-json-document-fragment: atoms/ranges를 포함한 JSON payload

Plain text fallback에서 atom은 다음 순서로 표시한다.

  1. atom.label
  2. atom.text
  3. atom.name
  4. 없으면 \uFFFC

Copy는 selected text range 안에 걸친 atoms/ranges만 fragment에 담고, offset은 fragment-local offset으로 재기준화한다. Paste는 fragment-local offset을 insertion start 기준으로 다시 rebasing한다.

Atom Patch 규칙

internal/atoms.ts가 atom sidecar patch를 만든다.

Selection replacement 시:

  • 선택 범위 안 atom은 remove한다.
  • 선택 범위 뒤 atom은 삽입 길이 delta만큼 offset을 이동한다.
  • 붙여넣은 atom은 insertion start + fragment offset으로 add한다.
  • id 충돌이 있으면 id-2, id-3처럼 새 id를 만든다.

DOM sync 시:

  • DOM에서 사라진 atom은 sidecar에서 remove한다.
  • DOM offset과 sidecar offset이 다르면 replace한다.

Range Patch 규칙

internal/ranges.ts가 mark/annotation range sidecar patch를 만든다.

Native text change 시:

  • 변경 전후 text diff로 changed region을 계산한다.
  • 변경 뒤 range start/end를 delta에 맞춰 이동한다.
  • 변경과 겹친 range는 보수적으로 줄이거나 제거한다.

Selection replacement 시:

  • 선택 범위 안 기존 range는 제거 또는 잘라낸다.
  • 선택 범위 뒤 range는 delta만큼 이동한다.
  • 붙여넣은 range는 insertion start + fragment range로 add한다.

이 layer는 bold/underline 자체를 알지 않는다. { type: "bold" } 같은 payload는 host schema의 의미이고, core는 start/end를 가진 range sidecar로만 다룬다.

내부 파일 지도

File 책임
contract.ts public constants, options, fragment, result, returned object type
index.ts public export boundary
createJsonContentEditable.ts event router, lease, flush, clipboard command, undo/redo, line-boundary command
internal/domText.ts DOM subtree를 model text로 project, atom을 한 글자로 계산, offset <-> DOM position 변환
internal/selection.ts DOM Selection <-> SelectionSnap 변환과 restore
internal/clipboard.ts selected fragment 생성, browser/document clipboard read/write, plain text fallback
internal/atoms.ts atom sidecar selection, replacement, DOM sync patch
internal/ranges.ts range sidecar selection, replacement, text-change sync patch
internal/keyboard.ts history key, hard line boundary key 분류
internal/textDiff.ts text commit 후 derived caret 계산용 changed region
internal/jsonDocument.ts JSON pointer value가 string인지 읽는 작은 guard
internal/record.ts unknown value record guard

Host가 책임지는 것

Host app은 다음을 책임진다.

  • document schema 정의
  • text path와 atoms/ranges sidecar path 연결
  • model value를 DOM으로 render
  • toolbar command에서 selection snapshot 보존
  • block split, heading, bold 같은 product command
  • /demo 같은 smoke surface

이 package가 책임지지 않는 것:

  • visual line geometry
  • pointer hit testing
  • block-level selection model
  • markdown
  • sanitizer
  • app-specific atom rendering
  • React lifecycle abstraction

현재 한계

  • Selection range는 한 text path 안에서만 fragment copy/paste를 보장한다.
  • Visual line이 아니라 \n 기준 hard line boundary만 처리한다.
  • beforeinput.getTargetRanges()를 first-class로 쓰지 않는다.
  • MutationObserver safety net은 없다.
  • React hook layer는 없다.
  • json-documentTextSurfaceFragment와 완전히 합쳐지지는 않았다. 현재는 이 package 자체 fragment schema를 쓴다.

이 한계는 의도된 축소다. 지금 목표는 제품 에디터가 아니라, DOM을 최대한 얇게 격리한 contenteditable web bridge다.

검증 계약

  • pnpm run test:core: jsdom package contract.
  • pnpm run verify:browser: /demo browser smoke route.
  • pnpm run verify:internal: TypeScript, Vitest, Biome, production build.