커스텀 날개 만들기
날개(wing)는 객체 하나입니다. 클래스를 상속하지도, 별도의 등록 절차를 밟지도 않습니다 — wings 배열에 넣는 것이 곧 등록입니다.
굵게·표·업로드도 여기 적힌 칸만 채워서 만들어져 있고, 코어 기능으로 들어가는 문도 꽃(flower) 하나입니다. 직접 만든 날개는 기본 날개와 같은 조건에서 동작합니다.
가장 짧은 날개
import { nabi, markIcon } from 'nabi-note'
import type { NabiWing } from 'nabi-note'
function kbd(): NabiWing {
return {
id: 'text-kbd',
group: 'mark',
names: { ko: '키 이름', en: 'Key' },
icon: markIcon('<rect x="1.75" y="4.75" width="12.5" height="6.5" rx="1.5"/>'),
inline: {
tag: 'kbd',
attributes: [],
escapeKeys: ['Escape'],
claims: (element) => element.tagName === 'KBD',
},
}
}
nabi.create('#editor', { wings: [kbd()] })툴바 버튼과 @ 메뉴 항목이 생기고, <kbd> 가 문서에서 살아남습니다. 등록하지 않으면 <kbd> 는 껍데기가 벗겨져 평문(plain text)으로 떨어집니다.
인스턴스가 아니라 팩토리 함수(factory)를 내보내세요
날개는 bold() 처럼 부를 때마다 새 객체를 돌려주는 함수로 만듭니다. 한 페이지에 에디터가 여럿 있을 수 있고, 객체 하나를 두 에디터가 나눠 가지면 그 안에 담긴 상태가 섞입니다.
// ✅ 에디터마다 독립된 객체
export function kbd(): NabiWing { return { /* … */ } }
// ❌ 모듈 하나에 객체 하나 — 두 에디터가 같은 것을 씁니다
export const kbd: NabiWing = { /* … */ }상태가 필요하면 팩토리 함수의 클로저에 둡니다. 업로드 날개가 그렇게 되어 있습니다 — 진행 중인 업로드 장부와 AbortController 가 전부 createWing() 안쪽 지역 변수입니다.
export function counter(): NabiWing {
let editor: EditorHandle | null = null // 이 에디터만의 상태
return {
id: 'demo-counter',
names: { ko: '글자 수', en: 'Count' },
setup(handle) {
editor = handle
return () => { editor = null }
},
}
}옵션을 받는 날개도 같은 모양입니다 — heading(1)·link({ allowLocalUrls: true })· code({ highlight }) 가 전부 인자를 받는 팩토리 함수입니다.
등록과 순서
nabi.create('#editor', {
wings: [bold(), italic(), highlight()],
html: '<p>초기 내용</p>',
onChange: (html) => console.log(html),
})배열 순서가 곧 스캔 순서입니다. 어떤 마크업의 주인을 가릴 때 코어는 이 순서대로 claims 를 물어보고, 처음 true 를 낸 날개가 가져갑니다. 아무도 가져가지 않으면 껍데기가 벗겨집니다.
순서가 결과를 가르는 칸은 다음과 같습니다.
| 칸 | 어떻게 갈립니까 |
|---|---|
inline.claims · block.claims | 처음 주장한 날개가 그 엘리먼트를 가져갑니다 |
keys | 처음 소비한 날개가 그 키를 가져갑니다 |
onClick · pasteText · pasteHtml · handleFiles | 처음 true(또는 값)를 낸 날개가 가져갑니다 |
fromSourceHtml | 처음 자기 것이라고 답한 규격이 고칩니다 |
| 툴바 기본 배치 | 갈래별 그룹 안에서 등록 순서대로 섭니다 |
같은 태그를 둘이 소유(claim)하지 않습니다
원칙은 한 정규 태그(canonical tag)에 한 소유자입니다. 글머리 목록(ul)과 체크리스트 (ul[data-nabi-list="task"])처럼 속성으로 갈리는 공유만 정당하고, 그때도 두 조건을 지켜야 합니다.
채울 수 있는 칸 전부
NabiWing 의 필드는 이것이 전부입니다. 필수는 id 와 names 이고, 나머지는 필요할 때 채웁니다.
| 필드 | 하는 일 | 어디서 다룹니까 |
|---|---|---|
id | 전역에서 유일한 식별자 | 이 문서 |
names | 로케일별 표시 이름 | 이 문서 |
icon | 툴바 버튼에 그릴 것 | 이 문서 |
group | 툴바 노출 갈래 | 이 문서 |
family | 툴바에서 한 무리로 붙어 서는 이름 | 이 문서 |
styles | 이 날개가 실어 나르는 CSS | 이 문서 |
inline | 글자에 씌우는 마크(mark) | 인라인 마크 |
block · blocks | 문단 자리를 차지하는 덩어리 — 블록(block) | 블록과 블록 속성 |
blockAttribute · blockAttributes | 태그는 그대로, 블록의 성질만 — 블록 속성(block attribute) | 블록과 블록 속성 |
picker | 크기 선택 격자를 먼저 띄웁니다 | UI 와 동작 |
prompt | 한 줄 입력창을 먼저 띄웁니다 | UI 와 동작 |
palette | 색 견본판(palette)을 먼저 띄웁니다 | UI 와 동작 |
activate | 완전 커스텀 동작 | UI 와 동작 |
commands | 상황 줄(context row)에 뜨는 추가 동작 | UI 와 동작 |
onClick | 본문 클릭을 가로챕니다 | UI 와 동작 |
keys | 키 하나를 가로챕니다 | 키·자동 변환·붙여넣기 |
inputRules | 글자만으로 일어나는 자동 변환(input rule) | 키·자동 변환·붙여넣기 |
pasteText | 붙여넣은 평문 한 덩어리 | 키·자동 변환·붙여넣기 |
pasteHtml | 붙여넣은 HTML 조각 통째로 | 키·자동 변환·붙여넣기 |
handleFiles | 들어온 파일을 가져갑니다 | 키·자동 변환·붙여넣기 |
prepareComposition | IME 조합 직전 캐럿(caret) 자리 준비 | 키·자동 변환·붙여넣기 |
setup | 선언으로 안 되는 모든 것 | setup 과 꽃 |
viewer | 에디터 밖에서, 저장된 HTML 을 그리는 쪽에서 도는 동작 | setup 과 꽃 |
단수와 복수는 같은 관계입니다 — block 은 blocks: [spec] 의 편의 문법이고, blockAttribute 는 blockAttributes: [spec] 의 편의 문법입니다. 함께 쓰면 단수가 먼저 스캔됩니다.
id
전역에서 유일한 절대 식별자입니다.
readonly id: string호스트가 코드에 적는 열쇠이기도 합니다 — editor.toggle('text-bold'), editor.runCommand('table-row-insert-above'), toolbar: [['text-bold', 'text-italic']] 이 전부 이 값을 씁니다.
- 소문자와 하이픈만 씁니다.
text-bold·block-heading-1·table-row-insert-above. - 관례는 갈래를 앞에 두는 것입니다. 마크는
text-, 블록은block-, 정렬 가족은align-. commands안의 커맨드 id 도 같은 규칙이고, 날개 id 를 접두사로 두기를 권합니다 (link-remove·code-language·details-toggle).
한번 나가면 바꾸기 어렵습니다
id 는 호스트의 툴바 레이아웃과 저장된 설정에 박힙니다. 이름이 마음에 들지 않아 나중에 고치면 그 호스트의 툴바에서 버튼이 조용히 사라집니다 — 모르는 id 는 건너뛰기 때문입니다.
names
툴바 버튼의 tooltip, @ 메뉴 항목, 검색어로 쓰이는 로케일별 표시 이름입니다.
interface WingNames {
readonly ko: string
readonly en: string
readonly [locale: string]: string // 언어를 더 채워도 됩니다
}names: { ko: '굵게', en: 'Bold' }
names: { ko: '위에 행 추가', en: 'Insert row above' }고르는 규칙은 정확히 일치 → 언어 코드만 → en → 남은 것 중 첫 번째 입니다. locale: 'ko-KR' 이면 ko 가 걸리고, 아무 언어도 맞지 않으면 en 으로 떨어집니다.
ko와en은 반드시 채웁니다. 나머지 언어는 선택입니다.names.en은 대문자로 시작합니다. 툴바와@메뉴에 여러 날개가 나란히 서기 때문에 대소문자가 섞이면 그 자리에서 티가 납니다.@메뉴 검색은 지금 로케일 이름 · 다른 로케일 이름 · id 를 모두 봅니다. 그래서 한국어 화면에서도bold로 찾을 수 있습니다.- 업로드 오류 문구처럼 값이 끼어드는 문장은
{param}자리표시자(placeholder)를 씁니다 — 꽃의t()가 채웁니다.
icon
툴바 버튼에 그릴 것입니다. 아이콘은 주소입니다.
readonly icon?: string받는 값은 세 갈래이고, 코어가 알아서 가릅니다.
| 값 | 어떻게 그려집니까 |
|---|---|
주소 (data: · https: · /… · ./…) | mask-image 로 얹고 바탕을 currentColor 로 칠합니다 |
< 로 시작하는 마크업 | 인라인 SVG 로 심습니다 (서드파티 호환용 옛 경로) |
| 그 밖의 짧은 문자열 | 글자 라벨로 씁니다 ('H1') |
| 없음 | 표시 이름의 첫 글자를 씁니다 |
주소로 굳히는 도구가 셋 있습니다.
import { svgIcon, markIcon, blockIcon } from 'nabi-note'
markIcon('<path d="M2 8h12"/>') // 획 1.6 — 인라인 마크·정렬·리스트 계열
blockIcon('<rect x="1.75" y="2.75" width="12.5" height="10.5" rx="1.5"/>') // 획 1.4 — 블록 계열
svgIcon('<path d="…"/>', 2) // 획 굵기를 직접 정할 때- 몸통(
<svg>껍데기·viewBox·선 색)은 도구가 씌웁니다. 선과 도형만 넘기세요. 격자는0 0 16 16입니다. - 프리셋이 둘인 이유는 무게를 맞추기 위해서입니다. 획이 많은 블록 아이콘은 가늘게(1.4), 획이 적은 마크 아이콘은 조금 굵게(1.6) 그려야 나란히 놓았을 때 같아 보입니다.
- 속을 채우는 도형은 불투명색으로 칠합니다. 마스크는 색이 아니라 alpha 만 보기 때문에, 칠하지 않은 도형은 뚫린 자리가 됩니다. 유튜브의 재생 삼각형이 그래서
fill="#000"입니다. - 색은 넣지 마세요.
currentColor를 따라가야 눌림·비활성·다크 테마가 그대로 따라옵니다. - 글자 라벨이 나은 자리도 있습니다 — 제목은
icon: 'H1'입니다.
사용자 값을 아이콘에 넣지 마세요
icon 의 SVG 문자열은 날개 작성자가 쓴 코드로 취급됩니다. 사용자에게서 받은 값을 여기에 흘려 넣으면 그대로 심어집니다.
group
툴바가 지금 상황에 맞는 버튼만 보여줄 때 쓰는 갈래입니다.
type WingGroup = 'mark' | 'block' | 'insert' // 없으면 'insert'| 갈래 | 뜻 | 예 |
|---|---|---|
mark | 글자에 씌우는 서식 | 굵게 · 링크 · 서식 지우기 |
block | 지금 블록의 전환·성질 | 제목 · 리스트 · 정렬 · 코드 · 드롭 캡 |
insert | 새 덩어리를 넣습니다 | 이미지 · 표 · 유튜브 · 구분선 · 업로드 |
어느 갈래가 보이는지는 캐럿이 놓인 자리가 정합니다.
| 자리 | 어디입니까 | 보이는 갈래 |
|---|---|---|
| 글 쓰는 중 | 문단 · 제목 · 리스트 항목 | mark · block · insert |
| 표 칸 안 | td · th | mark 뿐 — 칸에는 인라인 내용만 들어갑니다 |
| 코드 블록 안 | pre | 없음 — 코드는 글자 그대로여야 합니다 |
| 물건이 통째로 선택됨 | 이미지 · 구분선 | 없음 — 이미지에 굵게는 뜻이 없습니다 |
@ 메뉴도 같은 표를 보므로, 툴바에는 없는데 메뉴로는 넣어지는 구멍이 생기지 않습니다.
- 갈래를 적지 않으면
insert입니다 — 어디서나 보이는 쪽이 안전합니다. - 이 값은 노출만 정합니다. 눌렀을 때 실제로 되는지는 각자의 조건이 따로 봅니다 (드롭 캡의
appliesTo, 커맨드의isAvailable).
family
툴바에서 한 무리로 붙어 설 이름입니다.
readonly family?: string // 없으면 갈래(group) 전체가 한 무리group 은 "언제 보이나" 를 가르는 값이라 셋뿐이어서, 그대로 무리로 쓰면 한 무리가 열 개를 넘어 어디서 뜻이 갈리는지 보이지 않습니다. family 는 눈이 읽는 단위입니다 — 제목 여섯, 정렬 셋, 리스트 셋처럼 같은 자리를 두고 다투는 것들이 한 이름을 나눠 갖습니다.
{ id: 'text-highlight', group: 'mark', family: 'color', /* … */ }
{ id: 'text-color', group: 'mark', family: 'color', /* … */ }기본 날개가 쓰는 이름은 이렇습니다.
| 이름 | 누가 씁니까 |
|---|---|
emphasis | 굵게 · 기울임 · 밑줄 · 취소선 |
script | 윗첨자 · 아랫첨자 |
link | 링크 |
color | 형광펜 · 글자색 |
heading | 제목 여섯 |
align | 왼쪽 · 가운데 · 오른쪽 정렬 |
typeface | 세리프 · 산세리프 · 고정폭 |
text | 글자 크기 · 드롭 캡 |
list | 글머리 목록 · 번호 목록 · 체크리스트 |
structure | 표 · 구분선 |
container | 코드 · 접기 상자 |
media | 이미지 · 유튜브 · 업로드 |
clear | 서식 지우기 |
- 같은 이름은 등록 순서와 무관하게 모입니다. 사이에 다른 날개가 끼어도 무리는 흩어지지 않습니다.
- 무리끼리의 순서는 그 이름이 처음 나온 자리를 따릅니다.
- 적지 않으면 갈래 전체가 한 무리입니다. 이 칸을 모르는 서드파티 날개도 그대로 동작합니다.
styles
이 날개가 실어 나르는 스타일시트입니다. 문자열 하나입니다.
readonly styles?: stringimport { NOTE_SCOPE } from 'nabi-note'
const DIVIDER_STYLES = `
${NOTE_SCOPE} > hr {
margin: 1.1em 0;
border: none;
border-top: 1px solid var(--nabi-border);
}
`
export function horizontalRule(): NabiWing {
return { id: 'block-divider', styles: DIVIDER_STYLES, /* … */ }
}코어 시트(stylesheet)에는 날개가 하나도 없어도 필요한 공용 원시(틀·색 토큰·버튼·떠 있는 상자)만 있고, 서식별 규칙은 각 날개가 나릅니다. 그래서 제목을 등록하지 않은 에디터에는 제목 CSS 가 아예 들어가지 않습니다.
- mount 할 때 코어 시트 뒤에 주입되므로, 같은 우선순위에서는 날개가 이깁니다.
- 내용이 같은 문자열은 문서당 한 장입니다. 한 가족 날개들이 같은 상수를 나눠 가지면 사본이 쌓이지 않습니다 —
heading(1)~heading(6)이HEADING_STYLES하나를, 윗첨자·아랫첨자가SUBSUP_STYLES하나를, 세 정렬이ALIGN_STYLES하나를 씁니다. - 마지막 에디터가
destroy()될 때 걷힙니다. - 뿌리는 물음 하나로 고릅니다 — 이 규칙이
getHtml()결과에도 걸려야 하나요? 걸려야 하면NOTE_SCOPE(문서 서식 — 저장된 HTML 의 모양), 아니면EDITOR_SCOPE(편집 화면 전용 — 캐럿 표시·임시 마크업)를 앞에 세웁니다. wing 이 스스로 그리는 상자는 자기 블록 클래스입니다 —.nabi-<이름>, 부품은.nabi-<이름>__<부품>(kebab-case), 상태는.nabi-<이름>--<상태>. - 자기 색 토큰을 선언한다면
TOKEN_SCOPE에 선언하세요 — 아니면 에디터 밖에서 그린 저장값에서var(--nabi-…)가 값을 얻지 못합니다. - 색·모서리·그림자는
var(--nabi-*)토큰으로 씁니다. 자기 색 토큰이 필요하면 라이트·다크· 명시적 라이트 세 블록을 스스로 재현해야 합니다 — 하나라도 빠지면 그 테마에서 색이 샙니다. - 문자열 안에 백틱을 두지 마세요.
날개 시트는 문서 전역입니다
그래서 "이 에디터만 세 줄, 저 에디터는 다섯 줄" 같은 값을 날개 옵션으로 두면 안 됩니다 — 같은 문서에 에디터가 둘 있으면 나중 시트가 둘 다 이기고 조용히 어긋납니다. 자리마다 달라져야 하는 값은 CSS 변수로 열어 두세요. 드롭 캡이 감쌀 줄 수를 --nabi-dropcap-lines 로 받는 것이 그 이유입니다.
브라우저 기본값에 기대지 마세요. 호스트가 리셋 CSS(예: ol, ul { list-style: none })를 쓰면 목록의 점과 번호가 통째로 사라집니다. 시트를 나르는 날개가 자기 마크업의 모양을 책임진다는 것이 규칙이라, 리스트 날개는 표식을 스스로 선언합니다.
다음 문서
- 인라인 마크 — 글자에 씌우는 마크(
InlineMarkSpec) 전부 - 블록과 블록 속성 —
BlockSpec·ContentSpec·blockAttribute - UI 와 동작 —
picker·prompt·activate·commands·onClick - 키·자동 변환·붙여넣기 —
keys·inputRules· 붙여넣기 · 파일 - setup 과 꽃 — 수명주기 훅(hook)과 코어 기능으로 들어가는 문