NABI NOTE
문서

키·자동 변환·붙여넣기·파일

사용자가 버튼을 누르지 않고 일으키는 일들입니다. 키를 치고, 규격 글자를 치고, 붙여넣고, 파일을 끌어다 놓는 길입니다.


keys — 키 하나 가로채기

ts
interface KeyBinding {
  readonly key: string       // KeyboardEvent.key 값 그대로
  readonly shift?: boolean   // 기본 false — Shift 가 눌리지 않은 조합만
  readonly mod?: boolean     // 기본 false — 플랫폼 보조키(Ctrl·Cmd)가 눌린 조합만
  run(context: CommandContext, event: KeyboardEvent): KeyRunResult
}

type KeyRunResult = false | 'handled' | 'changed'

Tab 처럼 자리에 따라 뜻이 달라지는 키에 씁니다. 등록 순서대로 물어보고 처음 소비한 날개가 가져갑니다 — 그 키의 본래 동작과 코어의 기본 처리는 일어나지 않습니다.

ts
// 표 — Tab 은 "다음 칸". 표 끝에서는 잡지 않고 코어에 넘깁니다
keys: [
  {
    key: 'Tab',
    run: (context) => {
      const position = tableAt(context.cell)
      return position && moveToCell(context.root, position, true) ? 'handled' : false
    },
  },
  {
    key: 'Tab',
    shift: true,
    run: (context) => {
      const position = tableAt(context.cell)
      return position && moveToCell(context.root, position, false) ? 'handled' : false
    },
  },
],
ts
// 리스트 — Tab 은 들여쓰기. 문서가 바뀌므로 'changed' 입니다
keys: [
  { key: 'Tab', run: (context) => (indentListItems(context, false) ? 'changed' : false) },
  { key: 'Tab', shift: true, run: (context) => (indentListItems(context, true) ? 'changed' : false) },
],

돌려주는 값이 셋인 이유

true 는 받지 않습니다. "바꿨다" 와 "소비만 했다" 를 구분할 수 없기 때문입니다.

코어가 하는 일
false처리하지 않았습니다다음 날개, 그다음 코어 기본 동작으로 넘어갑니다
'handled'키는 소비했지만 문서는 그대로입니다 (캐럿 이동)아무것도 하지 않습니다 — 되돌리기 지점이 생기지 않습니다
'changed'문서를 바꿨습니다정규화 · 되돌리기 스냅샷 · onChange

'handled' 를 써야 할 자리에 'changed' 를 쓰면 캐럿을 옮길 때마다 되돌리기 기록이 늘어납니다. 접기 상자의 Enter 는 이것을 정확히 가립니다 — 상자를 펼치거나 문단을 만들었으면 'changed', 캐럿만 옮겼으면 'handled' 입니다.

mod — 플랫폼 보조키

윈도우·리눅스의 Ctrl 과 맥의 Cmd같은 것으로 봅니다. 맥에서 Ctrl+방향키 는 OS 가 미션 컨트롤로 가로채므로, Cmd 도 받지 않으면 그 환경에서 기능이 닿지 않습니다.

ts
// 표의 칸 고르기 — 방향 넷 × 조합 셋 (Shift · Mod · Mod+Shift)
cellSelectKey(key, direction, { shift: true })
cellSelectKey(key, direction, { mod: true })
cellSelectKey(key, direction, { mod: true, shift: true })
  • mod: true선언한 규격만 걸립니다. 적지 않은 날개는 보조키가 눌린 순간 조회에서 빠지므로, 이 칸을 몰라도 동작이 달라지지 않습니다.
  • 선언하면 그 자리에서 본래 동작을 빼앗습니다 — 윈도우·리눅스의 낱말 단위 이동, 맥의 줄 처음·끝입니다. 자리를 좁혀서 선언하세요. 표는 칸 안에서만 잡으므로 본문에서는 둘 다 평소대로 동작합니다.
  • Alt 는 열려 있지 않습니다. 알트 조합은 단축키의 영역이고 아직 설계되지 않았습니다.

