Skip to content

newtil-editor — 프레임워크 중립 Markdown ↔ HTML 양방향 편집기 설계 문서

1. 목표

프론트엔드 기술(React/Vue/Angular/Vanilla JS 등)에 영향을 받지 않고 범용적으로 사용 가능한 Markdown ↔ HTML 양방향 편집기를 만든다. newlecture의 프론트엔드 유틸리티 라이브러리 패밀리인 newtil-* 의 일원으로 위치시킨다.

1.1 핵심 요구사항

  1. 라이브러리 모듈로 배포 (npm 패키지 등) — 다른 프로젝트에서 import 또는 <script>로 바로 사용 가능
  2. 플랫폼/프레임워크 중립 — React/Vue/Angular/Vanilla JS 어디서든 동일하게 동작
  3. 모바일 하이브리드 지원 — Ionic/Capacitor 환경에서도 정상 동작
  4. @newtil/css와의 선택적 통합 — 독립적으로 동작하되, @newtil/css 사용자에게는 매끄러운 테마 제공

1.2 네이밍 체계

항목
npm 스코프@newtil
패키지명@newtil/editor
Custom Element<newtil-editor>
전역 객체 (UMD)window.NewtilEditor
CSS 클래스 접두.newtil-editor__*
저장소 이름newtil-editor

1.3 제품군 내 위치

@newtil/css        ← 스타일링 유틸리티 (기존 `newtil-css` → 스코프로 이전 예정)
@newtil/editor     ← 본 프로젝트 (Markdown ↔ HTML 에디터)
@newtil/...        ← 향후 확장 예정

GitHub 조직: github.com/newlecture-corp

2. 구현 가능성 결론

가능합니다. 핵심은 프레임워크 중립 레이어를 Web Components(Custom Elements) 또는 순수 TypeScript 클래스 + DOM API로 만들고, 내부 엔진으로 headless 에디터 코어 위에 Markdown ↔ HTML 직렬화 레이어를 얹는 것이다. React/Vue 등은 얇은 래퍼만 제공하면 된다.


3. 기술 선택지 비교

방식장점단점
ProseMirror/Lexical 기반구조화된 문서 모델 덕분에 양방향 변환이 안정적번들 크기 큼 (~100KB+)
CodeMirror + Preview가볍고 단순WYSIWYG 경험이 약함
contenteditable 직접 제어가벼움브라우저별 엣지케이스가 지옥 — 비추천

권장 조합: Web Component + ProseMirror + remark/rehype → <newtil-editor> 태그 하나로 모든 프레임워크에서 동일하게 사용 가능


4. ProseMirror vs Lexical

두 라이브러리 모두 headless 에디터 프레임워크다. UI는 없고 문서 모델과 편집 로직만 제공한다.

ProseMirror

  • 제작: Marijn Haverbeke (CodeMirror 제작자)
  • 사용처: Notion, Atlassian(Confluence/Jira), New York Times, GitLab
  • 특징:
    • 구조화된 트리(Node/Mark)로 문서 표현
    • 모든 변경은 Transaction을 거침 → undo/redo, 협업(OT) 안정적
    • 스키마로 허용 노드/마크를 엄격히 정의

Lexical

  • 제작: Meta (Facebook) — Draft.js 후속작
  • 사용처: Facebook 게시물 편집기, WhatsApp Web
  • 특징:
    • ProseMirror와 유사한 구조화된 모델
    • React 친화적 API
    • 성능 최적화 우수, 플러그인 생태계는 아직 얕음

왜 "엔진"으로 쓰는가?

contenteditable을 직접 다루면:

  • 브라우저마다 Enter/Backspace 동작이 다름
  • 복사-붙여넣기로 더러운 HTML이 들어옴
  • undo 스택이 엉망이 됨

ProseMirror/Lexical은 자체 문서 모델 → DOM 렌더링 구조로 이를 해결한다. 사용자가 타이핑해도 DOM을 직접 수정하지 않고, 모델을 바꾼 뒤 DOM을 다시 그린다. 덕분에 Markdown ↔ 모델 ↔ HTML 양방향 변환이 깔끔해진다.

양방향 편집기에서의 역할

Markdown 문자열
    ↕  (remark 파서)
ProseMirror/Lexical 문서 모델  ← 진실의 원천
    ↕  (렌더러)
HTML DOM (화면에 보이는 것)

5. 상세 비교표

항목ProseMirrorLexical
제작Marijn Haverbeke (2016~)Meta/Facebook (2022~)
성숙도매우 안정, 10년 검증비교적 신생, 빠르게 성장 중
API 철학함수형, 불변성, 엄격한 스키마더 명령형, React 친화적
학습 곡선가파름상대적으로 완만
번들 크기코어 ~130KB코어 ~22KB
프레임워크 중립성✅ 순수 JS, 래퍼 없이 사용 가능⚠️ React 우선 설계
Markdown 지원prosemirror-markdown 공식 패키지커뮤니티 @lexical/markdown
협업 편집(CRDT/OT)성숙 (Yjs 연동 표준)지원되지만 덜 검증됨
대표 사용처Notion, Atlassian, NYTFacebook, WhatsApp Web

6. 최종 선택: ProseMirror

둘 중 하나만 선택한다. 각자 자체 문서 모델을 가져서 혼합 불가능.

선택 근거

  1. React 의존성 없음 — Web Component로 감싸기 쉬움. Lexical은 @lexical/react가 사실상 주력이라 vanilla로 쓰려면 역풍을 맞음
  2. Markdown 양방향 변환 공식 지원prosemirror-markdown이 파서+직렬화기 모두 제공
  3. 스키마 엄격성 — Markdown은 표현 가능한 구조가 제한적이라 엄격한 스키마가 오히려 유리. HTML-only 요소를 즉시 걸러냄

Lexical이 유리한 경우: React 생태계에만 배포하고 번들 크기가 최우선일 때


7. ProseMirror 핵심 개념 3가지

ProseMirror를 이해하려면 "문서는 데이터, 편집은 변환, 기능은 조립" 사고방식에 익숙해져야 한다.

7.1 Schema — "어떤 문서가 유효한가?"

문서 구조의 문법 규칙. HTML의 DTD, DB의 테이블 정의와 유사.

구성 요소

  • Node: 블록 단위 요소 (paragraph, heading, list_item, code_block 등)
  • Mark: 인라인 스타일 (bold, italic, link, code 등)

예시

js
const schema = new Schema({
  nodes: {
    doc: { content: "block+" },
    paragraph: {
      content: "inline*",
      group: "block",
      toDOM: () => ["p", 0]
    },
    heading: {
      attrs: { level: { default: 1 } },
      content: "inline*",
      group: "block",
      toDOM: node => [`h${node.attrs.level}`, 0]
    },
    text: { group: "inline" }
  },
  marks: {
    strong: { toDOM: () => ["strong", 0] },
    em:     { toDOM: () => ["em", 0] }
  }
});

왜 중요한가? 사용자가 <script>를 붙여넣거나 heading 안에 heading을 넣으려 하면 스키마가 자동 거부한다. Markdown 편집기라면 Markdown이 표현 가능한 노드만 허용.

7.2 Transaction — "문서는 어떻게 바뀌는가?"

문서는 불변(immutable). "이 문단의 텍스트를 바꾼다"가 아니라 **"새 문서를 만든다"**가 원칙.

흐름

현재 상태(state) → Transaction(변경 명세) → 새 상태(state')

예시

js
// 단일 변경
const tr = state.tr.insertText("Hello");
const newState = state.apply(tr);
view.updateState(newState);

// 여러 변경 누적
const tr = state.tr
  .delete(5, 10)
  .insertText("World", 5)
  .addMark(5, 10, schema.marks.strong.create());
view.dispatch(tr);

왜 이렇게 하나?

  • Undo/Redo: 트랜잭션 로그 되감기
  • 협업 편집: 트랜잭션을 네트워크로 전송해 재생
  • 디버깅: 모든 변경이 명시적

7.3 Plugin — "기능은 어떻게 확장하는가?"

상태에 얹히는 독립 모듈. 키보드 단축키, 히스토리, 플레이스홀더, 협업 등 거의 모든 기능이 플러그인.

플러그인이 할 수 있는 일

  1. 자체 상태 유지 (예: 히스토리 스택)
  2. 트랜잭션 가로채기 (예: "## " → heading 자동 변환)
  3. 키 바인딩 (예: Ctrl+B → bold 토글)
  4. DOM 이벤트 처리 (예: 붙여넣기 가공)
  5. 데코레이션 추가 (예: 맞춤법 밑줄)

예시

js
import { history, undo, redo } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { inputRules, textblockTypeInputRule } from "prosemirror-inputrules";

const plugins = [
  history(),
  keymap({
    "Mod-z": undo,
    "Mod-y": redo,
    "Mod-b": toggleMark(schema.marks.strong)
  }),
  inputRules({
    rules: [
      textblockTypeInputRule(/^#\s$/, schema.nodes.heading, { level: 1 })
    ]
  })
];

7.4 세 개념의 관계

     Schema (문법)
        ↓ 정의
     Document (현재 트리)
        ↓ 담겨 있음
     State (문서 + 선택영역 + 플러그인 상태)
        ↓ 변환
     Transaction (이 변경을 적용하라)
        ↓ 관여
     Plugins (키 입력, 규칙, 가로채기)
        ↓ 결과
     New State → DOM 다시 그림

8. 이 프로젝트에 적용 방안

8.1 아키텍처

┌─────────────────────────────────────────┐
│   React / Vue / Angular / Vanilla JS    │  ← 소비 측
├─────────────────────────────────────────┤
│   <newtil-editor>  (Web Component)      │  ← 프레임워크 중립 API
├─────────────────────────────────────────┤
│   ProseMirror Core                      │  ← 편집 엔진
│   + prosemirror-markdown                │  ← MD ↔ Model
│   + prosemirror-schema-basic            │
│   + prosemirror-history                 │
│   + prosemirror-inputrules              │
└─────────────────────────────────────────┘

8.2 스키마 방침

Markdown이 지원하는 노드만 정의:

  • Nodes: heading(1-6), paragraph, bullet_list, ordered_list, list_item, code_block, blockquote, horizontal_rule, hard_break, image
  • Marks: strong, em, code, link

prosemirror-markdown이 기본 스키마를 제공하므로 그대로 활용 가능.

8.3 플러그인 구성

플러그인역할
prosemirror-historyundo/redo
prosemirror-inputrules# , **, - 같은 Markdown 단축 입력
prosemirror-keymapCtrl+B, Ctrl+I 등 단축키
커스텀 serializerMarkdown ↔ HTML 모드 전환 시 직렬화

8.4 양방향 변환 흐름

  • 입력: Markdown 문자열 → defaultMarkdownParser → PM Document → DOM 렌더링
  • 편집 중: 사용자 입력 → Transaction → PM Document 갱신 → DOM 재렌더링
  • 출력: PM Document → defaultMarkdownSerializer → Markdown 문자열 (또는 DOMSerializer → HTML)

8.5 Web Component API 예시

html
<newtil-editor
  value="# Hello World"
  toolbar="full">
</newtil-editor>

<script>
  const editor = document.querySelector("newtil-editor");
  editor.addEventListener("change", e => {
    console.log(e.detail.markdown);
    console.log(e.detail.html);
  });
</script>

9. 라이브러리 배포 전략

진정한 "어디서든 쓸 수 있는" 라이브러리가 되려면 여러 모듈 포맷을 동시에 제공해야 한다.

9.1 패키지 구조

@newtil/editor (npm package name)
├── dist/
│   ├── newtil-editor.esm.js       ← 모던 번들러용 (Vite, webpack, Rollup)
│   ├── newtil-editor.cjs.js       ← Node/구형 환경
│   ├── newtil-editor.umd.js       ← <script> 태그 직접 로드용 (CDN)
│   └── newtil-editor.d.ts         ← TypeScript 타입 정의
├── themes/
│   └── newtil-css.css             ← @newtil/css 연동 테마 (선택적)
├── react/                         ← 선택적 React 래퍼
├── vue/                           ← 선택적 Vue 래퍼
└── package.json

9.2 package.json exports 필드

환경별 엔트리를 분리하여 트리쉐이킹 및 타입 추론이 정확하게 동작하도록 한다.

json
{
  "name": "@newtil/editor",
  "type": "module",
  "main": "./dist/newtil-editor.cjs.js",
  "module": "./dist/newtil-editor.esm.js",
  "types": "./dist/newtil-editor.d.ts",
  "exports": {
    ".": {
      "types": "./dist/newtil-editor.d.ts",
      "import": "./dist/newtil-editor.esm.js",
      "require": "./dist/newtil-editor.cjs.js"
    },
    "./react": {
      "types": "./dist/react/index.d.ts",
      "import": "./dist/react/index.js"
    },
    "./vue": {
      "types": "./dist/vue/index.d.ts",
      "import": "./dist/vue/index.js"
    },
    "./themes/newtil-css": "./dist/themes/newtil-css.css"
  }
}

9.3 사용 예시

Vanilla / Ionic / 기타 어디서든

js
import "@newtil/editor";
// <newtil-editor>가 전역 커스텀 엘리먼트로 등록됨
html
<newtil-editor value="# Hello"></newtil-editor>

React 프로젝트

jsx
import { NewtilEditor } from "@newtil/editor/react";

function App() {
  return <NewtilEditor value="# Hello" onChange={v => console.log(v)} />;
}

Vue 프로젝트

vue
<script setup>
import { NewtilEditor } from "@newtil/editor/vue";
</script>

<template>
  <NewtilEditor v-model="content" />
</template>

CDN 직접 로드

html
<script src="https://cdn.example.com/@newtil/editor/umd/newtil-editor.umd.js"></script>
<newtil-editor></newtil-editor>

9.4 빌드 도구 권장

9.5 CSS 격리 전략 (Shadow DOM 사용 여부)

9.5.1 결론: Light DOM + 다층 격리 전략

방식장점단점
Shadow DOM스타일 완전 격리ProseMirror의 contenteditable + Selection API가 일부 브라우저에서 불안정, IME 이슈
Light DOM + 스코프 CSSSelection API 안정적, 디버깅 쉬움, ProseMirror 공식 권장외부 스타일 침범 가능성

Notion, Atlassian, GitLab 등 대형 ProseMirror 사용처들은 모두 Light DOM 방식이다. 모바일 WebView와 IME 호환성을 고려하면 Light DOM이 안전한 선택이며, 우리도 이를 따른다.

9.5.2 Light DOM의 트레이드오프

호스트 페이지 CSS가 에디터 내부에 영향을 미칠 수 있다. 실제 발생 빈도와 심각도는 다음과 같다:

영향 종류빈도예시우리 전략
글자색·배경 상속높음다크 테마에서 글자 안 보임기본값 명시
폰트 상속높음부모 font-family 따라감대부분 OK, 명시적 inherit
전역 reset (* { box-sizing })높음대부분 호환무시
태그 셀렉터 침범 (h1 { color: red })중간에디터 내부 <h1> 영향기본 스타일 명시 재정의
유틸리티 클래스 충돌거의 없음.d:flex.newtil-editor__* 네임스페이스 분리BEM 네이밍으로 자동 방어
!important 남용 사이트낮음해결 불가사용자 책임

9.5.3 다층 완화 전략

다음 5개 계층을 조합하여 Light DOM의 약점을 보완한다.

계층 1 — BEM 네임스페이스 (필수, Step 1부터 적용)

모든 내부 요소에 .newtil-editor__* 접두어를 부여하여 클래스 충돌을 원천 차단.

css
.newtil-editor { ... }
.newtil-editor__content { ... }
.newtil-editor__toolbar { ... }
.newtil-editor__toolbar-button { ... }

계층 2 — 기본 스타일 명시 재정의 (필수, Step 5에서 강화)

호스트 페이지의 태그 셀렉터 침범을 막기 위해 에디터 내부 블록 요소에 기본값을 명시한다. @tailwindcss/typographyprose 클래스와 유사한 접근.

css
.newtil-editor__content h1 {
  font-size: 2em;
  font-weight: 600;
  margin: 0.67em 0;
  color: inherit;
}
.newtil-editor__content ul {
  list-style: disc;
  padding-inline-start: 2em;
}
.newtil-editor__content code {
  font-family: ui-monospace, Menlo, monospace;
  font-size: 0.9em;
}
/* ... 모든 Markdown 지원 요소에 대해 ... */

계층 3 — CSS @layer 래핑 (권장, Step 5)

모던 브라우저(Safari 15.4+, Chrome 99+) 에서 우선순위를 체계적으로 관리.

css
@layer newtil-editor.base {
  .newtil-editor__content { ... }
}
@layer newtil-editor.theme {
  .newtil-editor { ... }
}

호스트가 @layer 를 사용할 경우 우선순위가 예측 가능해진다.

계층 4 — all: revert 격리 모드 (옵션, Step 6 이후)

호스트 CSS 영향을 최대한 차단해야 하는 사용자를 위한 강력한 격리 옵션. 성능 비용이 있어 기본값 아님.

css
.newtil-editor[data-isolation="strict"] {
  all: revert;
}
.newtil-editor[data-isolation="strict"] * {
  all: revert;
}
.newtil-editor[data-isolation="strict"] .newtil-editor__content {
  /* 우리 스타일 처음부터 다시 */
}
html
<newtil-editor data-isolation="strict"></newtil-editor>

계층 5 — Shadow DOM 옵트인 (탈출구, Step 6 이후)

완전한 CSS 격리가 필수인 경우를 위한 마지막 수단. Selection/IME 이슈 가능성을 사용자가 감수한다는 전제.

html
<newtil-editor shadow></newtil-editor>

기본값은 항상 Light DOM, shadow 속성이 있을 때만 Shadow DOM으로 렌더링.

9.5.4 적용 우선순위

계층필수성적용 Step
1. BEM 네임스페이스✅ 필수Step 1 (완료)
2. 기본 스타일 명시 재정의✅ 필수Step 5
3. @layer 래핑🔹 권장Step 5
4. all: revert 격리 모드⚠️ 옵션Step 6 이후
5. Shadow DOM 옵트인⚠️ 옵션Step 6 이후

기본 스택은 1 + 2 + 3. 4와 5는 특수 요구사항이 있을 때 추가.


10. Ionic / 모바일 하이브리드 지원

10.1 결론: ✅ 완벽 지원 가능

Ionic은 Web Components 기반 프레임워크다. <ion-button>, <ion-input> 같은 Ionic 컴포넌트 자체가 Stencil로 빌드된 Web Component다. 즉, 이 프로젝트의 <newtil-editor>는 Ionic과 같은 언어로 말한다.

10.2 Ionic 환경별 동작

환경동작 방식지원
Ionic + AngularAngular 템플릿에 <newtil-editor> 직접 사용
Ionic + ReactJSX에 <newtil-editor> 또는 /react 래퍼 사용
Ionic + VueVue 템플릿에 <newtil-editor> 또는 /vue 래퍼 사용
Ionic Capacitor (iOS/Android 네이티브 빌드)내부는 WebView → 일반 브라우저와 동일
Ionic Cordova (레거시)동일하게 WebView
Ionic PWA일반 브라우저 환경

10.3 모바일 환경 주의사항

Web Component 자체는 문제없지만, ProseMirror + 모바일 WebView 조합에서 다음 사항을 체크해야 한다.

1. 가상 키보드 / IME

  • iOS/Android 가상 키보드는 composition 이벤트를 복잡하게 발생시킴
  • ProseMirror는 IME를 상당히 잘 처리하는 편 (Notion 모바일이 증거)
  • 한글 입력처럼 조합 문자는 반드시 실기기 테스트 필수

2. 터치 선택 (Selection)

  • 모바일은 long-press로 텍스트 선택 — 데스크톱과 동작이 다름
  • ProseMirror는 기본 대응하지만, 툴바 UI는 모바일 전용으로 별도 설계 권장
    • 예: 선택 시 나타나는 floating toolbar (iOS 네이티브 느낌)

3. Viewport / 스크롤

  • 키보드가 올라오면 뷰포트가 줄어듦 → 에디터 높이 계산 주의
  • 필요 시 window.visualViewport API 활용하여 동적 레이아웃 조정

4. Safe Area

  • iOS 노치, 다이나믹 아일랜드 대응은 Ionic/Capacitor 쪽이 처리하므로 에디터 레벨에서는 크게 신경 쓰지 않아도 됨

5. 성능

  • 저사양 안드로이드 WebView에서는 긴 문서 렌더링 시 프레임 저하 가능
  • 가상 스크롤링 또는 지연 렌더링은 필요 시 추후 최적화

10.4 Ionic 지원을 위한 필수 체크리스트

  • [ ] Shadow DOM 미사용 (또는 신중히 사용) — Selection 안정성 확보
  • [ ] composition 이벤트 올바르게 처리 — 한글/중국어/일본어 입력
  • [ ] 터치 이벤트로 bold/italic 토글 가능한 모바일 툴바 제공
  • [ ] visualViewport 대응 — 키보드 올라왔을 때 에디터 영역 스크롤 유지
  • [ ] iOS Safari / Chrome Android 실기기 테스트

11. @newtil/* 패밀리 통합 전략

2026-04-14 업데이트: @newtil/* 패키지가 단일 패키지에서 3-패키지 구조 (@newtil/design-tokens + @newtil/ui + @newtil/css) 로 분리되었다. (NEWTIL-MIGRATION.md 참조). 이에 맞춰 본 섹션은 단일 @newtil/css 통합에서 패밀리 전체 통합 전략으로 확장되었다.

11.1 기본 원칙: 독립 + 선택적 테마

newtil-editorCSS 독립적으로 동작하되, @newtil/* 패밀리 사용자에게는 매끄러운 통합 테마를 별도로 제공한다. 두 제품의 책임이 명확히 분리된다.

원칙설명
독립 동작newtil-editor만 설치해도 기본 스타일로 완전히 동작
하드 의존성 없음package.jsondependencies에 어떤 @newtil/* 패키지도 두지 않음
선택적 테마@newtil/editor/themes/newtil 를 추가 import하면 @newtil/design-tokens 의 변수와 조화
프레임워크 중립 유지테마 파일은 순수 CSS이므로 어떤 환경에서도 사용 가능

11.2 토큰 출처: @newtil/design-tokens

분리 후 구조에서 모든 디자인 토큰의 단일 출처는 @newtil/design-tokens 다. @newtil/ui@newtil/css 는 각자 이 패키지에 의존하여 토큰을 참조한다.

따라서 newtil-editor 의 테마 파일도 @newtil/design-tokens 의 변수만 참조한다. 이렇게 하면:

사용자 시나리오테마 동작
@newtil/design-tokens 만 사용✅ 테마 정상 적용 (가장 가벼움)
@newtil/ui 사용✅ 자동으로 @newtil/design-tokens 포함 → 테마 적용
@newtil/css 사용✅ 자동으로 @newtil/design-tokens 포함 → 테마 적용
@newtil/* 미사용기본 내장 스타일로 동작

11.3 아키텍처

┌─────────────────────────────────────────────┐
│  @newtil/editor (기본 스타일 내장, 독립 동작)  │
├─────────────────────────────────────────────┤
│  @newtil/editor/themes/newtil (선택적)        │
│    ↓ @newtil/design-tokens 의 변수만 참조      │
│    ↓ .newtil-editor__* 를 덮어씀               │
└─────────────────────────────────────────────┘
              ↓ 참조만 (의존성 X)
┌─────────────────────────────────────────────┐
│  @newtil/design-tokens (사용자가 직접 또는    │
│  @newtil/ui · @newtil/css 통해 간접 설치)    │
│    - 디자인 토큰 (--newtil-color-* 등)       │
└─────────────────────────────────────────────┘

11.4 사용 방법

기본 (@newtil/* 없이)

js
import "@newtil/editor";
// 기본 스타일로 동작

@newtil/design-tokens 만 사용

js
import "@newtil/design-tokens";               // 토큰 정의
import "@newtil/editor";                      // 에디터 본체
import "@newtil/editor/themes/newtil";        // 토큰을 쓰는 테마

@newtil/ui 사용 시 (대표 시나리오)

js
import "@newtil/ui";                          // 토큰 자동 포함
import "@newtil/editor";
import "@newtil/editor/themes/newtil";

@newtil/css 사용 시

js
import "@newtil/css";                         // 토큰 자동 포함
import "@newtil/editor";
import "@newtil/editor/themes/newtil";

11.5 테마 파일 설계 방침

테마 파일은 @newtil/design-tokens 가 정의한 CSS 변수만 참조하여 newtil-editor 의 스타일 훅을 덮어쓴다.

css
/* @newtil/editor/themes/newtil.css 예시 */
.newtil-editor {
  font-family: var(--newtil-font-body);
  color: var(--newtil-color-text);
  background: var(--newtil-color-surface);
}
.newtil-editor__toolbar {
  background: var(--newtil-color-surface-muted);
  border-bottom: 1px solid var(--newtil-color-border);
}
.newtil-editor__toolbar-button {
  color: var(--newtil-color-text);
}
.newtil-editor__toolbar-button[aria-pressed="true"] {
  background: var(--newtil-color-primary-soft);
  color: var(--newtil-color-primary);
}

변수 명명 규칙은 @newtil/design-tokens 가 npm publish된 후 확정된다. Step 5 진입 시점에 실제 변수명에 맞춰 테마를 작성한다.

11.6 스타일 훅(Style Hook) 설계

테마 적용이 용이하도록 에디터 내부 모든 요소에 BEM 스타일 클래스 네임을 부여한다.

요소클래스
루트.newtil-editor
툴바.newtil-editor__toolbar
툴바 버튼.newtil-editor__toolbar-button
편집 영역.newtil-editor__content
상태바 (선택).newtil-editor__statusbar
플레이스홀더.newtil-editor__placeholder

이 규약 덕분에 @newtil/* 사용자뿐 아니라 누구나 쉽게 커스텀 테마를 작성할 수 있다.

11.7 (선택) @newtil/ui 컴포넌트 통합 옵션

@newtil/ui 가 버튼/아이콘/다이얼로그 등 컴포넌트를 제공하므로, 에디터의 툴바를 @newtil/ui 컴포넌트로 구성하는 통합 옵션을 검토할 수 있다.

접근장점단점
A. 빌드 옵션 (옵트인)단일 패키지 유지tree-shaking 복잡성
B. 별도 패키지 (@newtil/editor-ui-toolbar ?)명확한 책임 분리사용자가 추가 설치 필요
C. 미통합 (기본 자체 toolbar 유지)의존성 0@newtil/ui 사용자 입장에서 일관성 약함

기본은 C (자체 toolbar). A 또는 Broadmap.md "Step 이후 추가 기능" 의 검토 대상.

11.8 이 접근의 장점

  1. 결합도 최소화@newtil/editor 는 어떤 @newtil/* 패키지에도 의존하지 않음. 독립 업데이트 가능
  2. 번들 최적화@newtil/* 미사용자는 테마 CSS를 받지 않음
  3. 확장성 — 향후 themes/ionic.css, themes/material.css 등 추가 가능
  4. 분리 이익 향유 — 토큰 출처가 단일이라 @newtil/ui 사용자, @newtil/css 사용자, @newtil/design-tokens 단독 사용자 모두 동일한 테마 경험

12. 편집 모델 정책

12.1 핵심 결정: WYSIWYG 전용 + 읽기 전용 Markdown 노출

newtil-editorWYSIWYG 편집만 지원한다. Markdown 원문은 읽기 전용으로 꺼내 쓸 수 있지만, 사용자가 source 모드로 전환해서 Markdown 문법을 직접 편집하는 기능은 의도적으로 제공하지 않는다.

항목지원 여부
WYSIWYG 편집 (ProseMirror)✅ 기본이자 유일한 편집 방식
editor.markdown getter✅ 현재 상태를 Markdown 문자열로 꺼내기
editor.html getter✅ 현재 상태를 HTML 문자열로 꺼내기
HTML 붙여넣기 자동 정제✅ ProseMirror paste 핸들러 + 스키마 필터링
<newtil-editor preview="markdown"> — 읽기 전용 미리보기 패널⏳ 선택 구현 (Step 이후)
mode="source" — Markdown 원문 편집 토글❌ 미지원
Split 뷰 (Typora 스타일)❌ 미지원

12.2 왜 source 편집을 배제하는가

(1) Round-trip 정규화는 피할 수 없는 문제

prosemirror-markdown 은 문법이 아닌 의미(semantic) 만 저장하므로, Markdown → PM Document → Markdown 왕복에서 문자 단위로는 다른 결과가 나온다:

원본왕복 후
_italic_*italic*
__bold__**bold**
Setext 제목 (===, ---)ATX 제목 (#, ##)
- 항목 / + 항목* 항목 으로 통일
참조 링크 [text][1] + 하단 정의인라인 [text](url)
연속 빈 줄한 줄로 축약

이는 Markdown 에디터 업계의 공통 특성이며 (GitHub, GitLab, Discourse, StackEdit 모두 동일), 버그가 아니라 정규화 정책이다. 하지만 사용자가 source 모드에서 자신의 원본을 편집한 뒤 WYSIWYG로 갔다가 돌아오면 의도치 않게 스타일이 통일되어 놀랄 수 있다.

(2) 양방향 동기화는 복잡도 대비 가치가 낮음

  • source 모드를 제공하려면 CodeMirror(또는 <textarea>) 통합이 필요
  • 두 모드 간 일관성 유지 로직이 복잡 (dirty flag, 원본 보존, 충돌 처리)
  • 실사용 시나리오의 대부분은 "편집은 WYSIWYG로 하고, 저장·전송은 Markdown 문자열로" 이므로 source 편집 없이도 커버됨

(3) 읽기 전용 Markdown 노출만으로 충분

Markdown 문자열이 필요한 시나리오는 다음과 같다:

시나리오해결 방법
백엔드에 저장editor.markdown 으로 값을 읽어 fetch/axios로 전송
파일 다운로드editor.markdownBlob 으로 만들어 .md 파일로 저장
클립보드 복사navigator.clipboard.writeText(editor.markdown)
실시간 미리보기change 이벤트 구독 후 editor.markdown 을 다른 DOM에 표시
디버깅·학습동일 — 읽기 전용 미리보기

모두 읽기 전용 API만으로 해결된다. source 편집은 없어도 된다.

12.3 "읽기 전용 Markdown 미리보기" UX

Step 이후 추가 기능으로 도입 예정. 세 가지 수준으로 제공 가능:

수준 1 — API만 제공 (Step 2에서 완료)

사용자가 자신의 UI로 자유롭게 표시:

js
const editor = document.querySelector("newtil-editor");
editor.addEventListener("change", (e) => {
  document.getElementById("preview").textContent = e.detail.markdown;
});

프레임워크 중립 철학에 가장 부합. 이 수준이 MVP의 기본 지원 범위.

수준 2 — 내장 분할 뷰 속성 (Step 이후)

html
<newtil-editor preview="markdown"></newtil-editor>

우측에 읽기 전용 Markdown 패널이 자동 분할 표시. 복사 버튼 포함.

수준 3 — 모달/드로어 형태 (필요 시)

툴바 버튼으로 토글하는 별도 뷰. 모바일에서 유용.

12.4 이 결정이 나중에 바뀔 가능성

  • 사용자 요구가 압도적이거나 Obsidian 스타일 커뮤니티가 형성되면 재검토
  • 재검토 시 선택지: (a) 별도 패키지 @newtil/editor-source-mode (b) mode 속성 부활
  • 현재는 WYSIWYG 집중으로 품질 끌어올리는 게 우선

13. 다음 단계

  • [ ] 스키마 상세 설계 (어떤 Markdown 요소를 지원할지 확정)
  • [ ] Markdown ↔ HTML 변환 파이프라인 구현
  • [ ] 툴바/단축키 사양 정의
  • [ ] 프레임워크별 래퍼 (React/Vue) 설계
  • [ ] 번들링 전략 (ESM, CJS)
  • [ ] @newtil/design-tokens 테마 파일 작성 및 배포 경로 확정

14. 정리

  • 라이브러리 이름: newtil-editor (npm: @newtil/editor, 태그: <newtil-editor>)
  • 제품군 위치: @newtil/* 패밀리의 4번째 패키지 (@newtil/design-tokens, @newtil/ui, @newtil/css 와 동일 네임스페이스)
  • 가능 여부: ✅ 가능
  • 엔진: ProseMirror 단독
  • 중립화 수단: Web Component(Custom Element)로 감싸기
  • 변환 파이프라인: Markdown ↔ ProseMirror Document ↔ HTML
  • 핵심 이해 개념: Schema, Transaction, Plugin
  • 편집 모델: WYSIWYG 전용 + 읽기 전용 Markdown 노출 (mode="source" 미지원, §12 참조)
  • 배포 형태: ESM / CJS / UMD 멀티 포맷 npm 패키지 + 선택적 React/Vue 래퍼
  • Ionic/Capacitor 지원: ✅ 동일 Web Component 기반이라 네이티브 호환
  • Light DOM 기본값 + 5계층 CSS 격리 전략 (§9.5)
  • @newtil/* 패밀리 통합: 독립 동작 + 선택적 테마 파일 (@newtil/design-tokens 의 변수만 참조)
  • @newtil/ui 컴포넌트 통합: 검토 항목, 기본은 자체 toolbar 유지