Skip to content

선택 인식 Markdown Live Preview — source-preserving notation reveal 엔진 구현 #143

Description

@developer-1px

목적

contenteditable DOM을 실제 입력 자원으로 계속 사용하면서, Markdown 원문을 잃지 않고 현재 caret/selection이 속한 문법 범위에서만 notation을 드러내는 selection-aware Markdown Live Preview 엔진을 구현한다.

이 문서는 조사 기록이자 구현의 정본이다. 이후 구현자가 이 문서만 읽어도 다음을 다시 조사하지 않고 시작할 수 있어야 한다.

  • 현재 bearMarkdown()이 왜 Bear 기능이 아닌지
  • Bear가 실제로 숨기고 드러내는 범위와 우리가 원하는 #, > 노출의 차이
  • CodeMirror/Joplin/Zettlr/Muya/HyperMD/ProseMirror 계열이 어떤 데이터와 DOM 전략을 쓰는지
  • 현재 semantic JSON 모델로는 왜 원래 notation을 복구할 수 없는지
  • source, syntax range, selection, DOM, IME composition을 어떤 불변식으로 묶을지
  • 자동화로 재현할 수 없는 실제 OS IME를 무엇으로 통과 판정할지

Parent

없음. 이 이슈가 이 기능의 상위 정본이며, 구현 PR 또는 후속 하위 이슈는 이 이슈의 phase를 수직 slice로 가져간다.

한 문장 제품 정의

Markdown source는 그대로 살아 있고, 평소에는 rich하게 보이지만 caret/selection이 해당 syntax owner에 들어가면 원래 delimiter를 같은 논리 위치에서 직접 편집할 수 있는 에디터.

예:

inactive                    caret inside strong

중요한 내용                 **중요한 내용**

블록 문법은 정책에 따라 다르게 보일 수 있어야 한다.

inactive                    active-line reveal       Bear-like attachment

제목                        # 제목                    1  제목
인용                        > 인용                    │  인용

마지막 열처럼 Bear 자체는 block marker를 literal source로 항상 되살리는 제품이 아니다. 따라서 엔진은 Bear를 하드코딩하지 않고, 문법 종류별 reveal policy를 받으며 Bear는 그 위의 preset이어야 한다.


먼저 바로잡을 용어

이름 의미 문서 변경 history 현재 구현 여부
Markdown input rule # , > 같은 입력을 감지해 다른 구조로 transform 있음
Markdown Live Preview source는 유지하고 selection에 따라 notation 표시만 전환 아니오 아니오 없음
Rich presentation 인식된 notation을 계속 숨기고 toolbar로 편집 아니오 아니오 없음(semantic 문서 렌더는 있음)
Source presentation 모든 Markdown source를 그대로 표시 아니오 아니오 이번 범위 아님
Bear preset inline marker는 active일 때 reveal, block marker는 attachment/gutter로 표현 아니오 아니오 없음

제품/업계에서 쓰는 가까운 용어는 다음과 같다.

  • Bear: Hide Markdown, 과거 Auto Hide Markdown, hybrid WYSIWYG Markdown editor
  • Obsidian: Live Preview
  • Typora: Seamless Live Preview, WYSIWYM
  • 구현 용어: selection-aware syntax reveal/concealment, Markdown projection

public naming은 제품명을 빌리기보다 Markdown Live Preview를 정본으로 쓴다. Bear는 preset 또는 비교 설명에서만 사용한다.


현재 저장소에서 확인한 사실

조사 기준 commit: fd9d860

1. 현재 bearMarkdown()은 input rule의 별칭이다

현재 구현은 # > 를 감지한다.

입력 후 planBlockPrefixTransform이 prefix를 text에서 제거하고 block type을 바꾼다. 따라서 이후 source, selection coordinate, DOM 어디에도 사용자가 입력한 # 또는 > 가 남지 않는다.

현재 toggle의 의미:

  • on: 이후 입력되는 prefix를 semantic block으로 변환
  • off: 이후 입력되는 prefix를 literal text로 둠

필요한 Live Preview toggle의 의미:

  • on/off 어느 쪽도 source와 semantic meaning을 바꾸지 않음
  • projection policy만 바꿈
  • document revision/publication, selection, undo history에 들어가지 않음

따라서 현재 것은 Markdown input-rule plugin이며, 진짜 Bear/Live Preview 기능과 분리해야 한다.

2. 현재 @3 모델은 lexical notation을 잃는다

현재 EditableDocumentValue는 다음만 저장한다.

  • block: paragraph | heading | quote | code
  • content: notation이 제거된 text
  • inline: bold | italic | underline | strike | code와 start/end

