newtil-editor — 프레임워크 중립 Markdown ↔ HTML 양방향 편집기 설계 문서
1. 목표
프론트엔드 기술(React/Vue/Angular/Vanilla JS 등)에 영향을 받지 않고 범용적으로 사용 가능한 Markdown ↔ HTML 양방향 편집기를 만든다. newlecture의 프론트엔드 유틸리티 라이브러리 패밀리인 newtil-* 의 일원으로 위치시킨다.
1.1 핵심 요구사항
- 라이브러리 모듈로 배포 (npm 패키지 등) — 다른 프로젝트에서
import또는<script>로 바로 사용 가능 - 플랫폼/프레임워크 중립 — React/Vue/Angular/Vanilla JS 어디서든 동일하게 동작
- 모바일 하이브리드 지원 — Ionic/Capacitor 환경에서도 정상 동작
@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-corp2. 구현 가능성 결론
가능합니다. 핵심은 프레임워크 중립 레이어를 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. 상세 비교표
| 항목 | ProseMirror | Lexical |
|---|---|---|
| 제작 | Marijn Haverbeke (2016~) | Meta/Facebook (2022~) |
| 성숙도 | 매우 안정, 10년 검증 | 비교적 신생, 빠르게 성장 중 |
| API 철학 | 함수형, 불변성, 엄격한 스키마 | 더 명령형, React 친화적 |
| 학습 곡선 | 가파름 | 상대적으로 완만 |
| 번들 크기 | 코어 ~130KB | 코어 ~22KB |
| 프레임워크 중립성 | ✅ 순수 JS, 래퍼 없이 사용 가능 | ⚠️ React 우선 설계 |
| Markdown 지원 | prosemirror-markdown 공식 패키지 | 커뮤니티 @lexical/markdown |
| 협업 편집(CRDT/OT) | 성숙 (Yjs 연동 표준) | 지원되지만 덜 검증됨 |
| 대표 사용처 | Notion, Atlassian, NYT | Facebook, WhatsApp Web |
6. 최종 선택: ProseMirror
둘 중 하나만 선택한다. 각자 자체 문서 모델을 가져서 혼합 불가능.
선택 근거
- React 의존성 없음 — Web Component로 감싸기 쉬움. Lexical은
@lexical/react가 사실상 주력이라 vanilla로 쓰려면 역풍을 맞음 - Markdown 양방향 변환 공식 지원 —
prosemirror-markdown이 파서+직렬화기 모두 제공 - 스키마 엄격성 — 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 등)
예시
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')예시
// 단일 변경
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 — "기능은 어떻게 확장하는가?"
상태에 얹히는 독립 모듈. 키보드 단축키, 히스토리, 플레이스홀더, 협업 등 거의 모든 기능이 플러그인.
플러그인이 할 수 있는 일
- 자체 상태 유지 (예: 히스토리 스택)
- 트랜잭션 가로채기 (예: "## " → heading 자동 변환)
- 키 바인딩 (예:
Ctrl+B→ bold 토글) - DOM 이벤트 처리 (예: 붙여넣기 가공)
- 데코레이션 추가 (예: 맞춤법 밑줄)
예시
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-history | undo/redo |
prosemirror-inputrules | # , **, - 같은 Markdown 단축 입력 |
prosemirror-keymap | Ctrl+B, Ctrl+I 등 단축키 |
| 커스텀 serializer | Markdown ↔ HTML 모드 전환 시 직렬화 |
8.4 양방향 변환 흐름
- 입력: Markdown 문자열 →
defaultMarkdownParser→ PM Document → DOM 렌더링 - 편집 중: 사용자 입력 → Transaction → PM Document 갱신 → DOM 재렌더링
- 출력: PM Document →
defaultMarkdownSerializer→ Markdown 문자열 (또는DOMSerializer→ HTML)
8.5 Web Component API 예시
<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.json9.2 package.json exports 필드
환경별 엔트리를 분리하여 트리쉐이킹 및 타입 추론이 정확하게 동작하도록 한다.
{
"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 / 기타 어디서든
import "@newtil/editor";
// <newtil-editor>가 전역 커스텀 엘리먼트로 등록됨<newtil-editor value="# Hello"></newtil-editor>React 프로젝트
import { NewtilEditor } from "@newtil/editor/react";
function App() {
return <NewtilEditor value="# Hello" onChange={v => console.log(v)} />;
}Vue 프로젝트
<script setup>
import { NewtilEditor } from "@newtil/editor/vue";
</script>
<template>
<NewtilEditor v-model="content" />
</template>CDN 직접 로드
<script src="https://cdn.example.com/@newtil/editor/umd/newtil-editor.umd.js"></script>
<newtil-editor></newtil-editor>9.4 빌드 도구 권장
- Vite library mode 또는 tsup
- ESM / CJS / UMD 동시 출력 지원
- TypeScript 타입 정의 자동 생성
9.5 CSS 격리 전략 (Shadow DOM 사용 여부)
9.5.1 결론: Light DOM + 다층 격리 전략
| 방식 | 장점 | 단점 |
|---|---|---|
| Shadow DOM | 스타일 완전 격리 | ProseMirror의 contenteditable + Selection API가 일부 브라우저에서 불안정, IME 이슈 |
| Light DOM + 스코프 CSS | Selection 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__* 접두어를 부여하여 클래스 충돌을 원천 차단.
.newtil-editor { ... }
.newtil-editor__content { ... }
.newtil-editor__toolbar { ... }
.newtil-editor__toolbar-button { ... }계층 2 — 기본 스타일 명시 재정의 (필수, Step 5에서 강화)
호스트 페이지의 태그 셀렉터 침범을 막기 위해 에디터 내부 블록 요소에 기본값을 명시한다. @tailwindcss/typography 의 prose 클래스와 유사한 접근.
.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+) 에서 우선순위를 체계적으로 관리.
@layer newtil-editor.base {
.newtil-editor__content { ... }
}
@layer newtil-editor.theme {
.newtil-editor { ... }
}호스트가 @layer 를 사용할 경우 우선순위가 예측 가능해진다.
계층 4 — all: revert 격리 모드 (옵션, Step 6 이후)
호스트 CSS 영향을 최대한 차단해야 하는 사용자를 위한 강력한 격리 옵션. 성능 비용이 있어 기본값 아님.
.newtil-editor[data-isolation="strict"] {
all: revert;
}
.newtil-editor[data-isolation="strict"] * {
all: revert;
}
.newtil-editor[data-isolation="strict"] .newtil-editor__content {
/* 우리 스타일 처음부터 다시 */
}<newtil-editor data-isolation="strict"></newtil-editor>계층 5 — Shadow DOM 옵트인 (탈출구, Step 6 이후)
완전한 CSS 격리가 필수인 경우를 위한 마지막 수단. Selection/IME 이슈 가능성을 사용자가 감수한다는 전제.
<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 + Angular | Angular 템플릿에 <newtil-editor> 직접 사용 | ✅ |
| Ionic + React | JSX에 <newtil-editor> 또는 /react 래퍼 사용 | ✅ |
| Ionic + Vue | Vue 템플릿에 <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.visualViewportAPI 활용하여 동적 레이아웃 조정
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-editor는 CSS 독립적으로 동작하되, @newtil/* 패밀리 사용자에게는 매끄러운 통합 테마를 별도로 제공한다. 두 제품의 책임이 명확히 분리된다.
| 원칙 | 설명 |
|---|---|
| 독립 동작 | newtil-editor만 설치해도 기본 스타일로 완전히 동작 |
| 하드 의존성 없음 | package.json의 dependencies에 어떤 @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/* 없이)
import "@newtil/editor";
// 기본 스타일로 동작@newtil/design-tokens 만 사용
import "@newtil/design-tokens"; // 토큰 정의
import "@newtil/editor"; // 에디터 본체
import "@newtil/editor/themes/newtil"; // 토큰을 쓰는 테마@newtil/ui 사용 시 (대표 시나리오)
import "@newtil/ui"; // 토큰 자동 포함
import "@newtil/editor";
import "@newtil/editor/themes/newtil";@newtil/css 사용 시
import "@newtil/css"; // 토큰 자동 포함
import "@newtil/editor";
import "@newtil/editor/themes/newtil";11.5 테마 파일 설계 방침
테마 파일은 @newtil/design-tokens 가 정의한 CSS 변수만 참조하여 newtil-editor 의 스타일 훅을 덮어쓴다.
/* @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 또는 B 는 roadmap.md "Step 이후 추가 기능" 의 검토 대상.
11.8 이 접근의 장점
- 결합도 최소화 —
@newtil/editor는 어떤@newtil/*패키지에도 의존하지 않음. 독립 업데이트 가능 - 번들 최적화 —
@newtil/*미사용자는 테마 CSS를 받지 않음 - 확장성 — 향후
themes/ionic.css,themes/material.css등 추가 가능 - 분리 이익 향유 — 토큰 출처가 단일이라
@newtil/ui사용자,@newtil/css사용자,@newtil/design-tokens단독 사용자 모두 동일한 테마 경험
12. 편집 모델 정책
12.1 핵심 결정: WYSIWYG 전용 + 읽기 전용 Markdown 노출
newtil-editor 는 WYSIWYG 편집만 지원한다. 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.markdown 을 Blob 으로 만들어 .md 파일로 저장 |
| 클립보드 복사 | navigator.clipboard.writeText(editor.markdown) |
| 실시간 미리보기 | change 이벤트 구독 후 editor.markdown 을 다른 DOM에 표시 |
| 디버깅·학습 | 동일 — 읽기 전용 미리보기 |
모두 읽기 전용 API만으로 해결된다. source 편집은 없어도 된다.
12.3 "읽기 전용 Markdown 미리보기" UX
Step 이후 추가 기능으로 도입 예정. 세 가지 수준으로 제공 가능:
수준 1 — API만 제공 (Step 2에서 완료)
사용자가 자신의 UI로 자유롭게 표시:
const editor = document.querySelector("newtil-editor");
editor.addEventListener("change", (e) => {
document.getElementById("preview").textContent = e.detail.markdown;
});프레임워크 중립 철학에 가장 부합. 이 수준이 MVP의 기본 지원 범위.
수준 2 — 내장 분할 뷰 속성 (Step 이후)
<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 유지