불변 JSON AST 문서와 트랜잭션 기반 업데이트를 헤드리스 코어(createEditor)에서 처리하는 멀티 프레임워크 리치 텍스트 에디터입니다. React, Vue 3, Vue 2.7, Svelte 5, 순수 JS CDN을 지원하며 서식·표·코드 블록·커스텀 노드 뷰를 Extension으로 확장합니다.
불변 JSON AST 문서와 트랜잭션 기반 업데이트로 만드는, 프레임워크 비의존 헤드리스 리치 텍스트 에디터.
moda-editor는 문서 모델·트랜잭션·셀렉션·입력 바인딩 같은 모든 에디터 로직을 순수 TypeScript 코어(createEditor)에 두고, 각 프레임워크 어댑터는 얇은 반응형 바인딩만 담당합니다. 5개 렌더러가 같은 DOM 계약(.me-*)을 만들어 어디서든 동작이 같습니다.
스타터 번들 하나로 서식·제목·인용구·목록·할일·표·코드 블록·링크· 이미지·정렬·히스토리까지 갖추고, Extension/Plugin/InputRule로 무한 확장할 수 있습니다.
CDN에서 불러온 실제 코어로 동작합니다. 툴바의 각 버튼은editor.commands.*호출 하나와 1:1로 대응되며(툴팁에 API 이름 표시), 편집할 때마다 아래 패널에 HTML 직렬화와 문서 JSON이 실시간으로 출력됩니다. 하단에는editor.on()으로 구독한 이벤트가 흘러가는 것을 볼 수 있습니다.
| 사용 환경 | 패키지 | 비고 |
|---|---|---|
| React | @moda-editor/react | useEditor + EditorView, 커스텀 노드 뷰는 React 컴포넌트 |
| Vue 3 | @moda-editor/vue | useEditor + EditorView SFC, useEditorState로 외부 에디터 구독 |
| Vue 2.7 | @moda-editor/vue2 | Vue 3과 동일한 Composition API 사용법 |
| Svelte 5 | @moda-editor/svelte | createEditorStore + EditorView, readable 스토어 연동 |
| 순수 JS / CDN | moda-editor.js | 빌드 도구 없이 <script> 한 줄, window.ModaEditor |
| 프레임워크 없음 | @moda-editor/core | 헤드리스 코어 createEditor + mountEditor 직접 사용 |
npm i @moda-editor/react # Reactnpm i @moda-editor/vue # Vue 3npm i @moda-editor/vue2 # Vue 2.7npm i @moda-editor/svelte # Svelte 5npm i @moda-editor/core # 헤드리스 코어만https://editor.modaolive.com/moda-editor.jshttps://editor.modaolive.com/style.cssnpm i @moda-editor/react처럼 어댑터를 설치하거나, CDN <script> 한 줄로 코어를 로드합니다.
starterExtensions() 하나면 스키마·커맨드·키맵·입력 규칙·히스토리가 다 들어갑니다. doc으로 초기 문서 JSON을 넘길 수 있습니다.
어댑터는 <EditorView> 하나면 끝. 툴바는 editor.commands.*에 연결하고, vanilla는 mountEditor(el, options)로 배선합니다.
// npm i @moda-editor/react
import { EditorView, starterExtensions, useEditor } from "@moda-editor/react";
import "@moda-editor/react/styles.css";
function App() {
const { editor, state } = useEditor({
doc: myDoc, // DocNode JSON (없으면 빈 문서)
extensions: starterExtensions(), // 스타터 스키마·커맨드·키맵·히스토리
});
return (
<>
<button onClick={() => editor.commands.toggleBold()}>B</button>
<button onClick={() => editor.commands.setHeading(2)}>H2</button>
<EditorView editor={editor} />
</>
);
}
// state는 불변 EditorState — === 비교로 렌더 필요 여부 결정EditorState는 불변 — 변경 전까지 같은 참조를 반환하므로 React useSyncExternalStore의 스냅샷 계약이 구조적으로 보장됩니다.
모든 기능은 코어의 커맨드·옵션으로 동작하고 어댑터는 전달만 합니다. 라이브 데모의 툴바 버튼에 마우스를 올리면 대응 API가 툴팁으로 표시됩니다.
| 기능 | 켜는 방법 (옵션/prop/입력) | 코어 API |
|---|---|---|
| 굵게·기울임·밑줄 | 툴바 버튼 / Mod-B·I·U | commands.toggleBold() / toggleItalic() / toggleUnderline() |
| 취소선 | 툴바 버튼 | commands.toggleStrike() |
| 위·아래 첨자 | — | commands.toggleSuperscript() / toggleSubscript() |
| 인라인 코드 | 툴바 버튼 | commands.toggleCode() |
| 마크 속성 변경 | — | commands.setMarkAttr("link", { href }) |
| 서식 초기화 | — | clearFormat (starter 헬퍼) |
| 기능 | 켜는 방법 (옵션/prop/입력) | 코어 API |
|---|---|---|
| 제목 | 툴바 버튼 | commands.setHeading(1~6) |
| 인용구 | 툴바 버튼 | commands.toggleQuote() |
| 코드 블록 | toggleCodeBlock — 언어 선택 포함 | commands.setCodeLanguage(lang) / highlightCode |
| 수평선·하드 브레이크 | — | commands.insertHorizontalRule() / insertHardBreak() |
| 정렬 | 정렬 feature 등록 시 | commands.setAlign("left"|"center"|"right") |
| 블록 분할·탈출 | Enter / Mod-Enter | splitBlock / exitBlock 커맨드 |
| 기능 | 켜는 방법 (옵션/prop/입력) | 코어 API |
|---|---|---|
| 글머리·번호 목록 | 툴바 버튼 | commands.toggleBulletList() / toggleOrderedList() |
| 할일 목록 | 툴바 버튼 | commands.toggleTaskList() / toggleTaskItem() |
| 들여쓰기·내어쓰기 | Tab / Shift-Tab | commands.sinkListItem() / liftListItem() |
| 표 삽입·행·열 | — | commands.insertTable(r,c) / addTableRow / addTableColumn / deleteTable |
| 셀 이동 | 표 안에서 Tab | nextCell / prevCell |
| 기능 | 켜는 방법 (옵션/prop/입력) | 코어 API |
|---|---|---|
| 링크 | 툴바 / [텍스트](url) 입력 규칙 | commands.setLink(href) / unsetLink() |
| 이미지 | URL 삽입 / 붙여넣기·드롭 | commands.insertImage(src, alt) / updateImage / deleteImage |
| 이미지 업로드 훅 | onImageUpload 옵션 — File → URL 비동기 변환 | 생략 시 FileReader data URL로 즉시 삽입 |
| HTML 붙여넣기 정제 | 자동 — script·on*·javascript: 제거 | parseHTML + sanitizeHTML 내장 파이프 |
| paste 이벤트 | — | editor.on("paste", ({html, text}) => …) |
| 기능 | 켜는 방법 (옵션/prop/입력) | 코어 API |
|---|---|---|
| 불변 상태 | — | editor.state — 변경 전까지 같은 참조 |
| 렌더 구독 | 어댑터가 자동 연결 | editor.subscribe(fn) / getSnapshot() |
| 의미 이벤트 | — | editor.on("update"|"selectionUpdate"|"transaction"|"focus"|"blur"|"destroy") |
| 실행 취소·다시 실행 | starterExtensions에 history 포함, Mod-Z / Mod-Shift-Z | commands.undo() / redo(), canUndo / canRedo |
| 읽기 전용 | EditorOptions.editable: false | 뷰가 contenteditable을 끄는 신호로 사용 |
| 수동 디스패치 | — | editor.dispatch(tr) — 모든 변경의 단일 진입점 |
| 기능 | 켜는 방법 (옵션/prop/입력) | 코어 API |
|---|---|---|
| JSON 문서 모델 | doc: DocNode 옵션 | createDoc(content) / createText(text, marks) |
| HTML 직렬화 | — | serializeToHTML(doc, schema) |
| HTML → 문서 | — | parseHTML(html, schema) → createDoc(nodes) |
| 커스텀 노드 뷰 | EditorView의 nodeViews prop | { codeBlock: MyComponent } — NodeViewProps |
| 확장 시스템 | extensions 배열 | Extension = nodes + marks + commands + keymap + inputRules + plugins |
| 스타터 번들 | extensions: starterExtensions() | 스키마·커맨드·키맵·입력 규칙·히스토리 일괄 |
| 테마 | styles.css의 --me-* CSS 변수 | .me-* DOM 클래스 계약으로 커스텀 |
복사해서 바로 쓸 수 있는 코드 조각입니다.
editor.commands는 확장에서 수집된 커맨드 레지스트리 — 버튼 onClick에 바로 연결
<button onClick={() => editor.commands.toggleBold()}>B</button>
<button onClick={() => editor.commands.setHeading(2)}>H2</button>
<button onClick={() => editor.commands.toggleTaskList()}>할일</button>
<button onClick={() => editor.commands.undo()}>↶</button>
// false 반환 = 현재 셀렉션에서 적용 불가 → 버튼 비활성화 신호로 사용문서·셀렉션이 바뀔 때마다 발행 — 디바운스해서 서버로 전송
editor.on("update", ({ state, prevState }) => {
if (state.doc === prevState.doc) return; // 셀렉션만 바뀐 경우 스킵
scheduleSave(serializeToHTML(state.doc, editor.schema));
});
// 또는 문서 JSON을 그대로 저장
const payload = JSON.stringify(state.doc);기존 HTML 콘텐츠를 파싱해 초기 문서로 — sanitizer가 내장되어 위험 태그 제거
import { createEditor, createDoc, parseHTML, starterExtensions }
from "@moda-editor/core";
const extensions = starterExtensions();
const boot = createEditor({ extensions });
const doc = createDoc(parseHTML(savedHtml, boot.schema));
const editor = createEditor({ extensions, doc });붙여넣기·드롭·파일 선택으로 들어온 File을 서버 업로드 URL로 교체
useEditor({
extensions: starterExtensions(),
onImageUpload: async (file) => {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: form });
return (await res.json()).url; // 에디터가 src로 삽입
},
});특정 노드 타입의 렌더링을 내 컴포넌트로 교체 — content에 편집 아울렛 렌더
function CodeBlockView({ node, editor, path, content }: NodeViewProps) {
return (
<div className="my-codeblock">
<select onChange={(e) =>
editor.commands.setCodeLanguageAt(path, e.target.value)} />
{content} {/* 편집 영역 — 반드시 렌더해야 함 */}
</div>
);
}
<EditorView editor={editor} nodeViews={{ codeBlock: CodeBlockView }} />transaction은 필터 전 모든 tr을 발행 — 디버깅·동기화의 최하단 훅
editor.on("transaction", ({ tr, state }) => console.log(tr.steps));
editor.on("paste", ({ html, text }) => console.log("붙여넣기:", text));
editor.on("blur", () => saveDraft());
editor.on("destroy", () => cleanup());
// 외부에서 만든 에디터를 뷰에 공유
const editor = createEditor({ extensions: starterExtensions() });
<EditorView editor={editor} /> // 구독은 뷰가 처리React·Vue·Svelte·Vanilla 렌더러는 모두 같은 DOM 계약(.me-*클래스)을 만들고, 공용 컨포먼스 스펙(runWidgetConformance)으로 검증됩니다. 한 플랫폼에서 익힌 API와 CSS 변수(--me-*)가 다른 플랫폼에서도 그대로 통합니다.