값을 먼저 받는 UI 와 동작
툴바 버튼이나 @ 메뉴에서 날개(wing)를 고르면 기본은 토글입니다 — 굵게가 켜지고, 문단이 제목이 됩니다. 하지만 표는 몇 행 몇 열인지, 이미지는 어느 주소인지 먼저 물어야 합니다. 이 문서의 칸들이 그 자리입니다.
| 칸 | 눌렀을 때 |
|---|---|
picker | 크기 선택 격자가 뜹니다 |
prompt | 한 줄 입력창이 뜹니다 |
palette | 견본판(palette)이 뜹니다 — 미리 정해진 것 중 하나를 고릅니다 |
activate | 날개가 통째로 처리합니다 |
commands | (버튼이 아니라) 상황 줄(context row)에 동작이 뜹니다 |
onClick | (버튼이 아니라) 본문 클릭을 가로챕니다 |
넷 중 하나라도 있으면 토글이 막힙니다
picker·prompt·palette·activate 가 있으면 toggle() 이 false 를 돌려줍니다. 크기나 주소나 색을 모르는 채로 빈 껍데기가 문서에 들어가는 것을 막기 위해서입니다.
picker — 크기 선택 격자
interface GridPickerSpec {
readonly kind: 'grid'
readonly maxRows?: number // 기본 8
readonly maxColumns?: number // 기본 8
run(context: CommandContext, rows: number, columns: number): boolean
}표를 넣을 때 뜨는 그 격자입니다. 날개는 최대 크기와 실행 함수만 적고, DOM 과 마우스·키보드 조작은 전부 코어가 그립니다.
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 — 한 줄 입력창
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·확장 프로그램 환경에서 막히는 경우가 있어 직접 그립니다.
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))
},
},placeholder 와 confirm 은 names 와 같은 로케일 사전입니다 — 문자열 하나가 아닙니다.
validate — 확정해도 되는지
validate?(value: string): booleanfalse 인 동안 확인 버튼이 눌리지 않고 Enter 로도 뚫리지 않습니다. 상자는 열린 채 남습니다. 선언하지 않으면 빈 값만 걸러집니다.
validate: (value) => youtubeVideoId(value) !== nullrun 에서 한 번 더 확인하는 쪽을 권합니다 — validate 가 막지만 방어적으로 두는 것이 번들 날개들의 관례입니다.
preview — 입력창 아래 미리보기
preview?(value: string, host: HTMLElement, context: CommandContext): void입력할 때마다 불립니다. host 는 코어가 마련한 빈 영역이고, 그 안을 전부 날개가 그립니다.
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 — 견본판
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 입니다. 고를 것이 대여섯 개로 정해져 있을 때, 격자는 크기를 묻는 것이고 입력창은 이름을 외워 치게 하므로 세 번째 갈래를 둡니다.
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 — 완전 커스텀 동작
activate?(context: CommandContext, anchor: () => DOMRect | null): booleanpicker·prompt 로 표현할 수 없는 것을 직접 처리합니다. 파일 선택창을 열거나, 구조를 갖춘 블록을 만들어 넣거나, context.ui 로 자기 상자를 엽니다.
// 서식 지우기 — UI 가 없습니다. 캐럿이 닿은 서식을 걷고 끝입니다
activate(context) {
if (!clearFormatAt(context)) return false
context.commit()
return true
},// 접기 상자 — 토글로는 만들 수 없습니다. 태그만 갈아 끼우면 요약 칸이 없는 상자가 됩니다
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
},// 업로드 — 파일 선택창을 열고, 고른 파일을 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.popup의anchor로 그대로 넘기면 상자가 그 옆에 뜹니다.
commands — 상황 줄의 동작들
토글 하나로 표현할 수 없는 것들입니다 — 표의 "행 추가", 코드의 "언어", 링크의 "해제".
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
}{
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 인 것만 상황 줄에 뜹니다. 없으면 언제나 쓸 수 있는 것으로 봅니다.
isAvailable: (context) => tableAt(context.cell) !== null // 캐럿이 표 안일 때만
isAvailable: (context) => context.block?.tagName === 'PRE' // 캐럿이 코드 안일 때만같은 술어를 run 에서도 다시 확인하세요 — 상황 줄이 그려진 뒤 캐럿이 움직였을 수 있습니다.
render — 버튼 대신 직접 그리는 컨트롤
render?(host: HTMLElement, context: CommandContext): voidhost 는 코어가 마련한 빈 자리입니다. 코드 블록의 언어 입력창이 그 예입니다 — 언어는 고르는 것이 아니라 치는 것이고, 목록은 제안일 뿐이라 가둘 수 없기 때문입니다.
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)이 팩토리 하나를 냅니다.
commands: [
deleteBlockCommand({
id: 'youtube-delete',
names: { ko: '영상 삭제', en: 'Delete video' },
find: (context) => findAdjacentBlock(context, isEmbed),
}),
],빈 문단으로 갈아 끼우고 캐럿을 그 안에 놓는 것까지 해 줍니다. 아이콘도 공통(TRASH_ICON) 입니다.
onClick — 본문 클릭 가로채기
onClick?(event: MouseEvent, context: CommandContext): boolean커맨드 버튼이 아니라 본문을 직접 눌러야 하는 동작에 씁니다. 체크리스트의 네모, 접기 상자의 표식, 이미지의 크기 상자가 그것입니다.
// 체크리스트 — 항목 앞의 네모 클릭
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 는 activate 나 commands 안에서 직접 엽니다. 위치 잡기·스크롤 따라가기·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을 돌려주면 기준점이 사라진 것으로 보고 닫습니다.notice의message는 언제나 평문(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 에는 날개 목록을 열람하는 능력이 없습니다. 날개가 다른 날개의 커맨드와 훅을 뒤지는 뒷문이 되기 때문입니다.