이 정보만으로는 아래 원문 차이를 복구할 수 없다.

  • *text*_text_
  • **text**__text__
  • ATX heading과 Setext heading
  • -, *, + list marker
  • quote/list의 들여쓰기와 선택적 공백
  • backtick 또는 code fence의 문자와 길이
  • escape, entity, raw Unicode spelling
  • incomplete syntax (**한, `한 등)
  • link destination/title spelling

semantic model에서 canonical notation을 새로 합성해 보여줄 수는 있다. 그러나 그것은 사용자가 입력한 Markdown을 드러내고 직접 고치는 기능이 아니므로 true Live Preview라고 부르면 안 된다.

3. 현재 DOM/selection 좌표도 semantic text 전제다

현재 canonical surface는 surface.textContent === block.text를 요구한다. textFromSurface()textContent 전체를 모델 text로 읽고, DOM offset은 Range.toString().length로 계산한다.

현재 [data-editable-text] 안에 합성 marker Text node를 끼워 넣으면:

  • source가 아닌 marker가 textContent에 섞임
  • selection offset이 semantic block.text와 달라짐
  • native input을 다시 수집할 때 marker까지 문서 변경으로 오인
  • copy/cut과 Range.toString() 의미가 브라우저의 conceal CSS에 종속

즉, 현재 모델을 그대로 두고 DOM에 별표만 삽입하는 방식은 구조적으로 맞지 않는다.

4. composition island는 좋은 출발점이다

현재 projection은 composition 중인 block의 exact Text node와 surface를 보호하고, pin이 유지되는 동안 canonical projection을 미룬다.

Live Preview는 이 규칙을 약화하면 안 된다. 오히려 syntax reveal/conceal도 composition lease 안에서는 topology와 presentation을 함께 freeze해야 한다.


제품 조사: 확인된 사실과 추론을 구분한다

Bear에서 확인된 동작

Bear는 closed source이므로 공개 자료로 제품 동작과 철학은 확인할 수 있지만 내부 canonical data structure를 단정할 수는 없다.

syntax Bear 2에서 공개적으로 확인되는 UX 이 엔진의 의미
**bold**, _italic_, ==highlight== caret/selection이 style 범위에 있을 때 delimiter가 나타남 active policy
link interaction/selection 시 Markdown 표현이 확장됨 owner range 기반 reveal 필요
heading # literal hash 대신 gutter heading indicator 사용 Bear preset은 attachment
quote > literal marker 대신 block attachment/vertical line Bear preset은 attachment
list marker literal marker 대신 bullet/attachment Bear preset은 attachment
fenced code active 상태에서 fence/레이아웃 변화가 보고됨 vertical layout shift 회피 필요
tag #tag hash가 의미 일부라 일반 heading marker처럼 숨지 않음 syntax kind별 정책 필요

근거:

중요한 결론:

사용자가 원하는 “heading/quote 안에 가면 #, >도 실제 source로 나타남”은 충분히 지원할 가치가 있지만, Bear exact preset은 아니다. 엔진의 active-line/active-owner block policy로 제공하고 Bear preset과 분리한다.

selection과 layout은 Bear도 계속 고친 문제다

이 기능은 CSS 한 줄 문제가 아니다.

  • hidden delimiter 주변 선택을 Bear 개발자가 rubber bending issue라고 표현: selection 문제
  • link text와 hidden marker가 selection에 들고 빠지는 문제: link selection 보고
  • code block 활성화 시 높이와 cursor 위치가 변하는 문제: layout shift 보고

따라서 active unit은 DOM node가 아니라 logical syntax owner + source range여야 하고, 핵심 저수준 계약은 아래 양방향 매핑이다.

logical source position
        ↕
current projection DOM position

Bear의 IME 자료에서 확인되는 것

Bear가 composition 중 marker를 정확히 언제 reveal하는지는 공개 스펙이 없다. 아래는 Bear 내부 알고리즘의 증거가 아니라, real-world IME 검증 없이는 성공을 주장할 수 없다는 증거다.

  • macOS 한국어 조립 underline 문제를 팀 환경에서 오래 재현하지 못한 사례: Korean composition 재현 문제
  • Safari IME 수정 뒤 한국어 compositionend + Space가 삼켜진 회귀: 한국어 Space 회귀
  • 2026-06 Bear Web editor engine 업데이트에서 한국어 입력 개선을 별도 항목으로 기록: Bear Web 0.35

오픈소스 real-world 해법 조사

비교 요약

구현 정본 conceal/reveal selection/IME에서 배울 점 그대로 가져오지 않을 것 License
Joplin + CodeMirror 6 raw Markdown source Decoration.replace, syntax별 reveal strategy line/select/active 정책, atomic range, pointer drag freeze, table composition freeze AGPL source 복사 AGPL-3.0
Zettlr + CodeMirror 6 raw Markdown source selection-aware Decoration.replace 최소한의 range/selection 계산 GPL source 복사 GPL-3.0
MarkText/Muya block raw Markdown token range와 hidden/gray marker DOM contenteditable에 가까운 source↔DOM mapper, composition lock innerHTML 전체 교체, zero-width CSS 의존 MIT
HyperMD (CM5) raw Markdown source selection intersect 시 token unhide 역사적으로 검증된 focus-reveal 개념 private CM internals, dormant 구조, composition guard 부재 MIT
Milkdown/ProseMirror semantic nodes/marks delimiter는 parser에서 소비 rich editor 명령/semantic model의 장점 원래 spelling을 다시 드러낼 수 있다고 가정 MIT
CodeMirror 6 core source text decoration + atomic range source position이 숨김 뒤에도 살아 있음, composition state contenteditable DOM의 답을 그대로 제공한다고 가정 MIT

1. Joplin/CodeMirror 6 — 가장 현대적인 정책 reference

Joplin의 Markdown editor는 raw source를 EditorState.doc에 두고 Lezer syntax node에서 replace range를 만든다.

또한 mouse drag selection 중 decoration update를 멈추고 mouseup 후 reconcile한다. 이 패턴은 selection 도중 marker가 나타나 geometry가 바뀌며 selection이 튀는 문제를 직접 겨냥한다.

Joplin은 AGPL이므로 interface/behavior 아이디어만 참고하고 코드는 복사하지 않는다.

2. Zettlr — 작은 selection-aware replace reference

Zettlr도 GPL이므로 알고리즘 개념만 clean-room으로 재구현한다.

3. MarkText/Muya — 현재 contenteditable 구조와 가장 가까운 reference

Muya는 block마다 raw Markdown text를 유지하고 tokenizer의 source range로 inline DOM을 만든다. selection이 token 범위와 겹치면 marker를 보이고, 그렇지 않으면 숨긴다.

가져올 인사이트:

  • source range를 가진 token index
  • explicit DOM↔source offset mapper
  • selection을 projection input으로 취급
  • composition 동안 render lock

가져오지 않을 것:

  • selection 변화마다 innerHTML로 subtree 전체 교체
  • zero-width/display CSS만으로 native caret가 알아서 맞을 것이라는 가정

4. HyperMD — 오래된 exact precedent

HyperMD는 raw Markdown 위에서 selection이 span과 겹치면 token을 unhide했다.

개념은 정확히 가깝지만 CM5 private internals, split token FIXME, 명시적 composition guard 부재 때문에 현재 구현 기반으로 삼지 않는다.

5. Milkdown/ProseMirror — 중요한 반대 사례

이 계열은 Markdown delimiter를 semantic node/mark로 소비하고 serializer가 canonical syntax를 다시 만든다.

traditional rich editor에는 좋은 구조지만 원래 입력한 _/*, fence 길이, 공백, incomplete syntax를 같은 위치에서 편집하는 true Live Preview의 정본으로는 부족하다.

6. CodeMirror 6 공식 API에서 가져올 원리

CodeMirror가 주는 핵심 인사이트는 “보이지 않는 source도 document position을 계속 가진다”는 점이다. 다만 CodeMirror의 DOM은 직접 mutation하지 말아야 하는 관리형 view이고, 우리는 contenteditable DOM을 입력 자원으로 사용하므로 동일 코드를 옮기는 것이 아니라 동일 불변식을 우리 DOM mapper와 composition lease로 구현해야 한다.


채택할 설계 결정

결정 1. canonical coordinate는 Markdown source offset이다

JSONDocument는 유지할 수 있다. 다만 역할을 정확히 나눈다.

JSONDocument
  ├─ lossless Markdown source     ← canonical editable content
  ├─ source-based selection       ← canonical position
  └─ source edit history          ← canonical undo/redo

derived, never independently writable
  ├─ incremental syntax index
  ├─ semantic formatting/block view
  ├─ reveal state
  └─ DOM projection plan

반드시 지킬 불변식:

  • accepted notation은 formatting을 위해 source에서 삭제하지 않는다.
  • source와 semantic text/marks를 둘 다 독립적으로 수정 가능한 정본으로 두지 않는다.
  • parser 결과는 derived cache이며 언제든 source에서 재생성 가능해야 한다.
  • selection은 source UTF-16 offset으로 표현한다. JavaScript String, DOM Text, Selection boundary와 같은 좌표계다.
  • parser가 byte/code-point/line-column을 반환하면 engine seam에서 한 번만 UTF-16 global offset으로 정규화한다.

저장 shape가 단일 source: string이든 lossless source chunk/block이든 엔진 밖에서는 하나의 연속된 source coordinate처럼 보여야 한다. block chunk를 택하면 newline, blank line, indent, fence를 포함한 전체 serialization이 lossless라는 것을 증명해야 한다.

결정 2. 세 모델 선택지의 판정

선택지 판정 이유
현 semantic JSON + 합성 marker 제한적 rich hint로만 가능 original spelling/incomplete syntax를 편집할 수 없음
semantic JSON + markdownSource 양방향 동기화 금지 두 authority가 충돌하고 IME/undo/remote edit 순서에서 불일치
lossless source + derived syntax/semantic projection 채택 real-world Live Preview 구현들과 같은 단일 좌표/authority
lossless CST/token tree를 canonical로 저장 허용 가능한 미래 최적화 exact serialization과 source offset이 보장될 때만 source abstraction 뒤에서 교체

현재 interactive-os.editable-document@3 값을 말없이 다른 의미로 해석하지 않는다. source-backed schema/version과 migration은 명시적으로 도입한다. migration 완료 뒤 legacy와 source runtime을 영구히 두 벌 유지하지 않는다.

결정 3. low layer는 DOM이 아니라 source range를 입출력한다

parser/analyzer의 최소 개념 계약:

type SourceRange = { from: number; to: number };

type MarkdownSyntax = {
  id: string;                 // parse update 사이에 mapping 가능한 logical identity
  kind: string;               // strong, emphasis, heading-marker, quote-marker ...
  ownerRange: SourceRange;    // 이 syntax가 소유하는 전체 범위
  markerRanges: readonly SourceRange[];
  contentRanges: readonly SourceRange[];
};

요구 사항:

  • range는 global source UTF-16 offset
  • incomplete/invalid syntax도 source에서 사라지지 않고 literal fallback
  • analyzer는 pure하며 DOM/selection/history를 수정하지 않음
  • parser 교체가 DOM coordinator와 public editor interface 변경을 요구하지 않음
  • token identity가 offset만으로 구성되어 앞쪽 입력마다 전부 교체되지 않음

결정 4. reveal은 pure policy다

syntax 종류별 scope를 지원한다.

type RevealScope =
  | "active-owner"   // inline owner가 active일 때 양쪽 delimiter
  | "active-line"    // active block/line의 marker
  | "always"         // raw/source-like display
  | "attachment"     // literal은 숨기고 gutter/widget로 의미 표현
  | "concealed";     // rich presentation

개념적 preset:

markdownLivePreview({
  inline: "active-owner",
  heading: "active-line",
  quote: "active-line",
});

bearPreset({
  inline: "active-owner",
  heading: "attachment",
  quote: "attachment",
  list: "attachment",
});

전통적인 rich mode와 Markdown Live Preview mode는 같은 source와 analyzer를 사용하고 policy만 다르다.

  • rich: recognized notation을 conceal/attachment로 유지하고 toolbar로 수정
  • live-preview: active owner/line notation을 직접 편집 가능하게 reveal
  • source: 전부 표시하는 별도 모드이며 이번 이슈 범위 아님

결정 5. DOM에는 source 문자가 실제로 존재한다

true direct editing을 위해 marker는 pseudo-element가 아니라 실제 source Text content여야 한다.

<span data-editable-text="/source">
  <span data-markdown-token="strong-open">**</span>
  <strong data-markdown-owner="strong:...">한글</strong>
  <span data-markdown-token="strong-close">**</span>
</span>

원칙:

  • idle canonical DOM의 logical text는 canonical source와 같음
  • reveal/conceal은 source 문자나 token 순서를 바꾸지 않음
  • 같은 parse shape에서는 innerHTML/replaceChildren로 subtree를 재생성하지 않음
  • compatible Text node와 ancestor identity를 keyed reconciliation으로 유지
  • renderer-owned DOM write를 native input으로 재수집하지 않음
  • CSS generated content는 표시 전용 attachment에는 쓸 수 있지만 편집 가능한 notation에는 쓰지 않음
  • 현재 Range.toString() 기반 offset 계산을 source-aware mapper로 교체
  • hidden range의 arrow/delete/click behavior를 브라우저 우연에 맡기지 않음

결정 6. selection의 active 단위는 logical syntax owner다

초기 정책:

  • caret가 owner content 안에 있으면 opening/closing marker를 균형 있게 함께 reveal
  • nested syntax는 caret를 포함하는 ancestor owner를 deterministic하게 reveal; 첫 tracer는 inner→outer 전체 reveal
  • collapsed caret가 정확히 boundary에 있으면 stored affinity로 owner를 결정
  • non-collapsed selection은 owner overlap/anchor/focus 정책을 fixture로 고정
  • 여러 block selection 때문에 전체 문서를 펼치지 않음
  • pointer drag 중 reveal signature를 freeze하고 pointerup/mouseup 뒤 한 번 reconcile

projection 순서:

1. native DOM selection을 source selection으로 capture
2. active syntax identity signature 계산
3. signature가 바뀐 경우만 projection policy 적용
4. 동일 source selection을 새 projection DOM에 restore
5. 같은 signature로 다시 온 selectionchange는 no-op

이 과정이 source position을 바꾸거나 history를 만들면 실패다.

결정 7. composition island는 visibility까지 freeze한다

권고 프로토콜:

compositionstart
  logical composition island + protected ancestor topology pin
  collapsed single-leaf는 Text identity pin; browser가 같은 ancestor 아래 Text만 교체하면 검증 후 re-pin
  cross-delimiter range는 선택 범위 밖 segment identity/attributes/text를 pin
  현재 composing block의 reveal/conceal signature freeze

compositionupdate / composing input
  browser preedit DOM을 그대로 소유하게 둠
  parser 결과가 달라져도 topology/visibility 변경 금지
  source commit/re-render 보류

compositionend + final input settle
  net DOM text change를 source edit 한 번으로 commit
  syntax index 갱신
  logical source selection 복원
  projection 한 번 적용

후속 Enter/Space
  compositionend에 흡수하지 않고 별도 input intent로 처리

Android처럼 caret placement만으로 composition 상태가 시작되거나 reveal보다 composition이 먼저 잡히면 즉시 시각 reveal보다 입력 보존을 우선하고 settle 뒤 reveal한다.

composition 중 toggle:

  • plugin generation/policy state는 즉시 갱신해 stale work를 무효화
  • composing block DOM 변화는 settle 뒤 수행
  • 다른 block의 proven-disjoint projection만 허용

결정 8. history에는 source edit만 들어간다

  • parse 결과 변경: history 없음
  • selection-driven reveal/conceal: history 없음
  • rich/live-preview toggle: history 없음
  • toolbar formatting: source delimiter/prefix transformation 한 transaction
  • IME preedit: history 없음
  • final composition commit: 기존 composition merge policy에 따른 한 unit

예를 들어 # 입력 뒤 heading이 되는 것은 source # 입력 하나의 결과다. prefix 삭제 + block type patch라는 두 번째 semantic transaction을 만들지 않는다.

결정 9. plugin은 declarative하고 DOM을 모른다

public plugin에 노출하지 않을 것:

  • DOM node, native Selection, Event
  • MutationObserver
  • renderer callback/arbitrary HTML
  • CSS mutation hook
  • composition lease 내부

public capability 방향:

markdownInputRules();        // 기존 입력 shortcut 역할, Live Preview와 별개
markdownLivePreview(options); // source projection policy
bearPreset();                 // 위 policy들의 제품 preset

정확한 이름은 public API review에서 확정하되, 현재 bearMarkdown()이 input rule과 Live Preview 양쪽 의미를 동시에 갖게 두지 않는다. repo가 pre-1.0이라 제거 가능하면 잘못된 alias를 제거하고, 호환이 필요하면 deprecation 기간을 명시한다.

결정 10. clipboard, accessibility, layout은 projection의 일부다

Clipboard

native DOM copy가 hidden marker를 넣거나 빼는 우연에 의존하지 않는다.

  • normal Copy text/plain: 사용자가 보는 semantic text
  • text/html: 지원하는 rich formatting
  • same-editor custom MIME: lossless source selection/fragment
  • Copy as Markdown: exact canonical source slice
  • Cut: source selection을 기준으로 한 editor-owned edit; owner 전체 삭제/빈 delimiter 정리는 fixture로 명시

Accessibility

  • concealed marker가 screen reader에 중복 낭독되지 않음
  • marker를 reveal해 직접 편집할 때는 실제 문자가 접근 가능함
  • raw source가 필요한 사용자를 위한 source mode는 후속으로 열어 둠

Layout

  • inline reveal 때문에 line height가 바뀌지 않음
  • block attachment와 active-line marker가 content를 좌우로 튕기지 않도록 gutter geometry를 예약
  • code fence reveal로 block height가 바뀌는 정책은 기본값으로 채택하지 않음
  • demo는 기존 toolbar/type/color/spacing을 재사용하고 새 선·장식·패널을 최소화

금지할 shortcut/anti-pattern

  • 현재 semantic [data-editable-text]에 합성 marker Text를 끼워 넣고 성공이라 부르기
  • source와 semantic document를 둘 다 writable authority로 유지
  • CSS display:none, font-size:0, zero-width만 적용하고 native selection에 전부 맡기기
  • selectionchange마다 전체 parse + innerHTML 교체
  • composing block 또는 pinned Text node의 ancestor 교체
  • regex 몇 개로 CommonMark 전체를 파싱하려 하기
  • plugin public API에 DOM/MutationObserver를 노출
  • toggle을 document patch 또는 undo item으로 기록
  • pointer drag 중 marker geometry를 계속 바꾸기
  • Playwright에서 synthetic CompositionEvent를 dispatch한 것만으로 실제 IME 완료 선언
  • 전체 CommonMark/GFM를 한 번에 구현하려 하기
  • Bear/Obsidian의 icon, asset, layout, trade dress 복제

단계별 vertical tracer

각 phase는 model/parser/policy/DOM/selection/composition/demo/test를 가로지르는 end-to-end slice다. 아래 syntax 하나가 green이 되기 전 parser 종류만 늘리지 않는다.

Phase 0 — authority와 API 정리

  • source-backed value/selection coordinate를 최소 shape로 도입
  • current semantic @3 migration/recovery 전략 확정
  • syntax analyzer와 reveal policy의 pure seam 정의
  • bearMarkdown() input-rule alias를 정확히 rename/deprecate/remove
  • rich/live-preview toggle이 projection invalidation만 만드는 최소 API 추가

완료 기준:

  • 하나의 canonical source와 source-based selection이 있다.
  • semantic parse 결과는 independently writable field가 아니다.
  • package public surface test가 새로운 역할 구분을 잠근다.
  • toggle은 document revision, publication, patch, history를 만들지 않는다.

Phase 1 — **한글** tracer

이 phase가 전체 설계의 증명이다.

시나리오:

  1. exact source **한글**을 load/store한다.
  2. caret가 다른 owner에 있으면 별표 없이 굵은 한글만 보인다.
  3. caret/selection이 strong owner에 들어가면 양쪽 **가 실제 editable source로 나타난다.
  4. delimiter를 직접 삭제/수정하면 source와 derived formatting이 즉시 일치한다.
  5. incomplete source는 삭제/정규화하지 않고 literal로 남는다.
  6. reveal/conceal 전후 같은 logical source selection을 유지한다.

완료 기준:

  • source가 모든 상태에서 정확히 **한글**이다.
  • opening/content/closing/end의 모든 offset이 DOM↔source round-trip한다.
  • reveal/conceal 전후 compatible Text node identity가 유지된다.
  • ArrowLeft/ArrowRight/Backspace/Delete가 양쪽 경계에서 deterministic하다.
  • click과 Shift+Arrow/pointer drag selection이 Chromium/Firefox/WebKit에서 fixture와 일치한다.
  • toggle 뒤 Undo가 toggle이 아니라 마지막 source edit을 취소한다.
  • Copy as Markdown은 exact **한글**을 얻는다.
  • normal Copy/Cut 정책이 fixture로 고정된다.

Phase 2 — 실제 한국어 IME + delimiter boundary tracer

필수 시나리오:

  • marker concealed 상태에서 content start composition
  • marker revealed 상태에서 content start/middle/end composition
  • opening/closing delimiter 바로 앞·안·뒤 composition
  • range replacement composition이 delimiter 경계를 가로지름
  • composition 중 rich/live-preview toggle
  • composition 중 다른 block remote/programmatic update
  • composition 직후 Space
  • composition 중 Enter
  • composition 직후 Undo/Redo

완료 기준:

  • compositionstart부터 settle까지 editor-owned projection은 active island와 ancestor topology를 임의로 교체하지 않는다. 같은 ancestor 아래의 browser-created Text replacement는 source range 밖 topology/attributes가 그대로일 때만 re-pin하며, cross-delimiter range는 선택 범위 밖 identity를 보존한다.
  • composing block의 visibility/topology가 settle 전에 바뀌지 않는다.
  • 음절 누락/중복, candidate cancel, caret jump가 없다.
  • final source edit가 정확히 한 history unit으로 commit된다.
  • composition 직후 Space가 삼켜지지 않는다.
  • Enter는 조립을 끝내고 paragraph/line break를 정확히 한 번 실행한다.
  • synthetic protocol suite와 별개로 아래 real OS evidence gate를 통과한다.

Phase 3 — heading/quote와 policy preset tracer

지원 subset:

  • # 제목
  • > 인용
  • **strong**, _emphasis_, `code`

검증할 두 정책:

  • source-reveal: active heading/quote에서 literal # , > 표시
  • bear: inline은 active reveal, heading/quote는 attachment/gutter 유지

완료 기준:

  • block prefix와 paired delimiter가 같은 analyzer→policy→projection seam을 사용한다.
  • syntax 종류마다 coordinator에 새 switch/branch를 퍼뜨리지 않는다.
  • block attachment는 source를 대체하는 authority가 아니다.
  • heading/quote line을 오가도 horizontal/vertical layout jump가 허용 범위 안이다.
  • incomplete #, >, _, backtick은 destructive transform 없이 literal fallback한다.
  • toolbar heading/quote/inline formatting은 source transaction 하나다.

Phase 4 — migration, persistence, product completion

  • current representable @3 note를 source-backed schema로 명시적으로 migration
  • migration 전 recovery copy 유지
  • reload 후 exact supported source spelling 보존
  • demo toolbar에 rich/live-preview와 Bear/source-reveal preset 노출
  • current design을 재사용하고 기능 설명을 위한 최소 control만 추가
  • README에서 input rules와 Live Preview 차이 문서화
  • source-deleting old Bear alias/테스트/문서 노이즈 제거

완료 기준:

  • migration 실패가 기존 저장값을 덮어쓰지 않는다.
  • unsupported/crossing mark를 조용히 formatting loss로 낮추지 않는다.
  • reload 뒤 source, semantic appearance, selection-compatible position이 일치한다.
  • plugin on/off로 remount/focus steal/history 변화가 없다.
  • legacy와 source-backed runtime을 영구적으로 두 벌 유지하지 않는다.
  • layer checker와 package smoke/build/browser suite가 통과한다.

selection 상세 정책을 fixture로 확정할 것

공개 제품에서도 정답이 문서화되지 않은 부분이므로 우리 엔진이 명시적으로 정한다.

case 초기 권고
caret가 content 내부 owner의 모든 balanced marker reveal
caret가 opening marker 직전 affinity backward면 outside, forward면 owner
caret가 opening marker 직후 owner
caret가 closing marker 직전 owner
caret가 closing marker 직후 affinity forward면 outside
nested inline owner focus를 포함하는 inner→outer owner 모두 reveal
selection이 owner 일부와 겹침 anchor/focus owner reveal, 전체 문서 확장 금지
multi-block selection primary focus block만 active; 선택 영역 geometry를 흔들지 않음
pointer drag 시작 signature freeze, pointerup 후 reconcile
composition이 reveal보다 먼저 시작 composing block projection freeze, settle 후 reveal

이 표는 코드에 hardcode하는 대신 pure policy fixture로 남긴다.


검증 전략

1. pure/property tests

  • source edit 전후 syntax range mapping
  • source offset ↔ projected DOM point round-trip
  • reveal signature가 source/history를 바꾸지 않음
  • random Unicode text에서 UTF-16 offset 보존
  • surrogate pair, emoji, ZWJ, combining mark, variation selector 경계
  • incomplete/invalid syntax가 source loss를 만들지 않음
  • nested syntax의 balanced marker reveal

2. real browser automation

Chromium/Firefox/WebKit에서 최소 다음을 검증한다.

  • click, arrow, Shift+Arrow, Home/End, Backspace/Delete
  • pointer drag 중 projection freeze
  • copy/cut/paste와 HTML/plain/custom MIME
  • toggle/remount 없음, focus/selection 유지
  • non-IME ordinary input과 Enter
  • composition protocol regression trace

automation은 event/protocol regression을 잡는 도구이지 실제 OS IME acceptance가 아니다.

3. 실제 OS IME evidence gate

docs/manual-ime-acceptance.md와 기존 evidence recorder/manifest를 그대로 확장한다.

최소 release gate:

  • desktop real OS IME trace 1개 이상
  • mobile real OS IME trace 1개 이상

권장 matrix:

환경 필수 입력
macOS Chrome/Safari/Firefox + 한국어 2벌식 delimiter boundary, Space, Enter, Undo
Windows Chrome/Edge + Microsoft 한국어 IME delimiter boundary, candidate commit, Space, Enter
iOS/iPadOS Safari + 한국어 키보드 composition event가 없거나 replacement input인 경로 포함
Android Chrome + Gboard 한국어 suggestion/autocorrect, Backspace, Enter, boundary
후속: Japanese/Chinese IME owner boundary와 candidate commit

각 trace에 기록할 것:

  • OS/device/browser/version/keyboard/layout/locale
  • keydown, beforeinput, input, compositionstart/update/end, selectionchange
  • inputType, data, isComposing, 가능한 경우 getTargetRanges()
  • source before/after
  • native DOM selection과 canonical source selection before/after
  • active/revealed syntax ids
  • pinned Text node/ancestor identity
  • document revision/history grouping/Undo 결과
  • Enter/Space 결과
  • 최종 screenshot 또는 recording

pass condition:

  • 음절 누락/중복 없음
  • preedit가 별도 history entry로 남지 않음
  • source marker가 임의 삭제되지 않음
  • source/model/DOM caret가 같은 logical position
  • toggle/reveal이 composing node를 교체하지 않음
  • Enter가 0회나 2회가 아니라 정확히 1회 실행
  • Undo 한 번의 의미가 fixture와 일치

“직접 쳐봤는데 됨” 또는 synthetic event만으로 완료 체크하지 않는다. evidence artifact가 있어야 한다.


전역 acceptance criteria

Authority / source

  • JSONDocument 안의 lossless Markdown source가 editable content의 단일 authority다.
  • selection과 history가 source coordinate/edit를 사용한다.
  • semantic parse/index는 derived이며 independently writable mirror가 아니다.
  • accepted notation과 incomplete syntax를 formatting 때문에 삭제하지 않는다.
  • unrelated source spelling을 toolbar command가 normalize하지 않는다.

Projection / selection

  • syntax analyzer는 DOM과 무관한 source ranges를 반환한다.
  • policy는 syntax kind별 active-owner | active-line | attachment | concealed | always를 표현한다.
  • reveal/conceal은 document patch, revision, publication, history item이 아니다.
  • DOM↔source mapping이 hidden marker와 nested mark에서도 round-trip한다.
  • pointer drag와 selectionchange가 projection loop/rubber-banding을 만들지 않는다.
  • compatible projection update가 Text node identity를 보존한다.

IME

  • composition island가 source Text node와 ancestor chain을 pin한다.
  • composing block은 settle 전 parse-driven topology/visibility write가 없다.
  • composition 이후 projection은 한 번만 reconcile한다.
  • 후속 Space/Enter를 compositionend의 일부로 삼키지 않는다.
  • real desktop + mobile IME evidence gate를 통과한다.

Product / plugin

  • markdownInputRules, markdownLivePreview, Bear preset의 책임이 분리된다.
  • plugin은 DOM/Event/Selection/MutationObserver를 public으로 받지 않는다.
  • rich와 live-preview가 같은 source/parser/commands를 사용한다.
  • #, > literal active-line reveal과 Bear attachment preset을 둘 다 지원한다.
  • toggle은 remount, focus steal, selection move를 만들지 않는다.
  • demo는 현재 디자인을 활용하고 새 선·장식·패널을 최소화한다.

Clipboard / a11y / layout

  • normal Copy, rich HTML, same-editor fragment, Copy as Markdown 정책이 fixture로 고정된다.
  • Cut이 broken/ghost syntax를 우연히 만들지 않는다.
  • concealed marker가 screen reader에 중복 낭독되지 않는다.
  • inline reveal은 line height를 바꾸지 않는다.
  • block policy 전환이 content를 좌우로 튕기지 않는다.

Quality gates

  • TypeScript, unit, layer, package smoke, production build 검증이 통과한다.
  • Chromium/Firefox/WebKit suite가 통과한다.
  • 실제 OS IME evidence가 없으면 release note/README에서 IME 완료를 주장하지 않는다.
  • 사용하지 않게 된 source-deleting Bear code와 중복 테스트/문서를 제거한다.

비목표

  • 한 이슈에서 full CommonMark/GFM 완성
  • list/task/table/link/image/footnote/math/callout 전체 구현
  • raw source mode 완성
  • arbitrary Markdown 파일의 byte-for-byte import/export 전체 보장
  • CRDT/OT collaboration 또는 collaborative undo
  • third-party arbitrary DOM plugin SDK
  • React가 editable subtree를 소유하게 만들기
  • json-document 교체
  • proprietary 제품의 visual asset/design 복제

초기 tracer의 목표는 좁지만 깊다. **한글**, # 제목, > 인용이 source/selection/DOM/IME/history/clipboard 전 구간에서 완전히 맞는 것이 syntax 30종을 얕게 보이는 것보다 우선이다.


관련 저장소 이슈


Licensing / clean-room 원칙

  • Joplin(AGPL-3.0), Zettlr(GPL-3.0): behavior/interface 아이디어만 참고하고 source를 복사하지 않는다.
  • MarkText/Muya, HyperMD, CodeMirror, Milkdown/ProseMirror(MIT): 의존 또는 코드 차용 시 exact version, license, notice 의무를 별도 검토한다.
  • 제품 reference(Bear/Obsidian/Typora)는 behavior/terminology 참고만 한다.
  • CommonMark spec/fixture를 복사할 때는 해당 spec과 reference implementation license를 확인한다.

Blocked by

None — Phase 0 authority/API 정리와 Phase 1 **한글** tracer부터 바로 시작할 수 있다.

단, source-backed schema와 source coordinate 결정을 건너뛰고 marker DOM부터 만들면 안 된다. 그 방식은 현재 semantic coordinate와 충돌하며 다시 버려질 가능성이 높다.


시각 참고 이미지

사용자 제공 이미지(2026-07-16). 시각 복제 대상이 아니라 interaction reference다. 관찰 포인트는 (1) 평소 rich하게 보이는 본문, (2) active inline owner에서 notation이 나타나는 방식, (3) heading 같은 block syntax를 본문 문자 대신 좁은 gutter/attachment로 나타내는 방식, (4) 기존 타이포그래피와 여백을 유지하며 선·장식을 최소화한 구성이다.

Markdown Live Preview와 block gutter 표현 참고 이미지

Metadata

Metadata

Assignees

No one assigned

    Labels

    DOMDOM 경계와 브라우저 API 조사IMEIME와 composition 동작contenteditableBrowser contenteditable behavior and failuresenhancementNew feature or request레이아웃CSS layout, transform, zoom, geometry 영향선택selection, caret, range, geometry 동작아키텍처모듈 경계, public API, internal 구조클립보드붙여넣기, 복사, 잘라내기, 드롭 동작테스트테스트 하네스, 브라우저 자동화, fixture 한계

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions