목적
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 한 줄 문제가 아니다.
따라서 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 검증 없이는 성공을 주장할 수 없다는 증거다.
오픈소스 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 추가
완료 기준:
Phase 1 — **한글** tracer
이 phase가 전체 설계의 증명이다.
시나리오:
- exact source
**한글**을 load/store한다.
- caret가 다른 owner에 있으면 별표 없이 굵은
한글만 보인다.
- caret/selection이 strong owner에 들어가면 양쪽
**가 실제 editable source로 나타난다.
- delimiter를 직접 삭제/수정하면 source와 derived formatting이 즉시 일치한다.
- incomplete source는 삭제/정규화하지 않고 literal로 남는다.
- reveal/conceal 전후 같은 logical source selection을 유지한다.
완료 기준:
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
완료 기준:
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 유지
완료 기준:
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/테스트/문서 노이즈 제거
완료 기준:
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:
권장 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
Projection / selection
IME
Product / plugin
Clipboard / a11y / layout
Quality gates
비목표
- 한 이슈에서 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) 기존 타이포그래피와 여백을 유지하며 선·장식을 최소화한 구성이다.

목적
contenteditableDOM을 실제 입력 자원으로 계속 사용하면서, Markdown 원문을 잃지 않고 현재 caret/selection이 속한 문법 범위에서만 notation을 드러내는 selection-aware Markdown Live Preview 엔진을 구현한다.이 문서는 조사 기록이자 구현의 정본이다. 이후 구현자가 이 문서만 읽어도 다음을 다시 조사하지 않고 시작할 수 있어야 한다.
bearMarkdown()이 왜 Bear 기능이 아닌지#,>노출의 차이Parent
없음. 이 이슈가 이 기능의 상위 정본이며, 구현 PR 또는 후속 하위 이슈는 이 이슈의 phase를 수직 slice로 가져간다.
한 문장 제품 정의
예:
블록 문법은 정책에 따라 다르게 보일 수 있어야 한다.
마지막 열처럼 Bear 자체는 block marker를 literal source로 항상 되살리는 제품이 아니다. 따라서 엔진은 Bear를 하드코딩하지 않고, 문법 종류별 reveal policy를 받으며 Bear는 그 위의 preset이어야 한다.
먼저 바로잡을 용어
#,>같은 입력을 감지해 다른 구조로 transform제품/업계에서 쓰는 가까운 용어는 다음과 같다.
Hide Markdown, 과거Auto Hide Markdown,hybrid WYSIWYG Markdown editorLive PreviewSeamless Live Preview,WYSIWYMselection-aware syntax reveal/concealment,Markdown projectionpublic naming은 제품명을 빌리기보다
Markdown Live Preview를 정본으로 쓴다.Bear는 preset 또는 비교 설명에서만 사용한다.현재 저장소에서 확인한 사실
조사 기준 commit:
fd9d8601. 현재
bearMarkdown()은 input rule의 별칭이다현재 구현은
#와>를 감지한다.markdownBlockRulesbearMarkdown()과markdownInputRules()가 같은 factory를 호출입력 후
planBlockPrefixTransform이 prefix를 text에서 제거하고 block type을 바꾼다. 따라서 이후 source, selection coordinate, DOM 어디에도 사용자가 입력한#또는>가 남지 않는다.현재 toggle의 의미:
필요한 Live Preview toggle의 의미:
따라서 현재 것은
Markdown input-rule plugin이며, 진짜 Bear/Live Preview 기능과 분리해야 한다.2. 현재
@3모델은 lexical notation을 잃는다현재
EditableDocumentValue는 다음만 저장한다.paragraph | heading | quote | codetextbold | italic | underline | strike | code와 start/end이 정보만으로는 아래 원문 차이를 복구할 수 없다.
*text*와_text_**text**와__text__-,*,+list marker**한,`한등)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를 끼워 넣으면:textContent에 섞임block.text와 달라짐Range.toString()의미가 브라우저의 conceal CSS에 종속즉, 현재 모델을 그대로 두고 DOM에 별표만 삽입하는 방식은 구조적으로 맞지 않는다.
4. composition island는 좋은 출발점이다
현재 projection은 composition 중인 block의 exact
Textnode와 surface를 보호하고, pin이 유지되는 동안 canonical projection을 미룬다.protectedSurface처리composition-island-architecture.mdLive Preview는 이 규칙을 약화하면 안 된다. 오히려 syntax reveal/conceal도 composition lease 안에서는 topology와 presentation을 함께 freeze해야 한다.
제품 조사: 확인된 사실과 추론을 구분한다
Bear에서 확인된 동작
Bear는 closed source이므로 공개 자료로 제품 동작과 철학은 확인할 수 있지만 내부 canonical data structure를 단정할 수는 없다.
**bold**,_italic_,==highlight==activepolicy#attachment>attachmentattachment#tag근거:
중요한 결론:
selection과 layout은 Bear도 계속 고친 문제다
이 기능은 CSS 한 줄 문제가 아니다.
rubber bending issue라고 표현: selection 문제따라서 active unit은 DOM node가 아니라 logical syntax owner + source range여야 하고, 핵심 저수준 계약은 아래 양방향 매핑이다.
Bear의 IME 자료에서 확인되는 것
Bear가 composition 중 marker를 정확히 언제 reveal하는지는 공개 스펙이 없다. 아래는 Bear 내부 알고리즘의 증거가 아니라, real-world IME 검증 없이는 성공을 주장할 수 없다는 증거다.
compositionend + Space가 삼켜진 회귀: 한국어 Space 회귀오픈소스 real-world 해법 조사
비교 요약
Decoration.replace, syntax별 reveal strategyDecoration.replaceinnerHTML전체 교체, zero-width CSS 의존1. Joplin/CodeMirror 6 — 가장 현대적인 정책 reference
Joplin의 Markdown editor는 raw source를
EditorState.doc에 두고 Lezer syntax node에서 replace range를 만든다.line | select | active | boolean: types.ts또한 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를 보이고, 그렇지 않으면 숨긴다.
가져올 인사이트:
가져오지 않을 것:
innerHTML로 subtree 전체 교체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에서 가져올 원리
Decoration.replaceatomicRangescompositionStartedCodeMirror가 주는 핵심 인사이트는 “보이지 않는 source도 document position을 계속 가진다”는 점이다. 다만 CodeMirror의 DOM은 직접 mutation하지 말아야 하는 관리형 view이고, 우리는 contenteditable DOM을 입력 자원으로 사용하므로 동일 코드를 옮기는 것이 아니라 동일 불변식을 우리 DOM mapper와 composition lease로 구현해야 한다.
채택할 설계 결정
결정 1. canonical coordinate는 Markdown source offset이다
JSONDocument는 유지할 수 있다. 다만 역할을 정확히 나눈다.반드시 지킬 불변식:
text/marks를 둘 다 독립적으로 수정 가능한 정본으로 두지 않는다.String, DOMText, Selection boundary와 같은 좌표계다.저장 shape가 단일
source: string이든 lossless source chunk/block이든 엔진 밖에서는 하나의 연속된 source coordinate처럼 보여야 한다. block chunk를 택하면 newline, blank line, indent, fence를 포함한 전체 serialization이 lossless라는 것을 증명해야 한다.결정 2. 세 모델 선택지의 판정
markdownSource양방향 동기화현재
interactive-os.editable-document@3값을 말없이 다른 의미로 해석하지 않는다. source-backed schema/version과 migration은 명시적으로 도입한다. migration 완료 뒤 legacy와 source runtime을 영구히 두 벌 유지하지 않는다.결정 3. low layer는 DOM이 아니라 source range를 입출력한다
parser/analyzer의 최소 개념 계약:
요구 사항:
결정 4. reveal은 pure policy다
syntax 종류별 scope를 지원한다.
개념적 preset:
전통적인 rich mode와 Markdown Live Preview mode는 같은 source와 analyzer를 사용하고 policy만 다르다.
rich: recognized notation을 conceal/attachment로 유지하고 toolbar로 수정live-preview: active owner/line notation을 직접 편집 가능하게 revealsource: 전부 표시하는 별도 모드이며 이번 이슈 범위 아님결정 5. DOM에는 source 문자가 실제로 존재한다
true direct editing을 위해 marker는 pseudo-element가 아니라 실제 source Text content여야 한다.
원칙:
innerHTML/replaceChildren로 subtree를 재생성하지 않음Range.toString()기반 offset 계산을 source-aware mapper로 교체결정 6. selection의 active 단위는 logical syntax owner다
초기 정책:
pointerup/mouseup뒤 한 번 reconcileprojection 순서:
이 과정이 source position을 바꾸거나 history를 만들면 실패다.
결정 7. composition island는 visibility까지 freeze한다
권고 프로토콜:
Android처럼 caret placement만으로 composition 상태가 시작되거나 reveal보다 composition이 먼저 잡히면 즉시 시각 reveal보다 입력 보존을 우선하고 settle 뒤 reveal한다.
composition 중 toggle:
결정 8. history에는 source edit만 들어간다
예를 들어
#입력 뒤 heading이 되는 것은 source#입력 하나의 결과다. prefix 삭제 + block type patch라는 두 번째 semantic transaction을 만들지 않는다.결정 9. plugin은 declarative하고 DOM을 모른다
public plugin에 노출하지 않을 것:
Selection, Eventpublic capability 방향:
정확한 이름은 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를 넣거나 빼는 우연에 의존하지 않는다.
text/plain: 사용자가 보는 semantic texttext/html: 지원하는 rich formattingCopy as Markdown: exact canonical source sliceAccessibility
Layout
금지할 shortcut/anti-pattern
[data-editable-text]에 합성 marker Text를 끼워 넣고 성공이라 부르기display:none,font-size:0, zero-width만 적용하고 native selection에 전부 맡기기selectionchange마다 전체 parse +innerHTML교체CompositionEvent를 dispatch한 것만으로 실제 IME 완료 선언단계별 vertical tracer
각 phase는 model/parser/policy/DOM/selection/composition/demo/test를 가로지르는 end-to-end slice다. 아래 syntax 하나가 green이 되기 전 parser 종류만 늘리지 않는다.
Phase 0 — authority와 API 정리
bearMarkdown()input-rule alias를 정확히 rename/deprecate/remove완료 기준:
Phase 1 —
**한글**tracer이 phase가 전체 설계의 증명이다.
시나리오:
**한글**을 load/store한다.한글만 보인다.**가 실제 editable source로 나타난다.완료 기준:
**한글**이다.Copy as Markdown은 exact**한글**을 얻는다.Phase 2 — 실제 한국어 IME + delimiter boundary tracer
필수 시나리오:
완료 기준:
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 유지완료 기준:
#,>,_, backtick은 destructive transform 없이 literal fallback한다.Phase 4 — migration, persistence, product completion
완료 기준:
selection 상세 정책을 fixture로 확정할 것
공개 제품에서도 정답이 문서화되지 않은 부분이므로 우리 엔진이 명시적으로 정한다.
이 표는 코드에 hardcode하는 대신 pure policy fixture로 남긴다.
검증 전략
1. pure/property tests
2. real browser automation
Chromium/Firefox/WebKit에서 최소 다음을 검증한다.
automation은 event/protocol regression을 잡는 도구이지 실제 OS IME acceptance가 아니다.
3. 실제 OS IME evidence gate
docs/manual-ime-acceptance.md와 기존 evidence recorder/manifest를 그대로 확장한다.최소 release gate:
권장 matrix:
각 trace에 기록할 것:
keydown,beforeinput,input,compositionstart/update/end,selectionchangeinputType,data,isComposing, 가능한 경우getTargetRanges()pass condition:
“직접 쳐봤는데 됨” 또는 synthetic event만으로 완료 체크하지 않는다. evidence artifact가 있어야 한다.
전역 acceptance criteria
Authority / source
JSONDocument안의 lossless Markdown source가 editable content의 단일 authority다.Projection / selection
active-owner | active-line | attachment | concealed | always를 표현한다.IME
Product / plugin
markdownInputRules,markdownLivePreview, Bear preset의 책임이 분리된다.#,>literal active-line reveal과 Bear attachment preset을 둘 다 지원한다.Clipboard / a11y / layout
Quality gates
비목표
json-document교체초기 tracer의 목표는 좁지만 깊다.
**한글**,# 제목,> 인용이 source/selection/DOM/IME/history/clipboard 전 구간에서 완전히 맞는 것이 syntax 30종을 얕게 보이는 것보다 우선이다.관련 저장소 이슈
Licensing / clean-room 원칙
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) 기존 타이포그래피와 여백을 유지하며 선·장식을 최소화한 구성이다.