@aeriz/wysiwyg
@aeriz/wysiwyg
한국어 친화 contentEditable WYSIWYG 에디터.
자세한 동작 사양은 SPEC.md 를 참고하세요.
지원 환경
| 대상 | 진입점 | 방식 |
|---|---|---|
| Vanilla JS | @aeriz/wysiwyg |
클래스 직접 사용 |
| Web Component | @aeriz/wysiwyg/wc |
<aeriz-editor> 커스텀 엘리먼트 |
| jQuery | @aeriz/wysiwyg/jquery |
$.fn.aerizEditor 플러그인 |
| React 16.8+ / 17 / 18 / 19 | @aeriz/wysiwyg/react |
<AerizEditor /> 컴포넌트 |
| Vue 3 | @aeriz/wysiwyg/vue |
v-model 컴포넌트 |
| Vue 2.6 / 2.7 | @aeriz/wysiwyg/vue2 |
v-model 컴포넌트 |
| Svelte 3 / 4 / 5 | @aeriz/wysiwyg/svelte |
use:aerizEditor 액션 |
| Angular 14+ | @aeriz/wysiwyg/angular |
[aerizEditor] 디렉티브 (ngModel 지원) |
| Node / Deno / Bun / Cloudflare Workers | @aeriz/wysiwyg/server |
DOM 없는 렌더러 · 변환기 |
빌드 도구가 없는 페이지도 지원합니다 — 코어뿐 아니라 어댑터도 스크립트 태그로 로드할 수 있습니다 (CDN 절 참고).
프레임워크 패키지는 모두 optional peerDependency 입니다. React 프로젝트에 Vue 가 설치될 일은 없습니다.
설치
npm install @aeriz/wysiwyg
# yarn add @aeriz/wysiwyg
# pnpm add @aeriz/wysiwyg
스타일시트는 어느 프레임워크를 쓰든 한 번 로드해야 합니다.
import '@aeriz/wysiwyg/style.css';
Vanilla JS
번들러 사용
import { AerizEditor, AerizViewer } from '@aeriz/wysiwyg';
import '@aeriz/wysiwyg/style.css';
const editor = new AerizEditor({
element: document.querySelector('#editor')!,
initialHTML: '<p>안녕하세요</p>',
placeholder: '내용을 입력하세요',
theme: 'dark',
onChange: (html) => localStorage.setItem('draft', html),
});
const viewer = new AerizViewer({
element: document.querySelector('#preview')!,
});
editor.onStateChange(() => viewer.setBlocks(editor.serialize()));
CDN — 스크립트 태그
빌드 단계 없이 바로 사용. 라이브러리는 글로벌 window.AerizWysiwyg 와 단축
별칭 window.AerizEditor / window.AerizViewer 로 노출됩니다.
<!-- ✓ 프로덕션 — 특정 버전 고정 (immutable, 가장 긴 캐시) -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.css"
/>
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.js"></script>
<!-- 1.x 트랙 — 패치만 자동 수신 -->
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@^1.0.5/dist/aeriz-wysiwyg.min.js"></script>
<!-- 항상 최신 — 프로토타이핑용 (프로덕션 권장 X) -->
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@latest/dist/aeriz-wysiwyg.min.js"></script>
<!-- unpkg 미러 -->
<script src="https://unpkg.com/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.js"></script>
<div id="editor"></div>
<script>
const editor = new AerizEditor({
element: document.querySelector('#editor'),
initialHTML: '<p>안녕하세요</p>',
onChange: (html) => console.log(html),
});
</script>
CDN — ES Module
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.css"
/>
<script type="module">
import {
AerizEditor,
AerizViewer,
} from 'https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.esm.min.js';
new AerizEditor({ element: document.querySelector('#editor') });
</script>
CDN — 어댑터도 스크립트 태그로
dist/adapters/*.js 는 ESM 이라 <script type="module"> 로 CDN 에서 바로
부르면 bare import 를 해석하지 못합니다. 그래서 프레임워크 의존이 없는
어댑터는 IIFE 로도 배포합니다. 코어를 번들에 넣지 않고 전역
AerizWysiwyg 를 참조하므로, 코어를 먼저 부르기만 하면 됩니다 (코어가 두 벌
로드되지 않습니다).
| 파일 | 노출 전역 | 역할 |
|---|---|---|
dist/aeriz-wysiwyg-wc.min.js |
AerizWysiwygWC |
<aeriz-editor> 자동 등록 |
dist/aeriz-wysiwyg-jquery.min.js |
AerizWysiwygJQuery |
$.fn.aerizEditor 자동 등록 |
dist/aeriz-wysiwyg-adapter.min.js |
AerizWysiwygAdapter |
createEditorController 등 |
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.css"
/>
<!-- 1) 코어 먼저 -->
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.js"></script>
<!-- 2) 필요한 어댑터 -->
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg-wc.min.js"></script>
<aeriz-editor theme="dark" placeholder="내용을 입력하세요"></aeriz-editor>
jQuery 는 jQuery 자체를 먼저 로드해야 자동 등록됩니다.
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg-jquery.min.js"></script>
<script>
$('#editor').aerizEditor({ value: '<p>안녕하세요</p>' });
</script>
React · Vue 를 CDN 으로 쓸 때 — import map
React / Vue 어댑터는 IIFE 로 제공하지 않습니다. 소비자 앱의 프레임워크 인스턴스를 그대로 써야 하는데 전역 하나로 묶으면 인스턴스가 갈라져 훅이 깨지기 때문입니다. CDN 에서는 import map 이 정답입니다.
<script type="importmap">
{
"imports": {
"vue": "https://esm.sh/vue@3",
"@aeriz/wysiwyg": "https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.esm.min.js",
"@aeriz/wysiwyg/vue": "https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/adapters/vue.js"
}
}
</script>
<script type="module">
import { createApp, h, ref } from 'vue';
import { AerizEditor } from '@aeriz/wysiwyg/vue';
const html = ref('<p>안녕하세요</p>');
createApp({
render: () =>
h(AerizEditor, {
modelValue: html.value,
'onUpdate:modelValue': (v) => (html.value = v),
}),
}).mount('#app');
</script>
@aeriz/wysiwyg항목이 반드시 있어야 합니다 — 어댑터가 그 이름으로 코어를 가져오기 때문입니다. 빠뜨리면 브라우저가 bare specifier 해석에 실패합니다.
Web Component
프레임워크 의존이 전혀 없는 최소 공통 분모입니다. Shadow DOM 을 쓰지 않으므로 전역 스타일시트가 그대로 적용됩니다.
<link rel="stylesheet" href="/aeriz-wysiwyg.min.css" />
<script type="module">
import '@aeriz/wysiwyg/wc'; // import 만으로 자동 등록
</script>
<form action="/posts" method="post">
<!-- name 을 주면 폼 전송에 본문 HTML 이 함께 실립니다 (폼 연동 지원 브라우저) -->
<aeriz-editor name="body" theme="dark" placeholder="내용을 입력하세요">
</aeriz-editor>
<button type="submit">저장</button>
</form>
<script type="module">
const el = document.querySelector('aeriz-editor');
el.addEventListener('change', (e) => console.log(e.detail.html));
el.value = '<p>안녕하세요</p>';
el.onImageUpload = async (file) => uploadAndGetUrl(file);
</script>
| 속성 | 값 |
|---|---|
value |
본문 HTML (프로퍼티로도 읽고 쓸 수 있음) |
placeholder |
빈 본문 안내 문구 |
theme |
light / dark |
locale |
en / ko / ja / zh |
toolbar |
false · 항목 목록("bold italic") · JSON 객체 |
image-upload-url, file-upload-url |
업로드 API URL |
프로퍼티: value · editor · getBlocks() · focus() ·
onImageUpload · onFileUpload.
이벤트: input · change (detail.html) · aeriz-ready (detail.editor).
태그 이름이 충돌하면 접두사를 바꿀 수 있습니다.
import { defineAerizElements } from '@aeriz/wysiwyg/wc';
defineAerizElements('my'); // <my-editor> / <my-viewer>
jQuery
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<link rel="stylesheet" href="/aeriz-wysiwyg.min.css" />
<script type="module">
import '@aeriz/wysiwyg/jquery'; // 전역 jQuery 가 있으면 자동 등록
$('#editor').aerizEditor({
value: '<p>안녕하세요</p>',
theme: 'dark',
onChange: (html) => console.log(html),
});
const html = $('#editor').aerizEditor('getHTML');
$('#editor').aerizEditor('setHTML', '<p>바꿈</p>');
$('#editor').aerizEditor('destroy');
</script>
번들러 환경에서 전역 jQuery 를 쓰지 않는다면 명시적으로 등록합니다.
import $ from 'jquery';
import { registerAerizJQuery } from '@aeriz/wysiwyg/jquery';
registerAerizJQuery($);
명령: getHTML · getBlocks · setHTML · update · focus ·
instance · destroy. aerizViewer 도 같은 방식으로 동작합니다.
같은 노드에 두 번 초기화하면 새 인스턴스를 만들지 않고 갱신으로 처리합니다.
React
import { useState } from 'react';
import { AerizEditor, AerizViewer } from '@aeriz/wysiwyg/react';
import '@aeriz/wysiwyg/style.css';
export function Compose() {
const [html, setHtml] = useState('<p>안녕하세요</p>');
return (
<>
<AerizEditor
value={html}
onChange={setHtml}
theme="dark"
placeholder="내용을 입력하세요"
className="my-editor"
imageUploadUrl="/api/upload"
/>
<AerizViewer html={html} />
</>
);
}
value 를 그대로 되돌려줘도 캐럿이 튀지 않습니다 — 들어온 값이 에디터의 현재
출력과 같으면 본문을 교체하지 않기 때문입니다.
ref 로 명령형 API 에 접근합니다.
const ref = useRef<AerizEditorHandle>(null);
// ref.current.editor — 코어 인스턴스 (전체 API)
// ref.current.getHTML() / setHTML(html) / focus()
Vue 3
<script setup>
import { ref } from 'vue';
import { AerizEditor, AerizViewer } from '@aeriz/wysiwyg/vue';
import '@aeriz/wysiwyg/style.css';
const html = ref('<p>안녕하세요</p>');
const editor = ref(null);
</script>
<template>
<AerizEditor
ref="editor"
v-model="html"
theme="dark"
placeholder="내용을 입력하세요"
@state-change="onStateChange"
@ready="onReady"
/>
<AerizViewer :html="html" />
</template>
전역 등록도 가능합니다.
import { AerizWysiwygPlugin } from '@aeriz/wysiwyg/vue';
app.use(AerizWysiwygPlugin);
이벤트: update:modelValue · change · state-change · ready.
ref 로 노출되는 것: editor · getHTML() · setHTML() · focus().
Vue 2 (2.6 / 2.7)
Vue 2 관례에 따라 v-model 은 value prop + input 이벤트를 씁니다.
import { AerizEditor } from '@aeriz/wysiwyg/vue2';
import '@aeriz/wysiwyg/style.css';
export default {
components: { AerizEditor },
data: () => ({ html: '<p>안녕하세요</p>' }),
template: `
<AerizEditor ref="editor" v-model="html" theme="dark" />
`,
methods: {
focusEditor() {
this.$refs.editor.focus();
},
},
};
이 어댑터는 vue 를 import 하지 않습니다 (Vue 2 컴포넌트는 순수 옵션 객체).
따라서 소비자 앱의 Vue 인스턴스와 이중화될 여지가 없습니다.
Svelte (3 / 4 / 5)
컴파일된 .svelte 컴포넌트가 아니라 액션으로 제공합니다. 액션은 순수 함수
계약이라 Svelte 메이저 버전 세 개를 배포본 하나로 만족시킵니다.
<script>
import { aerizEditor, aerizViewer } from '@aeriz/wysiwyg/svelte';
import '@aeriz/wysiwyg/style.css';
let html = '<p>안녕하세요</p>';
</script>
<div
use:aerizEditor={{
value: html,
onChange: (next) => (html = next),
theme: 'dark',
placeholder: '내용을 입력하세요',
}}
/>
<div use:aerizViewer={{ html }} />
Angular (14+)
Angular 연동은 TypeScript 소스로 배포됩니다 — 소비자의 Angular 컴파일러가 직접 컴파일하므로 Angular 14 이상 어느 버전에서도 같은 파일이 동작합니다. 설정 방법(택 1)과 상세 문서는 angular/README.md 를 참고하세요.
tsconfig.app.json 의 include 에 한 줄 추가하면 끝입니다.
"include": ["src/**/*.ts", "node_modules/@aeriz/wysiwyg/angular/*.ts"]
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AerizEditorDirective } from '@aeriz/wysiwyg/angular';
@Component({
standalone: true,
imports: [FormsModule, AerizEditorDirective],
template: `
<div aerizEditor [(ngModel)]="html" theme="dark"></div>
<div aerizViewer [html]="html"></div>
`,
})
export class ComposeComponent {
html = '<p>안녕하세요</p>';
}
[(value)] · [(ngModel)] · formControlName 세 가지 바인딩을 모두
지원합니다. 디렉티브는 에디터를 NgZone 밖에서 생성하므로 캐럿을 움직일 때마다
변경 감지가 돌지 않습니다.
서버 / 엣지 런타임 (DOM 없음)
저장된 BlockSnapshot[] 을 HTML · 마크다운 · 평문으로 바꿉니다. CSS 를
import 하지 않고, import 그래프 전체가 document / window 를 참조하지
않습니다 — Cloudflare Workers(workerd) · Node · Deno · Bun 에서 그대로
동작합니다.
import {
renderBlocksToHTML,
blocksToMarkdown,
blocksToPlainText,
} from '@aeriz/wysiwyg/server';
// 발행용 HTML — 같은 입력이면 항상 같은 출력 (결정적 · 멱등)
const html = renderBlocksToHTML(post.blocks, { wrapper: 'content' });
// 포맷 종속을 푸는 탈출구
const markdown = blocksToMarkdown(post.blocks);
// 전문 검색 색인용
const text = blocksToPlainText(post.blocks, { blockSeparator: ' ' });
// Cloudflare Worker
export default {
async fetch(req, env) {
const post = await env.DB.get('post:1', 'json');
return new Response(renderBlocksToHTML(post.blocks, { wrapper: 'content' }), {
headers: { 'content-type': 'text/html; charset=utf-8' },
});
},
};
키보드 단축키
macOS 는 Ctrl 대신 Cmd. 전체 목록과 근거는
SPEC.md §7 에 있습니다.
| 동작 | 단축키 |
|---|---|
| 되돌리기 / 다시 실행 | Ctrl+Z / Ctrl+Shift+Z · Ctrl+Y |
| 굵게 · 기울임 · 밑줄 · 취소선 | Ctrl+B · Ctrl+I · Ctrl+U · Ctrl+Shift+X |
| 본문으로 | Ctrl+Alt+0 |
| 제목 1~6 | Ctrl+Alt+1 ~ Ctrl+Alt+6 |
| 번호 목록 · 글머리 목록 · 체크리스트 | Ctrl+Alt+7 · Ctrl+Alt+8 · Ctrl+Alt+T |
| 인용구 · 코드 블록 | Ctrl+Alt+9 · Ctrl+Alt+C |
| 블록 이동 / 삭제 | Ctrl+Shift+↑ Ctrl+Shift+↓ / Ctrl+Shift+D |
| 표: 다음 / 이전 셀 | Tab / Shift+Tab (마지막 셀에서 Tab → 새 행) |
| 목록: 들여쓰기 / 내어쓰기 | Tab / Shift+Tab |
마크다운처럼 입력해도 됩니다. 블록 맨 앞에서 # - 1. > ```
--- - [ ] 를 치면 그 자리에서 유형이 바뀝니다.
새로 만들어지는 블록의 기본 유형은 언제나 본문입니다. 제목 끝에서
Enter를 눌러도 제목이 복제되지 않고 본문이 이어지며, 빈 제목·인용구· 코드 블록에서Backspace를 누르면 본문으로 돌아옵니다.
서버 sanitizer 와 함께 쓰기
getHTML() 은 기본적으로 테마 색을 인라인 style 에 베이크합니다 —
스타일시트 없이 어디에 붙여도 같은 시각으로 보이지만, style 속성을 제거하는
sanitizer 를 통과하면 서식이 통째로 사라집니다.
두 가지 대응이 있습니다.
1. 클래스 기반 출력으로 전환 — style 프로퍼티가 40여 개에서 10개로
줄고, 정렬은 클래스로 표현됩니다.
new AerizEditor({ element, htmlStyles: 'class' }); // getHTML/onChange 기본값
editor.getHTML({ styles: 'class' }); // 호출 단위 재정의
출력은 aeriz-wysiwyg.min.css 를 로드한 페이지의
.aeriz-wysiwyg > .aw-content 안에 넣어야 기본 서식이 적용됩니다.
renderBlocksToHTML(blocks, { wrapper: 'content' }) 가 그 컨테이너를 만들어
줍니다.
2. 허용목록을 출력에 맞추기 — 등장할 수 있는 태그 · 속성 · style
프로퍼티의 확정 목록이
SPEC.md §9 출력 HTML 허용목록 에 있습니다.
추측이 아니라 실측이며 npm run allowlist 로 재생성할 수 있습니다.
⚠️ HTML 가져오기 기능은 sanitizer 가 아닙니다. 원본 스크립트가 실행되고 이벤트 핸들러 속성이 제거되지 않습니다. 저장 전 서버 정화가 필수입니다 — SPEC.md §11 참고.
스냅샷 스키마 버전
editor.serialize() 는 각 블록에 version: 1 을 기록합니다. 저장된 데이터를
읽는 모든 경로가 upgradeSnapshots() 를 통과하므로, 버전이 올라가도 예전
데이터가 그대로 열립니다. 변경 정책은
SPEC.md §10 에 있습니다.
직접 어댑터 만들기
목록에 없는 프레임워크(Solid, Qwik, Lit …) 는 프레임워크 중립 컨트롤러 위에 얇게 올리면 됩니다. 값 바인딩 · 캐럿 보존 · 재생성 판단이 이미 들어 있습니다.
import { createEditorController } from '@aeriz/wysiwyg/adapter';
const controller = createEditorController(hostElement, {
value: '<p>안녕하세요</p>',
onChange: (html) => setState(html),
});
controller.update({ value: nextHtml }); // 바뀐 것만 반영
controller.destroy(); // 언마운트 시
빌드
npm install
npm run build # dist/ 에 IIFE / ESM / CJS / 어댑터 / CSS / .d.ts 생성
npm test # 서버 렌더러 · getHTML · 어댑터 계약 테스트 (jsdom)
npm run typecheck
npm run allowlist # 출력 HTML 허용목록 재생성
npm run version:sync # 문서·데모의 CDN 버전 표기를 package.json 에 맞춤
npm run version:check # 뒤처졌으면 실패 (CI 용)
npm run dev # 데모 페이지 (http://localhost:5173)
npm run release:stage # npm 업로드용 release/ 조립 + exports 경로 검증
npm run release:verify # release/ 에서 npm pack --dry-run
dist/ 는 데모 서버의 publicDir 을 겸하고 개발용 비압축본·CDN 번들까지
담고 있습니다. 실제로 npm 에 올라가는 것은 release:stage 가 "files" 만
골라 조립한 release/ 입니다. 배포 절차는 RELEASE.md (붙여넣어
실행하는 런북), 계정·권한·회수 등 배경은 PUBLISHING.md 를
참고하세요.
데모는 세 페이지입니다.
/index.html— 에디터 기능 데모/docs.html— 연동 · 직렬화 레퍼런스. 이 README 의 내용을 사이드바 · 검색 가능한 API 표 · 라이브 플레이그라운드로 정리한 단일 페이지 문서입니다. 플레이그라운드에서 같은 문서가inline/classHTML · 마크다운 · 평문 · 스냅샷 · 서버 렌더 여섯 가지로 어떻게 나오는지 실시간 비교할 수 있습니다./frameworks.html— 프레임워크 어댑터 데모. React · Vue 3 · Web Component · Svelte 액션 · jQuery 를 한 페이지에 올리고, 로드 직후 자동 점검을 실행합니다.
문서 사이트 배포 — Cloudflare Workers
/docs.html 을 그대로 정적 사이트로 올릴 수 있습니다. docs:build 가
examples/docs.html 과 라이브러리 산출물을 docs/ 에 모으고, 코드가 없는
정적 자산 전용 Worker (wrangler.toml) 가 그 폴더를 서빙합니다. 별도
Pages 프로젝트가 필요 없습니다.
npx wrangler login # 처음 한 번만
npm run docs:build # docs/ 조립
npm run docs:preview # 로컬 Workers 런타임으로 확인 (wrangler dev)
npm run docs:deploy # 배포
페이지 HTML 은 데모와 배포본이 같은 파일입니다 — examples/docs.html 이
라이브러리를 루트 절대 경로로 부르고, 배포 시에도 산출물을 사이트 루트에 같이
두기 때문에 경로 분기가 없습니다. 캐시·보안 헤더는 docs/_headers 로 함께
생성됩니다. docs/ 는 파생물이라 git 에 추적하지 않습니다.
오프라인 단일 파일
문서 페이지를 외부에 공유하거나 오프라인으로 열어야 하면 라이브러리를 인라인한 단일 파일로 만들 수 있습니다 (외부 요청 0건).
node scripts/build-docs-artifact.mjs docs-standalone.html
설치된 Chrome 으로 그 페이지를 실제 검증할 수 있습니다 (playwright-core 는 브라우저를 내려받지 않고 시스템 Chrome 을 그대로 씁니다).
npm run dev # 다른 터미널에서
npm run test:browser # 헤드리스 검증 + 스크린샷
node scripts/browser-check.mjs --headed # 창을 띄워서 보기
5173 이 이미 쓰이는 중이면 데모를 다른 포트로 띄우고 주소를 넘기면 됩니다.
npx vite --config vite.demo.config.ts --port 5347
node scripts/browser-check.mjs http://localhost:5347/frameworks.html
라이선스
Apache-2.0
Related Packages
Lilac - a smooth, modern WYSIWYG text editor built with pure TypeScript. Zero de...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 1.0.5 | ^2.6.0 || | Apache-2.0 | |
| 1.0.4 | ^2.6.0 || | Apache-2.0 | |
| 1.0.3 | ^2.6.0 || | Apache-2.0 | |
| 1.0.2 | Apache-2.0 | ||
| 1.0.1 | Apache-2.0 | ||
| 1.0.0 | Apache-2.0 |