setup 훅과 꽃 (flower)
선언 칸으로 표현되지 않는 것은 setup 에서 직접 만듭니다. 그리고 그때 쓰는 코어 기능은 꽃(flower) 이라는 문 하나로만 들어옵니다.
이름의 뜻 — 꽃은 몸통이 날개를 위해 피워 둔 자리입니다. 외부 라이브러리가 아니라 코어가 골라 내준 자기 표면입니다. 날개는 꽃에서만 꿀을 얻습니다.
setup
setup?(editor: EditorHandle): (() => void) | voidmount 직후 한 번 불립니다. 돌려준 함수는 destroy() 때 불립니다 — 만든 것은 반드시 여기서 되돌립니다.
setup(editor) {
const off = editor.on('change', sync)
return () => off()
}업로드 날개는 여기서 인스턴스 상태를 붙들고 정리합니다.
setup(handle) {
editor = handle
controller = new AbortController()
return () => {
// 진행 중인 업로드에 그만두라고 알립니다. 잠금은 코어가 destroy 때 걷어갑니다
controller.abort()
// 아직 걷히지 않은 자리표시자의 미리보기 주소를 전부 되돌립니다
for (const record of active) releasePreview(record)
active.clear()
editor = null
}
},코드 하이라이팅도 같은 모양입니다 — change 를 듣고 한 프레임에 한 번만 칠하며, 정리 함수가 구독과 예약된 프레임을 함께 걷습니다.
function startHighlighting(editor: EditorHandle, options: CodeOptions): () => void {
let frame = 0
const paintAll = () => {
frame = 0
// 조합 중에는 DOM 을 갈아엎지 않습니다 — 판정은 코어의 것 하나만 믿습니다
if (editor.composing) return
/* … 코드 블록마다 칠하기 … */
}
const schedule = () => {
if (editor.composing || frame !== 0) return
frame = editor.document.defaultView?.requestAnimationFrame(paintAll) ?? 0
if (frame === 0) paintAll()
}
const off = editor.on('change', schedule)
paintAll() // 처음 한 번
return () => {
off()
if (frame !== 0) editor.document.defaultView?.cancelAnimationFrame?.(frame)
}
}setup 이 던져도 에디터는 계속 돕니다. 스캔·키·클릭·pasteText·pasteHtml 도 마찬가지로 예외를 삼킵니다 — 날개 하나가 에디터 전체를 세우지 않습니다.
EditorHandle — setup 이 받는 것
Nabi 전체가 아니라 날개에게 필요한 표면만 받습니다. 전체 인스턴스를 넘기면 날개가 내부 구현에 기대게 되고, 그 순간 코어를 고칠 수 없게 됩니다.
| 칸 | 무엇입니까 |
|---|---|
element | 편집 영역 (contenteditable 루트) |
document | 그 문서. 새 엘리먼트는 여기서 만듭니다 |
locale | 표시 언어 |
readOnly | 읽기 전용인지 |
locked | 지금 편집이 잠겨 있는지 (readOnly 와 별개 상태입니다) |
composing | IME 조합 중인지 |
afterComposition(run) | 조합 중이면 끝난 뒤로 미루고, 아니면 지금 실행합니다 |
ui | 떠 있는 UI 서비스 (popup · grid · prompt · notice) |
lock({ timeout }) | 편집을 잠급니다 → { heartbeat, release, active } |
context() | 지금 캐럿 상황. 매 호출마다 새로 계산합니다 |
toggle(wingId) | 날개 하나를 토글합니다 |
runCommand(commandId) | 커맨드 하나를 실행합니다 |
labelOf(wingId) | 그 날개의 지금 로케일 표시 이름 |
commit() | 직접 바꾼 뒤 부릅니다 — 불변식 복구 → 스냅샷 → onChange |
on(type, listener) | 이벤트 구독. 돌려주는 함수로 해제합니다 |
들을 수 있는 이벤트는 change · selectionchange · markschange · menuchange · historychange · lockchange · fullscreenchange · focus · blur · destroy 입니다.
commit() 을 부르지 않은 편집은 없던 일이 됩니다
문서를 바꾸는 모든 경로는 편집 관문 하나를 지납니다. 거기서 불변식 복구 → 되돌리기 스냅샷 → 상태 갱신 → onChange 가 한꺼번에 일어납니다. 이 문을 안 타는 편집은 되돌릴 수 없고 값도 나가지 않습니다.
꽃이 주는 것
nabi-note 가 내보내는 것 중 날개를 만들 때 쓰는 것들입니다. 번들 날개와 서드파티 날개가 같은 문을 씁니다 — 안쪽에만 열려 있는 지름길은 없습니다.
import {
insertBlockAt,
findAdjacentBlock,
deleteBlockCommand,
caretToStart,
clearFormatAt,
clearMarksInRange,
isPlainBlock,
safeUrl,
markIcon,
blockIcon,
svgIcon,
TRASH_ICON,
} from 'nabi-note'블록 넣기·찾기·지우기
| 하는 일 | |
|---|---|
insertBlockAt(context, element) | 캐럿 블록 자리에 넣습니다. 빈 문단 위면 그 자리를 차지하고, 아니면 뒤에 붙습니다. 넣은 블록 뒤에 이어 쓸 문단을 보장하고 캐럿을 그리로 옮깁니다 |
findAdjacentBlock(context, matches) | 캐럿 블록 자신이거나 바로 앞 형제에서 조건에 맞는 블록을 찾습니다 — 캐럿이 못 들어가는 물건 블록을 잡는 길입니다 |
deleteBlockCommand({ id, names, find }) | "이 블록을 지운다" 커맨드를 찍어 냅니다. 빈 문단으로 갈아 끼우고 캐럿을 그 안에 놓는 것까지 합니다 |
caretToStart(root, element) | 그 엘리먼트의 맨 앞에 캐럿을 놓습니다 |
insertBlockAt 이 캐럿이 아니라 context.block 을 기준으로 삼는 것이 요점입니다 — 입력 상자로 포커스가 옮겨간 뒤에도 상자를 열던 순간의 자리에 넣을 수 있어야 하기 때문입니다.
마크
| 하는 일 | |
|---|---|
clearMarksInRange(context) | 선택 영역에 걸린 모든 인라인 마크를 벗깁니다. 어떤 태그가 마크인지는 스키마에 물어보므로 등록된 마크가 늘어나면 저절로 함께 지웁니다 |
clearFormatAt(context) | 캐럿이 닿은 모든 wing 기능을 걷습니다 — 마크에 더해 블록 속성(정렬·드롭 캡), 블록 종류(제목 → 문단), 리스트까지. 물건 블록(표·이미지·코드)은 건드리지 않습니다: 구조는 서식이 아닙니다. clearFormat() wing 이 이것을 씁니다 |
자리 술어
| 하는 일 | |
|---|---|
isPlainBlock(context) | 캐럿이 맨 바깥 블록(문단·제목)에 있는지 — 리스트 항목이나 표 칸 안이 아닌지. 블록을 갈아 끼우는 자동 변환의 자리 조건입니다 |
검증·아이콘·해시
| 하는 일 | |
|---|---|
safeUrl(raw, { allowLocal }) | 문서에 남길 주소인지 확인하고 다듬습니다. http(s) 절대 주소와 같은 사이트 상대 경로만 받습니다. allowLocal 을 켜면 blob: 과 data:image/… 도 받습니다 |
svgIcon(body, strokeWidth?) · markIcon(body) · blockIcon(body) | SVG 조각을 아이콘 주소로 굳힙니다 |
isIconUrl(icon) · paintIcon(doc, icon, label) | 아이콘이 주소인지 가리고, 그릴 엘리먼트를 만듭니다 |
TRASH_ICON | 블록 삭제 커맨드가 전부 나눠 쓰는 쓰레기통 아이콘 |
sha1Hex(bytes) | SHA-1. 업로드류가 파일 지문을 만들 때 씁니다 |
EPHEMERAL_PREFIX · isEphemeralTag(tag) | 편집 중 전용 임시 마크업의 태그 접두사 |
safeUrl 의 allowLocal 은 업로드 미리보기와 데모에서만 켜세요. blob:·data: 주소는 그 페이지를 벗어나면 의미가 없습니다. data: 는 그림만 통과합니다 — data:text/html 은 그림이 아니라 문서를 실어 나릅니다.
그 밖에 함께 나오는 것
import type {
NabiWing, InlineMarkSpec, BlockSpec, ContentSpec, BlockAttributeSpec,
CommandContext, CaretBlock, WingCommand, WingNames, WingGroup,
KeyBinding, KeyRunResult, InputRule, GridPickerSpec, PromptSpec,
EditorHandle, EditorLock, FileDrop, UIService, UIPopupHandle, NoticeTone, Locale,
} from 'nabi-note'WingRegistry 와 NABI_CSS·NABI_STYLE_ID 도 나옵니다 — 시트를 직접 다루거나 빌드 시점에 뽑아 쓸 때입니다.
viewer — 에디터 밖에서 도는 동작
viewer?(handle: ViewerHandle): (() => void) | voidsetup 이 편집 중의 훅이라면 viewer 는 저장된 HTML 을 그리는 쪽의 훅입니다. 나가는 값은 자바스크립트 없이 그대로 읽히는 HTML 이고 그것이 원칙이지만, 표 정렬이나 이미지 클릭 확대처럼 보는 쪽에서만 뜻이 있는 상호작용은 그 자리에 남길 것이 없습니다. 이 훅이 그 예외이고, 호스트가 nabiViewer 로 등록한 날개에 한해 돕니다 — "등록하지 않은 것은 존재하지 않는다" 가 보는 쪽에도 그대로 섭니다.
받는 손잡이는 EditorHandle 이 아니라 ViewerHandle 입니다. 편집기가 없는 페이지라 캐럿도 편집 관문도 없습니다.
| 칸 | 무엇입니까 |
|---|---|
root | 저장된 HTML 이 그려진 뿌리 (.nabi-note 를 단 컨테이너) |
document | 그 문서 |
locale | 버튼 aria-label 같은 표시 문구가 따를 언어 |
ui | 떠 있는 UI — 편집 중과 같은 규칙으로 띄웁니다 |
viewer(handle) {
const detachers = [...handle.root.querySelectorAll('table[data-nabi-sortable]')]
.map((table) => attachSort(table as HTMLTableElement, handle))
return () => { for (const detach of detachers) detach() }
}- 붙인 것은 해제 함수가 전부 되돌립니다 — 꽂은 버튼, 바꾼 행 순서, 단 속성까지. 해제한 뒤의 DOM 이 붙이기 전과 같아야 합니다.
- 저장값에는 한 글자도 남기지 않습니다. 문서에 남는 것은 표식 속성 (
data-nabi-sortable)뿐이고, 버튼도 정렬 상태도 보는 쪽의 순간 상태입니다. - 던져도 그 날개만 건너뜁니다. 한 날개의 오류가 페이지를 죽이지 않습니다.
- 에디터에서는 불리지 않습니다. 미리보기는 보는 쪽이므로 불립니다.
편집 대상 엘리먼트에 뷰어를 붙이지 마세요
붙어 있는 동안의 DOM 을 저장하면 뷰어가 넣은 것이 값에 굳습니다. 보는 쪽은 읽기 전용 사본이어야 합니다.
안쪽 날개들이 더 쓰는 것
번들 날개는 꽃의 더 넓은 표면을 씁니다 — 블록 전환(setBlockType), 리스트 토글· 들여쓰기(toggleContainer·indentListItems), 표 커맨드 일습, 캐럿 읽기(caretBlock· caretAtBlockStart), 블록 벗어나기(leaveBlock), 값을 가진 마크 걸기 (applyMarkWithValue·markAtCaret), 로케일 보간(t·resolveName) 같은 것들입니다.
이 중 일부는 아직 패키지 진입점(nabi-note)으로 나오지 않습니다. 다음 문단이 그때 할 일입니다.
계약이 부족하면 — 어디를 넓힙니까
날개를 만들다가 "이건 코어만 할 수 있는데" 싶은 순간이 옵니다. 그때 몰래 우회하지 마세요. 계약에 없는 것을 억지로 뚫으면 다음 버전에서 조용히 깨집니다.
넓혀야 할 자리는 무엇이 부족한가에 따라 다릅니다.
| 부족한 것 | 넓힐 곳 |
|---|---|
| "이 코어 함수를 쓰고 싶다" (블록 전환·표 조작·캐럿 읽기) | 꽃 — src/flower.ts 에 재수출을 더하고 진입점에서 내보냅니다 |
| "이런 훅이 있으면 되는데" (클릭·파일·붙여넣기 같은 새 갈래) | 계약 — NabiWing 에 칸을 하나 냅니다 |
"이 규격에 칸이 하나 모자라다" (appliesTo 같은 것) | 계약 — 그 규격 인터페이스를 넓힙니다 |
| "코어 파일을 고쳐야 한다" | 그것이 곧 계약이 부족하다는 신호입니다 |
지금 있는 훅들 상당수가 그렇게 생겼습니다.
- 체크박스를 누르는 길이 없어서 →
onClick이 생겼습니다. - 파일 드롭을 받을 길이 없어서 →
handleFiles가 생겼습니다. - 유튜브 주소 상자를 직접 그리고 있어서 →
prompt.validate와preview가 생겼습니다. - 캐럿 진입점을
content·empty로 추론하다 어긋나서 →caretEntry가 생겼습니다. - 정렬 버튼이 "보이는데 눌러도 아무 일이 없어서" →
appliesTo를 노출 판정도 함께 보게 됐습니다.
전부 코어를 고친 것이 아니라 계약을 넓힌 것입니다. 그래서 서드파티 날개도 같은 날 같은 능력을 얻었습니다.
새 날개를 만들 때의 확인 목록
- 팩토리 함수로 만들었습니까. 상태는 클로저 안에 있습니까.
id는 소문자와 하이픈뿐입니까.names.en은 대문자로 시작합니까.- 스캔이 맞는 태그를 잡습니까. 출력이 정규 태그입니까.
- 등록하지 않았을 때 평문으로 떨어집니까.
- 필터를 두 번 지나도 결과가 같습니까 (
filter(filter(x)) === filter(x)). - 내보냈다 되읽었을 때 값이 그대로입니까 (
fromSourceHtml(toOutputHtml(x)) === x). - 키·클릭 계약이 그 키의 본래 동작을 제대로 막거나 넘깁니까.
setup이 만든 것을 정리 함수가 전부 되돌립니까.- 문서를 바꾼 자리마다
commit()이나true/'changed'가 있습니까.