NABI NOTE
문서

인라인 마크 만들기

inline 칸을 채우면 그 날개(wing)는 글자에 씌우는 서식이 됩니다. 굵게·기울임·밑줄·취소선· 윗첨자·아랫첨자·링크가 전부 이 칸 하나로 만들어져 있습니다.

마크(mark)는 블록(block)과 달리 어디에나 삽니다 — 문단 안, 제목 안, 표 칸 안, 리스트 항목 안. 그래서 마크로 만들 수 있는 것을 블록으로 만들면 필터(filter)가 그것을 문단 밖으로 끌어냅니다.

ts
interface InlineMarkSpec {
  readonly tag: string
  readonly attributes?: readonly string[]
  readonly escapeKeys?: readonly string[]
  claims(element: Element): boolean
  normalizeAttributes?(element: Element): Record<string, string>
}

tag — 정규 태그(canonical tag)

필터를 통과한 뒤 최종적으로 남는 태그입니다. 나가는 값은 언제나 이 태그입니다.

ts
inline: { tag: 'b', /* … */ }

들어올 때는 <b><strong> 이든 인정하지만, 나갈 때는 하나로 모입니다.

  • 뜻이 같으면 짧은 쪽을 고릅니다. strong 이 아니라 b, em 이 아니라 i, del·strike 가 아니라 s.
  • 어느 태그를 인정할지는 tag 가 아니라 claims 가 정합니다. tag출력 모양입니다.
  • 필터가 기본적으로 버리는 태그도 정규 태그로 선언하면 살아납니다. 다만 script·style· form 은 누구도 살릴 수 없습니다.

같은 정규 태그를 두 날개가 소유(claim)하지 않습니다

<a> 의 소유자는 링크 날개 하나입니다. 업로드가 만드는 첨부도 <a> 지만, 업로드는 마크업을 선언하지 않고 링크의 어휘를 빌려 씁니다. 같은 태그를 둘이 선언하면 등록 순서에 따라 동작이 뒤바뀝니다.


attributes — 살릴 속성

정규 태그에서 살아남을 속성 이름입니다. 여기 적지 않은 속성은 전부 떨어집니다.

ts
attributes: []                      // 굵게 — 아무것도 살리지 않습니다
attributes: ['href', 'data-nabi-file']  // 링크
  • 빈 배열과 생략은 같습니다 — 둘 다 "속성 없음" 입니다. 그래도 빈 배열을 명시하는 쪽을 권합니다. 그 칸을 잊은 것이 아니라 일부러 비운 것이라는 표시가 됩니다.
  • 굵게에 class·style·data-* 를 붙여 넣어도 태그만 남습니다.
  • 링크는 href 와 첨부 표식만 살립니다 — target·rel·class·title·style 은 전부 떨어집니다.
  • 허용해도 필터가 막는 속성이 있습니다. on* 같은 위험한 이름은 날개가 적어도 통과하지 못합니다. 허용 목록(allow-list)은 넓히는 것이지 우회하는 것이 아닙니다.

escapeKeys — 마크를 끄는 키

이 마크가 켜져 있을 때 누르면 마크만 끄는 키입니다. KeyboardEvent.key 값 그대로 적습니다.

ts
escapeKeys: ['Escape']

코어는 어떤 키도 특별 취급하지 않습니다. Escape 조차 여기에 직접 적어야 잡힙니다. 적지 않으면 그 마크를 끌 방법이 툴바 버튼밖에 남지 않습니다.

  • 켜져 있는 마크 중 그 키를 선언한 것만 꺼지고, 그 키의 본래 동작은 일어나지 않습니다.
  • 기본 인라인 마크는 Escape 만 선언합니다. Enter 는 문단을 나누고 마크는 예약(pending mark)으로 이어집니다 — 굵게 쓰던 사람이 줄을 바꾸면 계속 굵게 씁니다.
  • Enter 를 escapeKey 로 두면 첫 Enter 는 문단을 나누지 않고 마크만 끕니다. 그 다음 Enter 부터 평소대로 동작합니다.

예약 마크

선택 없이 캐럿(caret)만 둔 채로 버튼을 누르면 마크가 예약됩니다. 다음에 입력하는 글자부터 걸립니다. 반대로 <b> 안에서 굵게를 끄면 DOM 을 즉시 고치지 않고 다음 글자를 마크 밖으로 빼냅니다. 코어가 손대는 것은 그 첫 글자뿐이고, 이후는 브라우저 기본 입력입니다 — 계속 가로채면 한글 조합이 깨지기 때문입니다.


claims — 소유 판정

임의의 엘리먼트를 보고 "이건 내 마크다" 라고 주장합니다. 규칙이 이름에 그대로 있습니다 — 먼저 주장한 쪽이 가져갑니다.

