NABI NOTE
문서

값을 먼저 받는 UI 와 동작

툴바 버튼이나 @ 메뉴에서 날개(wing)를 고르면 기본은 토글입니다 — 굵게가 켜지고, 문단이 제목이 됩니다. 하지만 표는 몇 행 몇 열인지, 이미지는 어느 주소인지 먼저 물어야 합니다. 이 문서의 칸들이 그 자리입니다.

눌렀을 때
picker크기 선택 격자가 뜹니다
prompt한 줄 입력창이 뜹니다
palette견본판(palette)이 뜹니다 — 미리 정해진 것 중 하나를 고릅니다
activate날개가 통째로 처리합니다
commands(버튼이 아니라) 상황 줄(context row)에 동작이 뜹니다
onClick(버튼이 아니라) 본문 클릭을 가로챕니다

넷 중 하나라도 있으면 토글이 막힙니다

picker·prompt·palette·activate 가 있으면 toggle()false 를 돌려줍니다. 크기나 주소나 색을 모르는 채로 빈 껍데기가 문서에 들어가는 것을 막기 위해서입니다.


picker — 크기 선택 격자

ts
interface GridPickerSpec {
  readonly kind: 'grid'
  readonly maxRows?: number       // 기본 8
  readonly maxColumns?: number    // 기본 8
  run(context: CommandContext, rows: number, columns: number): boolean
}

표를 넣을 때 뜨는 그 격자입니다. 날개는 최대 크기와 실행 함수만 적고, DOM 과 마우스·키보드 조작은 전부 코어가 그립니다.

ts
picker: {
  kind: 'grid',
  maxRows: 8,
  maxColumns: 8,
  run(context, rows, columns) {
    const created = createTable(context.document, rows, columns, true)
    if (!insertBlockAt(context, created)) return false

    // 캐럿은 이어 쓸 문단이 아니라 첫 칸으로 — 표를 만들었으면 바로 채우기 시작합니다
    const first = created.querySelector('tr > *')
    if (first) caretToStart(context.root, first as HTMLElement)
    return true
  },
},
  • run문서를 실제로 바꿨으면 true 를 돌려줍니다. 그러면 코어가 편집 마무리 (정규화·되돌리기(undo) 지점·onChange)를 합니다.
  • 격자는 아무것도 고르지 않은 상태로 열립니다. 상자는 대개 키로 열리고 그 키의 짝 (IME 조합 확정이 만드는 두 번째 Enter)이 곧바로 도착하기 때문입니다. 예전에는 그것이 1×1 표를 즉시 만들었습니다.

prompt — 한 줄 입력창

ts
interface PromptSpec {
  readonly kind: 'prompt'
  readonly placeholder?: WingNames
  readonly confirm?: WingNames
  validate?(value: string): boolean
  preview?(value: string, host: HTMLElement, context: CommandContext): void
  run(context: CommandContext, value: string): boolean
}

window.prompt 는 iframe·확장 프로그램 환경에서 막히는 경우가 있어 직접 그립니다.

ts
prompt: {
  kind: 'prompt',
  placeholder: { ko: '이미지 주소를 붙여넣으세요', en: 'Paste an image URL' },
  confirm: { ko: '넣기', en: 'Insert' },
  run(context, value) {
    const source = safeUrl(value)
    if (!source) return false
    return insertBlockAt(context, createImage(context.document, source))
  },
},

placeholderconfirmnames 와 같은 로케일 사전입니다 — 문자열 하나가 아닙니다.

validate — 확정해도 되는지

ts
validate?(value: string): boolean

false 인 동안 확인 버튼이 눌리지 않고 Enter 로도 뚫리지 않습니다. 상자는 열린 채 남습니다. 선언하지 않으면 빈 값만 걸러집니다.

ts
validate: (value) => youtubeVideoId(value) !== null

run 에서 한 번 더 확인하는 쪽을 권합니다 — validate 가 막지만 방어적으로 두는 것이 번들 날개들의 관례입니다.

preview — 입력창 아래 미리보기

ts
preview?(value: string, host: HTMLElement, context: CommandContext): void

입력할 때마다 불립니다. host 는 코어가 마련한 빈 영역이고, 그 안을 전부 날개가 그립니다.