알아 둘 것

  • shift 를 적지 않으면 Shift 가 눌리지 않은 조합만 잡습니다. mod 도 같습니다.

  • IME 조합 중의 키는 파이프라인에 들어오지 않습니다. 조합 확정 Enter 나 후보 탐색 화살표는 IME 의 것이라 날개가 받을 일도 막을 일도 없습니다.

  • 키가 걸리는 순서는 ① Shift 연타 ② 떠 있는 상자 ③ 되돌리기 ④ 마크의 escapeKeys ⑤ 날개 keys ⑥ 코어의 Tab 마무리입니다.

  • 아무도 안 가져간 Tab 은 포커스 이동을 막고 탭 문자를 찍습니다.

  • 블록 맨 앞의 Enter 는 코어에 넘기세요. 코어는 쪼개지 않고 밀어냅니다 — 위에 빈 문단이 생기고 블록은 껍데기째 내려갑니다. 쪼개면 제목의 글자가 <p> 로 빠지고 빈 <h1> 만 남습니다.

    ts
    // 코드 — 맨 앞에서는 넘깁니다. 단 빈 블록은 예외입니다
    if (caretAtBlockStart(context.root, block) && !isEmptyBlock(block)) return false

    빈 블록을 넘기면 캐럿이 항상 맨 앞이라 Enter 가 영원히 위에 문단만 쌓습니다.


inputRules — 글자만으로 일어나는 자동 변환

ts
interface InputRule {
  readonly trigger: 'space' | 'enter'
  readonly pattern: RegExp
  isAvailable?(context: CommandContext): boolean
  run(context: CommandContext, match: RegExpMatchArray): boolean
}

마크다운처럼 글자를 치는 것만으로 블록이 바뀌는 것들입니다. ---+Enter 는 구분선, #+공백은 제목, - 는 글머리 목록, 1. 은 번호 목록, [ ] ·[x] 는 체크리스트입니다.

ts
// 구분선 — --- 만 있는 줄에서 Enter
inputRules: [
  { trigger: 'enter', pattern: /^-{3,}$/, run: (context) => insertEmptyBlock(context, 'hr') },
],
ts
// 제목 — 정확히 그 개수여야 합니다 (## 는 h1 규칙에 걸리지 않습니다)
inputRules: [
  {
    trigger: 'space',
    pattern: new RegExp(`^#{${level}}$`),
    isAvailable: isPlainBlock,
    run: (context) => setBlockType(context, tag),
  },
],

코어가 하는 일은 넷입니다.

  1. trigger 시점에 캐럿 앞의 블록 평문pattern 에 맞춰 봅니다.
  2. 맞으면 그 키의 본래 동작을 막고, 매치된 글자를 지운 뒤 run 을 부릅니다.
  3. 변환 직전 상태를 기억합니다.
  4. 되돌리기로 이 변환이 취소되면 그 블록에서 같은 규칙을 다시 걸지 않습니다 — 안 그러면 영영 되돌릴 수 없습니다.

자리 조건은 반드시 isAvailable 에서

run 에서 거절하면 이미 글자를 지운 뒤라 공백이 사라집니다. isAvailable 은 지우기 전에 물어보므로, 여기서 false 를 내면 키가 평소대로 동작합니다 — 리스트 안에서 친 # 는 그냥 글자로 남습니다.

  • patterng 플래그를 쓰지 마세요.

  • isPlainBlock 은 "캐럿이 맨 바깥 블록(문단·제목)에 있는가" 입니다. 블록을 갈아 끼우는 규칙(setBlockType)은 이것을 자리 조건으로 씁니다 — 리스트 안에서 돌면 리스트를 통째로 풀어 버리기 때문입니다.

  • runfalse 를 돌려주면 코어가 지운 글자를 되돌려 놓습니다.

  • 변환 뒤에 그 블록을 더 손보려면 캐럿을 다시 재야 합니다. 체크리스트가 그렇습니다.

    ts
    run: (context, match) => {
      if (!rule.run(context, match)) return false
    
      // 변형 뒤 재조회 — run 이 방금 리스트를 만들었습니다. context.caret 은 낡았습니다
      const item = caretBlock(context.root, context.registry)?.editable
      if (item?.tagName === 'LI') {
        item.setAttribute(TASK_CHECKED_ATTR, String(match[1]?.toLowerCase() === 'x'))
      }
      return true
    },

pasteText — 붙여넣은 평문 한 덩어리

ts
pasteText?(text: string, context: CommandContext): HTMLElement | null

클립보드에 공백 없는 문자열 하나만 있을 때 등록 순서대로 물어봅니다. 처음으로 엘리먼트를 돌려준 날개가 가져갑니다. 관심 없으면 null 입니다.

ts
// 유튜브 주소를 붙여넣으면 영상이 됩니다
pasteText(text, context) {
  const id = youtubeVideoId(text)
  return id ? createEmbed(context.document, id) : null
},
ts
// 이미지 주소를 붙여넣으면 이미지가 됩니다
pasteText(text, context) {
  const source = looksLikeImageUrl(text) ? safeUrl(text) : null
  return source ? createImage(context.document, source) : null
},
  • 돌려줄 엘리먼트는 context.document 로 만드세요.
  • 주소를 보고 판정할 때는 확실한 것만 받으세요. 아니라고 판정하면 평문으로 남을 뿐이지만, 아닌 것을 가져가면 깨진 결과가 문서에 박힙니다.
  • 호스트 이름은 문자열 포함으로 검사하지 마세요. youtube.com.evil.test 가 뚫립니다. new URL() 로 파싱해 hostname 을 비교하세요.

pasteHtml — HTML 조각 통째로

ts
pasteHtml?(fragment: DocumentFragment, context: CommandContext): HTMLElement[] | null

text/html 갈래에서 코어 필터보다 먼저, 등록 순서대로 물어봅니다. 처음으로 블록을 돌려준 날개가 가져가고 나머지는 묻지 않습니다.

왜 필터보다 먼저인가 — 필터는 엘리먼트 하나하나를 보고 살릴지 벗길지만 정합니다. 자식 구조를 재배열하지 못합니다. GitHub 처럼 <table> 안에 줄번호 칸과 코드 칸이 나란히 오는 마크업은 필터를 지나고 나면 줄번호가 이미 본문 글자로 섞여 되돌릴 수 없습니다. 원본 모양이 남아 있는 동안 물어봐야 합니다.

ts
pasteHtml(fragment, context) {
  const tables = [...fragment.querySelectorAll('table')]
  if (tables.length === 0) return null

  // 코드 표만 골라 냅니다. 하나라도 아니면 이 조각은 우리 것이 아닙니다
  const found = tables.map((table) => ({ table, lines: codeLinesFromTable(table) }))
  if (found.some((entry) => !entry.lines)) return null

  // 표를 걷어내고도 글자가 남으면 코드 말고 다른 내용이 섞인 것입니다
  const rest = fragment.cloneNode(true) as DocumentFragment
  for (const table of rest.querySelectorAll('table')) table.remove()
  if ((rest.textContent ?? '').trim() !== '') return null

  return found.map(({ table, lines }) => {
    const pre = context.document.createElement('pre')
    for (const [index, line] of lines.entries()) {
      if (index > 0) pre.append(context.document.createElement('br'))
      if (line !== '') pre.append(context.document.createTextNode(line))
    }
    return pre
  })
},
  • 조각은 inert 문서의 것입니다 — 스크립트도 리소스 로드도 일어나지 않습니다. 읽기 전용으로 다루고, 돌려줄 블록은 context.document 로 새로 만드세요.
  • 돌려준 블록도 그대로 믿지 않습니다. 코어가 필터를 한 번 통과시킵니다.
  • null 은 "관심 없음"(다음 날개로), [] 는 "가져갔지만 남길 것이 없음"입니다 — 빈 배열은 다음 날개로 넘어가지 않습니다.
  • 던지면 그 날개만 건너뜁니다. 붙여넣기는 계속됩니다.
  • 오탐이 미탐보다 나쁩니다. 코드 날개는 조각 전체가 코드 표일 때만 가져갑니다 — 글 사이에 낀 표 하나까지 코드로 삼키면 평범한 표 붙여넣기가 망가집니다.

날개가 가져가지 않은 몫은 코어가 합니다

날개가 소유한 블록(제목·코드·리스트·표·임베드)은 통째로 세워지고, 문단만 글자로 풀어 잇습니다. 복사도 마찬가지로 블록 맥락째 실립니다 — 브라우저 기본은 한 블록 안 선택이면 인라인 조각만 실어서, 제목의 글자를 전부 골라 복사해도 "제목이었다" 가 클립보드에 없습니다.


handleFiles — 들어온 파일 가져가기

ts
handleFiles?(files: readonly File[], context: CommandContext, at: FileDrop): boolean

interface FileDrop {
  readonly source: 'paste' | 'drop' | 'pick'
  readonly range: Range | null   // 드롭 지점에서 계산한 캐럿. 그 밖에는 null
}

붙여넣기·드래그&드롭 양쪽에서 불립니다. 등록 순서대로 물어보고 처음 true 를 낸 날개가 가져갑니다.

ts
const handleFiles = (files: readonly File[], context: CommandContext): boolean => {
  if (!editor || editor.locked) return false

  const accepted = accept(files, config, report)
  // 검증에서 다 걸렀어도 우리가 가져간 것입니다 — 파일이 글자로 붙여넣어지면 안 됩니다
  if (accepted.length === 0) return true

  void runner.run(accepted, context)
  return true
}
경로코어가 하는 일
붙여넣기text/html 로 아무것도 안 나왔을 때만 물어봅니다 — 파일은 마지막 갈래입니다
드래그&드롭표시·안내를 띄우고 놓은 자리로 캐럿을 옮긴 뒤 물어봅니다
파일 선택창날개가 activate 에서 열고 같은 훅으로 합류합니다

파일이 마지막인 이유는 잘라내기-붙여넣기가 업로드로 변하는 사고를 막기 위해서입니다.

  • 가져가도 코어는 뒷정리를 하지 않습니다. 업로드는 비동기라 문서를 언제 바꿨는지 코어가 알 수 없습니다. 바꿨으면 context.commit() 을 부르세요.
  • range 는 코어가 이미 캐럿을 옮겨 둔 뒤에 넘겨줍니다. 보통은 context.block 만 봐도 됩니다.
  • 이 훅을 선언한 날개가 하나도 없어도 코어는 파일 드래그의 기본 동작을 막습니다. 막지 않으면 브라우저가 편집 영역을 드롭 대상으로 치지 않고, 그 드롭이 문서로 떨어져 끌어온 파일로 페이지가 이동합니다 — 편집하던 글이 통째로 사라집니다. "받아 갈 사람이 없다" 는 "아무 일도 일어나지 않는다" 여야 하지 "브라우저 마음대로" 가 아닙니다.

오래 걸리는 일은 잠급니다

ts
const lock = editor.lock({ timeout: 60000 })
try {
  /* … 업로드 … */
  lock.heartbeat()          // 아직 살아 있다고 알립니다 — 시한을 다시 셉니다
} finally {
  lock.release()
}

잠금은 readOnly 와 별개입니다. 여러 개가 겹칠 수 있고 마지막이 풀려야 편집이 돌아옵니다. 시한이 안전장치입니다 — heartbeat() 없이 지나면 코어가 스스로 풉니다. destroy() 도 전부 풉니다.

잠긴 동안 문서에 심는 임시 마크업에는 네 가지 규약이 있습니다 — 접두사 태그, 텍스트 노드 금지, commit() 미호출, 잠금 해제 전 제거.


prepareComposition — IME 조합 직전 자리 만들기

ts
prepareComposition?(context: CommandContext): boolean

한글·일본어 조합 중에는 DOM 을 고칠 수 없습니다 — 고치면 IME 가 끊겨 글자가 깨집니다. 그래서 캐럿이 앉을 자리가 필요한 날개는 조합이 시작되기 직전에 그것을 만듭니다.

자리 준비는 등록형 파이프라인입니다.

물건 블록 곁 문단 만들기  →  마크 예약(폭 없는 공백)  →  날개의 prepareComposition
  • 웬만한 날개는 필요 없습니다. 지금 번들 날개 중에는 이 훅을 쓰는 것이 없습니다 — "조합 첫 글자가 특별한 자리에 앉아야 하는" 기능만 씁니다.
  • 문서를 건드렸으면 true 를 돌려주세요. 코어가 정규화를 한 번 돕니다.
  • 던져도 조합은 계속됩니다.

조합에 관해 날개가 지킬 것

  • 자체 compositionstart/compositionend 리스너를 달지 마세요. 판정이 코어와 어긋나는 순간이 곧 버그입니다. editor.composing 을 읽으세요.
  • 미룰 일은 editor.afterComposition(fn) 에 맡깁니다 (조합 중이 아니면 즉시 실행). 코드 하이라이팅이 그 선례입니다.
  • commit() 은 이미 안에서 같은 유예를 거칩니다 — 따로 감쌀 필요가 없습니다. 조합 중에 들어와도 정규화·스냅샷·onChange 가 조합 뒤로 미뤄집니다.
  • 편집 중에 텍스트 노드의 정체를 기억하지 마세요. 조합 확정·정규화·범위 추출은 텍스트 노드를 예고 없이 갈아 끼우거나 쪼갭니다. 자리를 기억해야 하면 노드가 아니라 블록 + 글자 오프셋처럼 갈려도 살아남는 좌표를 쓰세요. 영문 타이핑은 한 노드에 이어 붙기 때문에 한글로 칠 때만 나는 사고가 됩니다.