-
Notifications
You must be signed in to change notification settings - Fork 0
Contenteditable Web Contract
이 문서는 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는 소유하지 않는다.
공개 표면은 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를 바꾸지 않았다는 뜻이다.
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만 읽는다.
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으로 직접 복원한다.
구조화된 클립보드 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은 다음 순서로 표시한다.
atom.labelatom.textatom.name- 없으면
\uFFFC
Copy는 selected text range 안에 걸친 atoms/ranges만 fragment에 담고, offset은 fragment-local offset으로 재기준화한다. Paste는 fragment-local offset을 insertion start 기준으로 다시 rebasing한다.
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한다.
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 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-document의TextSurfaceFragment와 완전히 합쳐지지는 않았다. 현재는 이 package 자체 fragment schema를 쓴다.
이 한계는 의도된 축소다. 지금 목표는 제품 에디터가 아니라, DOM을 최대한 얇게 격리한 contenteditable web bridge다.
-
pnpm run test:core: jsdom package contract. -
pnpm run verify:browser:/demobrowser smoke route. -
pnpm run verify:internal: TypeScript, Vitest, Biome, production build.