ts
function renderPreview(value: string, host: HTMLElement, context: CommandContext): void {
  const doc = host.ownerDocument
  const id = youtubeVideoId(value)

  if (!id) {
    delete host.dataset['video']
    const hint = doc.createElement('p')
    hint.className = 'nabi-youtube__hint'
    const key = value.trim() === '' ? 'hintEmpty' : 'hintInvalid'
    hint.textContent = resolveName(TEXT[key], context.locale) ?? TEXT[key].en
    host.replaceChildren(hint)
    return
  }

  // 같은 id 면 iframe 을 갈아 끼우지 않습니다 — 타이핑마다 다시 로드되지 않게
  if (host.dataset['video'] === id) return
  host.dataset['video'] = id
  host.replaceChildren(createEmbed(doc, id))
}
  • 상태가 필요하면 host.dataset 에 남깁니다. 날개 쪽에 필드로 들고 있으면 상자가 닫힌 뒤에도 남습니다.
  • context 는 상자를 열던 순간의 것입니다 (로케일·문서).
  • 타이핑마다 불립니다. 무거운 일(리소스를 다시 받는 일)은 값이 실제로 달라졌을 때만 하세요.

값 상자는 열던 순간의 자리를 돌려줍니다

상자가 뜨면 캐럿(caret)이 그 입력으로 옮겨갑니다. 코어가 버튼을 누른 순간의 CommandContext선택 범위 사본을 함께 붙들었다가 run 직전에 그 범위를 되돌립니다. 블록(block)을 넣는 날개는 context.block 만으로 충분하지만, 글자에 씌우는 날개(링크)는 "어느 글자였는가" 가 곧 대상이라 이 되돌림이 없으면 아무 일도 못 합니다.


palette — 견본판

ts
interface PaletteSpec {
  readonly kind: 'palette'
  readonly options: readonly PaletteOption[]
  readonly columns?: number    // 한 줄에 몇 개. 기본 6
  run(context: CommandContext, value: string): boolean
}

interface PaletteOption {
  readonly value: string       // run 에 넘어가는 값
  readonly names: WingNames    // tooltip 과 스크린 리더가 읽습니다
  readonly swatch?: string     // 견본에 칠할 CSS 색
}

형광펜·글자색·글자 크기가 쓰는 UI 입니다. 고를 것이 대여섯 개로 정해져 있을 때, 격자는 크기를 묻는 것이고 입력창은 이름을 외워 치게 하므로 세 번째 갈래를 둡니다.

ts
palette: {
  kind: 'palette',
  columns: 6,
  options: HIGHLIGHT_COLORS.map((color) => ({
    value: color,
    names: COLOR_NAMES[color]!,
    swatch: `var(--nabi-hl-${color})`,
  })),
  run(context, value) {
    const color = colorOf(value)
    if (color === '') return false
    return applyMarkWithValue(context, TAG, { [HIGHLIGHT_ATTR]: color }, '')
  },
},
  • swatch 를 비우면 색 대신 로케일 이름을 글자로 그립니다. 글자 크기 견본판이 그 길을 씁니다 — 크기는 색으로 표현할 수 없어서, 칸마다 자기 크기로 이름이 그려집니다. 칸의 모양은 시트가 data-value 로 구별해 잡습니다.
  • swatch 에는 시트 토큰 참조만 넘기세요 (var(--nabi-hl-yellow)). 코어가 style.setProperty 로만 넣으므로 마크업이 되지는 않지만, 임의의 CSS 가 흘러드는 문을 열지 않습니다.
  • names 는 색만으로 뜻을 전하지 않기 위한 칸입니다. tooltip 과 스크린 리더가 이것을 읽고, 짚은 칸은 aria-selected 로도 드러납니다.
  • 방향키·Home·End 로 옮기고 Enter·Space 로 확정합니다. 코어가 그립니다.

견본판을 activate 로 직접 열지 마세요

activate 안에서 ui.popup 을 열면 견본을 누르는 순간 선택 영역이 이미 사라져 있습니다 — 상자로 포커스가 옮겨갔기 때문입니다. 코어는 여는 순간의 범위를 붙들어 두고 확정 시점에 되돌려 주는데, 그 범위는 컨트롤러의 것이라 날개가 되돌릴 길이 없습니다. 격자·입력창과 같은 이유로 선언으로 두고 코어가 열고 되돌리고 커밋합니다.


activate — 완전 커스텀 동작

ts
activate?(context: CommandContext, anchor: () => DOMRect | null): boolean

picker·prompt 로 표현할 수 없는 것을 직접 처리합니다. 파일 선택창을 열거나, 구조를 갖춘 블록을 만들어 넣거나, context.ui 로 자기 상자를 엽니다.

ts
// 서식 지우기 — UI 가 없습니다. 캐럿이 닿은 서식을 걷고 끝입니다
activate(context) {
  if (!clearFormatAt(context)) return false
  context.commit()
  return true
},
ts
// 접기 상자 — 토글로는 만들 수 없습니다. 태그만 갈아 끼우면 요약 칸이 없는 상자가 됩니다
activate(context): boolean {
  const box = createDetails(context.document)
  if (!insertBlockAt(context, box)) return false

  // 캐럿은 이어 쓸 문단이 아니라 제목 칸으로
  const summary = summaryOf(box)
  if (summary) caretToStart(context.root, summary)
  context.commit()
  return true
},
ts
// 업로드 — 파일 선택창을 열고, 고른 파일을 handleFiles 와 같은 함수로 넘깁니다
activate(context): boolean {
  const input = context.document.createElement('input')
  input.type = 'file'
  input.multiple = true
  input.style.display = 'none'
  input.addEventListener('change', () => {
    const files = [...(input.files ?? [])]
    input.remove()
    if (files.length > 0) handleFiles(files, context, { source: 'pick', range: null })
  }, { once: true })

  ;(context.document.body ?? context.document.documentElement).append(input)
  input.click()
  return true
},

activate 는 편집 관문을 대신 타 주지 않습니다

문서를 바꿨으면 context.commit() 을 직접 불러야 합니다. 코어는 이 훅(hook)이 언제 문서를 바꿀지(당장인지, 파일을 고른 뒤인지) 알 수 없기 때문입니다. 부르지 않으면 그 편집은 되돌릴 수 없고 onChange 도 나가지 않습니다.

  • 돌려주는 값은 "UI 를 열었거나 일을 했으면 true" 입니다.
  • anchor 는 누른 버튼(또는 메뉴가 있던 자리)입니다. ui.popupanchor 로 그대로 넘기면 상자가 그 옆에 뜹니다.

commands — 상황 줄의 동작들

토글 하나로 표현할 수 없는 것들입니다 — 표의 "행 추가", 코드의 "언어", 링크의 "해제".

ts
interface WingCommand {
  readonly id: string
  readonly names: WingNames
  readonly icon?: string
  isAvailable?(context: CommandContext): boolean
  render?(host: HTMLElement, context: CommandContext): void
  run?(context: CommandContext): boolean
}
ts
{
  id: 'link-remove',
  names: { ko: '링크 해제', en: 'Remove link' },
  icon: UNLINK_ICON,
  isAvailable: (context) => markAtCaret(context, 'a') !== null,
  run(context) {
    const mark = markAtCaret(context, 'a')
    if (!mark) return false
    mark.replaceWith(...mark.childNodes)   // 껍데기만 벗깁니다 — 글자는 남습니다
    return true
  },
}

isAvailable

true 인 것만 상황 줄에 뜹니다. 없으면 언제나 쓸 수 있는 것으로 봅니다.

ts
isAvailable: (context) => tableAt(context.cell) !== null   // 캐럿이 표 안일 때만
isAvailable: (context) => context.block?.tagName === 'PRE' // 캐럿이 코드 안일 때만

같은 술어를 run 에서도 다시 확인하세요 — 상황 줄이 그려진 뒤 캐럿이 움직였을 수 있습니다.

render — 버튼 대신 직접 그리는 컨트롤

ts
render?(host: HTMLElement, context: CommandContext): void

host 는 코어가 마련한 빈 자리입니다. 코드 블록의 언어 입력창이 그 예입니다 — 언어는 고르는 것이 아니라 치는 것이고, 목록은 제안일 뿐이라 가둘 수 없기 때문입니다.

ts
render(host, context) {
  const doc = context.document
  // 그린 순간의 코드 블록을 붙들어 둡니다 — 입력창으로 포커스가 가면 캐럿이 없습니다
  const block = context.block
  if (!block) return

  const input = doc.createElement('input')
  input.type = 'text'
  input.className = 'nabi__control-input'
  input.value = block.getAttribute('data-nabi-lang') ?? ''
  /* … 값이 바뀌면 block 에 반영하고 context.commit() … */
  host.append(input)
},
  • 이 칸이 있으면 run 은 쓰이지 않습니다.
  • 그린 것의 상호작용은 날개가 맡습니다. 문서를 바꿨으면 context.commit() 을 부르세요.
  • 상황 줄은 쓸 수 있는 커맨드 목록이 달라질 때만 다시 그립니다. 게다가 코어는 컨트롤 안에 포커스가 있는 동안에는 절대 다시 그리지 않습니다 — 타이핑 도중에 입력창이 사라지지 않습니다.
  • 컨트롤이 자기 input 을 가지면 편집 영역 밖이라 코어의 IME 관문이 닿지 않습니다. 리스너 첫 줄에서 isComposing 을 직접 확인하세요.

블록 삭제 커맨드는 찍어 냅니다

캐럿이 들어가지 않는 물건 블록(object block)은 지우면 캐럿 놓을 자리가 사라집니다. 그 규칙이 날개마다 복제되지 않게 꽃(flower)이 팩토리 하나를 냅니다.

ts
commands: [
  deleteBlockCommand({
    id: 'youtube-delete',
    names: { ko: '영상 삭제', en: 'Delete video' },
    find: (context) => findAdjacentBlock(context, isEmbed),
  }),
],