ts
claims(element: Element): boolean
ts
// 굵게 — 태그 이름만 봅니다
const tags = new Set(['B', 'STRONG'])
claims: (element) => tags.has(element.tagName)
ts
// 링크 — 주소가 통과하지 못하는 <a> 는 우리 것이 아닙니다 (평문으로 떨어집니다)
claims: (element) =>
  element.tagName === 'A' && safeUrl(element.getAttribute('href') ?? '') !== null
  • 등록 순서대로 물어보고 처음 true 를 낸 날개가 그 엘리먼트를 가져갑니다. 아무도 가져가지 않으면 껍데기가 벗겨지고 안의 글자만 남습니다.
  • element.tagName대문자입니다. 'b' 와 비교하면 영원히 걸리지 않습니다.
  • getComputedStyle 을 쓰지 마세요. 상속값을 자기 것으로 오인합니다 — 굵은 제목 안의 보통 글자가 전부 굵게로 잡힙니다. 봐야 한다면 인라인 style 만 봅니다.
  • 그런데 인라인 style 판정도 대개 헛일입니다. 들어오는 문에서 style 이 먼저 전량 제거되므로 실제 입력 경로에서는 불리지 않습니다. 기본 마크들이 태그만 보는 이유입니다.
  • 술어는 부수효과가 없어야 합니다. 스캔 도중 여러 번 불릴 수 있습니다.

normalizeAttributes — 속성 다시 조립

정규 태그로 바꿀 때 속성을 다시 계산합니다. 없으면 attributes 목록에 적힌 것을 그대로 옮깁니다.

ts
normalizeAttributes?(element: Element): Record<string, string>

값을 그대로 통과시키면 안 될 때 씁니다. 링크가 그 예입니다 — href 를 믿지 않고 언제나 safeUrl 로 다시 조립합니다.

ts
normalizeAttributes(element) {
  const href = safeUrl(element.getAttribute('href') ?? '')
  if (!href) return {}          // 빈 객체 → 마크가 벗겨져 평문이 됩니다

  const attributes: Record<string, string> = { href }
  // 첨부 표식은 **있을 때만** 남깁니다 — 보통 링크에 빈 값이 붙으면 클립이 그려집니다
  if (element.hasAttribute(LINK_FILE_ATTR)) {
    attributes[LINK_FILE_ATTR] = normalizeExtension(element.getAttribute(LINK_FILE_ATTR) ?? '')
  }
  return attributes
}
  • 돌려준 객체가 그 엘리먼트의 속성 전부가 됩니다. 이름은 여전히 attributes 목록을 거칩니다 — 목록에 없는 이름을 돌려주면 떨어집니다.
  • 값을 못 만들면 빈 객체를 돌려주세요. 그 마크는 껍데기가 벗겨지고 평문(plain text)으로 떨어집니다.
  • 표현 수단이 바뀌는 경우에도 씁니다 — <span style="font-weight:700"><b> 로 옮기는 자리가 여기입니다.

값을 가진 마크

굵게는 값이 없어서 토글 하나로 끝나지만, 링크는 href 를 함께 실어야 합니다. 그 자리를 Range 로 손수 짜지 마세요 — 부분 선택·이미 걸린 마크·빈 문단의 filler 처리가 날개 수만큼 복제됩니다. 문은 하나입니다.

ts
prompt: {
  kind: 'prompt',
  placeholder: { ko: '주소를 입력하세요', en: 'Enter a URL' },
  confirm: { ko: '링크 걸기', en: 'Add link' },
  validate: (value) => safeUrl(value) !== null,
  run(context, value) {
    const href = safeUrl(value)
    if (!href) return false
    return applyMarkWithValue(context, 'a', { href }, href)
  },
},

applyMarkWithValue(context, tag, attributes, text) 는 세 경우를 한꺼번에 처리합니다.

상황하는 일
글자를 골라 두었다그 범위에 마크를 겁니다
캐럿이 이미 그 마크 안이다새로 만들지 않고 값만 갈아 끼웁니다 (주소 바꾸기)
캐럿뿐이다text 를 글자로 넣고 그 위에 마크를 겁니다

값 상자는 열던 순간의 선택 범위를 되돌려 줍니다

격자·입력창이 뜨면 캐럿이 그 상자의 입력으로 옮겨갑니다. 코어가 버튼을 누른 순간의 CommandContext선택 범위 사본을 함께 붙들었다가 run 직전에 되돌려 놓습니다. 값을 받는 마크 날개는 이 규칙에 기대도 됩니다 — 없으면 "어느 글자였는가" 를 알 수 없습니다.


상태와 커맨드

표면무엇입니까
editor.activeMarks지금 켜져 있는 날개 id 들 (예약 + 캐럿 위치)
markschange 이벤트그 값이 바뀔 때
editor.toggle(id) · activateMark · deactivateMark조작

마크에 딸린 동작(링크 해제 같은 것)은 commands 로 붙입니다 — UI 와 동작에서 다룹니다.


빠지기 쉬운 함정

  • 마크로 될 일을 블록으로 만들지 마세요. 블록은 루트의 직계여야 하므로, 문장 안의 마크업을 블록으로 선언하면 필터가 그것을 문단 밖으로 끌어냅니다. 링크가 예전에 블록이라 표 칸 안의 첨부가 표 밖으로 나갔던 사고가 있었습니다.
  • escapeKeys 를 빠뜨리지 마세요. 코어는 Escape 도 특별 취급하지 않습니다.
  • claimsgetComputedStyle 을 쓰지 마세요. 상속값 오탐이 납니다.
  • attributes 를 넓히기 전에 normalizeAttributes 를 먼저 생각하세요. 살려야 하는 것이 "값" 이 아니라 "뜻" 이라면 다시 조립하는 쪽이 안전합니다.