NABI NOTE
문서

setup 훅과 꽃 (flower)

선언 칸으로 표현되지 않는 것은 setup 에서 직접 만듭니다. 그리고 그때 쓰는 코어 기능은 꽃(flower) 이라는 문 하나로만 들어옵니다.

이름의 뜻 — 꽃은 몸통이 날개를 위해 피워 둔 자리입니다. 외부 라이브러리가 아니라 코어가 골라 내준 자기 표면입니다. 날개는 꽃에서만 꿀을 얻습니다.


setup

ts
setup?(editor: EditorHandle): (() => void) | void

mount 직후 한 번 불립니다. 돌려준 함수는 destroy() 때 불립니다 — 만든 것은 반드시 여기서 되돌립니다.

ts
setup(editor) {
  const off = editor.on('change', sync)
  return () => off()
}

업로드 날개는 여기서 인스턴스 상태를 붙들고 정리합니다.

ts
setup(handle) {
  editor = handle
  controller = new AbortController()

  return () => {
    // 진행 중인 업로드에 그만두라고 알립니다. 잠금은 코어가 destroy 때 걷어갑니다
    controller.abort()
    // 아직 걷히지 않은 자리표시자의 미리보기 주소를 전부 되돌립니다
    for (const record of active) releasePreview(record)
    active.clear()
    editor = null
  }
},

코드 하이라이팅도 같은 모양입니다 — change 를 듣고 한 프레임에 한 번만 칠하며, 정리 함수가 구독과 예약된 프레임을 함께 걷습니다.

ts
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 와 별개 상태입니다)
composingIME 조합 중인지
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 가 내보내는 것 중 날개를 만들 때 쓰는 것들입니다. 번들 날개와 서드파티 날개가 같은 문을 씁니다 — 안쪽에만 열려 있는 지름길은 없습니다.

ts
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)편집 중 전용 임시 마크업의 태그 접두사

safeUrlallowLocal업로드 미리보기와 데모에서만 켜세요. blob:·data: 주소는 그 페이지를 벗어나면 의미가 없습니다. data: 는 그림만 통과합니다 — data:text/html 은 그림이 아니라 문서를 실어 나릅니다.

그 밖에 함께 나오는 것

ts
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'

WingRegistryNABI_CSS·NABI_STYLE_ID 도 나옵니다 — 시트를 직접 다루거나 빌드 시점에 뽑아 쓸 때입니다.


viewer — 에디터 밖에서 도는 동작

ts
viewer?(handle: ViewerHandle): (() => void) | void

setup 이 편집 중의 훅이라면 viewer저장된 HTML 을 그리는 쪽의 훅입니다. 나가는 값은 자바스크립트 없이 그대로 읽히는 HTML 이고 그것이 원칙이지만, 표 정렬이나 이미지 클릭 확대처럼 보는 쪽에서만 뜻이 있는 상호작용은 그 자리에 남길 것이 없습니다. 이 훅이 그 예외이고, 호스트가 nabiViewer 로 등록한 날개에 한해 돕니다 — "등록하지 않은 것은 존재하지 않는다" 가 보는 쪽에도 그대로 섭니다.

받는 손잡이는 EditorHandle 이 아니라 ViewerHandle 입니다. 편집기가 없는 페이지라 캐럿도 편집 관문도 없습니다.

무엇입니까
root저장된 HTML 이 그려진 뿌리 (.nabi-note 를 단 컨테이너)
document그 문서
locale버튼 aria-label 같은 표시 문구가 따를 언어
ui떠 있는 UI — 편집 중과 같은 규칙으로 띄웁니다
ts
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.validatepreview 가 생겼습니다.
  • 캐럿 진입점을 content·empty 로 추론하다 어긋나서 → caretEntry 가 생겼습니다.
  • 정렬 버튼이 "보이는데 눌러도 아무 일이 없어서" → appliesTo 를 노출 판정도 함께 보게 됐습니다.

전부 코어를 고친 것이 아니라 계약을 넓힌 것입니다. 그래서 서드파티 날개도 같은 날 같은 능력을 얻었습니다.


새 날개를 만들 때의 확인 목록

  1. 팩토리 함수로 만들었습니까. 상태는 클로저 안에 있습니까.
  2. id 는 소문자와 하이픈뿐입니까. names.en 은 대문자로 시작합니까.
  3. 스캔이 맞는 태그를 잡습니까. 출력이 정규 태그입니까.
  4. 등록하지 않았을 때 평문으로 떨어집니까.
  5. 필터를 두 번 지나도 결과가 같습니까 (filter(filter(x)) === filter(x)).
  6. 내보냈다 되읽었을 때 값이 그대로입니까 (fromSourceHtml(toOutputHtml(x)) === x).
  7. 키·클릭 계약이 그 키의 본래 동작을 제대로 막거나 넘깁니까.
  8. setup 이 만든 것을 정리 함수가 전부 되돌립니까.
  9. 문서를 바꾼 자리마다 commit() 이나 true/'changed' 가 있습니까.

다음 문서

  • 시작하기 — 날개의 기본 칸들
  • 용어 — wing · flutter · soul · sourceHtml · outputHtml