빈 문단으로 갈아 끼우고 캐럿을 그 안에 놓는 것까지 해 줍니다. 아이콘도 공통(TRASH_ICON) 입니다.


onClick — 본문 클릭 가로채기

ts
onClick?(event: MouseEvent, context: CommandContext): boolean

커맨드 버튼이 아니라 본문을 직접 눌러야 하는 동작에 씁니다. 체크리스트의 네모, 접기 상자의 표식, 이미지의 크기 상자가 그것입니다.

ts
// 체크리스트 — 항목 앞의 네모 클릭
onClick(event, context) {
  const block = closestBlock(context.root, event.target as Node)
  if (block?.getAttribute(TASK_FLAG) !== TASK_VALUE) return false

  const item = closestItem(block, event.target as Node)
  if (!item?.hasAttribute(TASK_CHECKED_ATTR)) return false
  if (!inCheckboxArea(item, event.clientX)) return false

  const checked = item.getAttribute(TASK_CHECKED_ATTR) === 'true'
  item.setAttribute(TASK_CHECKED_ATTR, String(!checked))
  return true
},
  • 등록 순서대로 물어보고 처음 true 를 낸 날개가 가져갑니다. 그때 코어가 기본 동작을 막고 변경을 알립니다.
  • true 는 "문서를 바꿨다" 입니다. 캐럿만 옮겼거나 상자만 열었다면 그것도 true 로 답할 수 있지만, 되돌리기 지점이 생기는 것을 원치 않으면 false 를 돌려주세요. 접기 상자가 그렇게 합니다 — 표식 자리가 아닌 클릭은 preventDefault() 만 하고 false 입니다.
  • 표식이 ::before 로 그려져 히트 대상 엘리먼트가 없으면 좌표로 재야 합니다. 그때 폭은 CSS 와 같은 단위로 계산하세요. 체크박스 판정이 px 로 박혀 있다가 큰 글꼴에서 어긋난 적이 있습니다.
  • 물건 블록을 통째로 고르는 일은 코어가 이미 했습니다. 이미지 날개의 onClick 은 자기 상자만 엽니다.

context.ui — 직접 여는 상자

picker·prompt 로 표현되지 않는 UI 는 activatecommands 안에서 직접 엽니다. 위치 잡기·스크롤 따라가기·Escape·바깥 클릭 닫기·정리는 전부 코어가 맡습니다.

메서드무엇입니까
ui.popup({ anchor, render, className?, onClose? })임의 내용 상자. { modal: true } 면 화면 전체를 덮습니다 (라이트박스)
ui.grid({ anchor, onPick, maxRows?, maxColumns? })크기 선택 격자
ui.prompt({ anchor, onSubmit, placeholder?, confirm? })한 줄 입력창
ui.notice({ message, tone?, duration? })잠깐 떴다 사라지는 알림
  • anchor 는 함수입니다. 화면이 움직일 때마다 다시 불러 따라가고, null 을 돌려주면 기준점이 사라진 것으로 보고 닫습니다.
  • noticemessage언제나 평문(plain text)입니다. 파일명 같은 남의 값이 섞이므로 마크업으로 해석하지 않습니다. tone'info'(기본)·'error', duration 은 ms 이고 0 이면 직접 닫을 때까지 남습니다.
  • 닫기(×) 버튼은 두지 않는 것이 이 사이트 상자들의 관례입니다.
  • 선언형으로 되는 것은 선언형으로 하세요. 유튜브 주소 상자는 원래 직접 그리던 것을 prompt.validate/preview 로 옮긴 선례입니다.

CommandContext — 실행할 때 받는 것

이 문서의 모든 훅이 받는 객체입니다.

무엇입니까
root편집 영역 루트
document새 엘리먼트를 만들 문서. globalThis.document 를 쓰지 마세요
caret디스패치 시점에 계산된 캐럿 자리 (CaretBlock)
block캐럿이 놓인 최상위 블록 (caret.block 의 지름길)
cell캐럿이 놓인 말단 칸 (td·li). 보통 블록이면 블록 자신
registry문서 스키마 조회 표면 — "이 태그는 무엇인가"
ui위의 UI 서비스
locale표시 언어
commit()문서를 직접 바꾼 뒤 부릅니다

context.caret 은 변형 뒤에 낡습니다

문서를 바꾼 다음에도 캐럿 자리가 필요하면 그 자리에서 다시 재야 합니다. 그 재조회는 낭비가 아니라 바뀐 DOM 을 읽는 것입니다. 체크리스트의 자동 변환(input rule)이 그 선례입니다 — 리스트를 만든 뒤에 항목을 다시 찾아 체크 상태를 붙입니다.

registry 에는 날개 목록을 열람하는 능력이 없습니다. 날개가 다른 날개의 커맨드와 훅을 뒤지는 뒷문이 되기 때문입니다.