인라인 마크 만들기
inline 칸을 채우면 그 날개(wing)는 글자에 씌우는 서식이 됩니다. 굵게·기울임·밑줄·취소선· 윗첨자·아랫첨자·링크가 전부 이 칸 하나로 만들어져 있습니다.
마크(mark)는 블록(block)과 달리 어디에나 삽니다 — 문단 안, 제목 안, 표 칸 안, 리스트 항목 안. 그래서 마크로 만들 수 있는 것을 블록으로 만들면 필터(filter)가 그것을 문단 밖으로 끌어냅니다.
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)
필터를 통과한 뒤 최종적으로 남는 태그입니다. 나가는 값은 언제나 이 태그입니다.
inline: { tag: 'b', /* … */ }들어올 때는 <b> 든 <strong> 이든 인정하지만, 나갈 때는 하나로 모입니다.
- 뜻이 같으면 짧은 쪽을 고릅니다.
strong이 아니라b,em이 아니라i,del·strike가 아니라s. - 어느 태그를 인정할지는
tag가 아니라claims가 정합니다.tag는 출력 모양입니다. - 필터가 기본적으로 버리는 태그도 정규 태그로 선언하면 살아납니다. 다만
script·style·form은 누구도 살릴 수 없습니다.
같은 정규 태그를 두 날개가 소유(claim)하지 않습니다
<a> 의 소유자는 링크 날개 하나입니다. 업로드가 만드는 첨부도 <a> 지만, 업로드는 마크업을 선언하지 않고 링크의 어휘를 빌려 씁니다. 같은 태그를 둘이 선언하면 등록 순서에 따라 동작이 뒤바뀝니다.
attributes — 살릴 속성
정규 태그에서 살아남을 속성 이름입니다. 여기 적지 않은 속성은 전부 떨어집니다.
attributes: [] // 굵게 — 아무것도 살리지 않습니다
attributes: ['href', 'data-nabi-file'] // 링크- 빈 배열과 생략은 같습니다 — 둘 다 "속성 없음" 입니다. 그래도 빈 배열을 명시하는 쪽을 권합니다. 그 칸을 잊은 것이 아니라 일부러 비운 것이라는 표시가 됩니다.
- 굵게에
class·style·data-*를 붙여 넣어도 태그만 남습니다. - 링크는
href와 첨부 표식만 살립니다 —target·rel·class·title·style은 전부 떨어집니다. - 허용해도 필터가 막는 속성이 있습니다.
on*같은 위험한 이름은 날개가 적어도 통과하지 못합니다. 허용 목록(allow-list)은 넓히는 것이지 우회하는 것이 아닙니다.
escapeKeys — 마크를 끄는 키
이 마크가 켜져 있을 때 누르면 마크만 끄는 키입니다. KeyboardEvent.key 값 그대로 적습니다.
escapeKeys: ['Escape']코어는 어떤 키도 특별 취급하지 않습니다. Escape 조차 여기에 직접 적어야 잡힙니다. 적지 않으면 그 마크를 끌 방법이 툴바 버튼밖에 남지 않습니다.
- 켜져 있는 마크 중 그 키를 선언한 것만 꺼지고, 그 키의 본래 동작은 일어나지 않습니다.
- 기본 인라인 마크는
Escape만 선언합니다.Enter는 문단을 나누고 마크는 예약(pending mark)으로 이어집니다 — 굵게 쓰던 사람이 줄을 바꾸면 계속 굵게 씁니다. Enter를 escapeKey 로 두면 첫Enter는 문단을 나누지 않고 마크만 끕니다. 그 다음Enter부터 평소대로 동작합니다.
예약 마크
선택 없이 캐럿(caret)만 둔 채로 버튼을 누르면 마크가 예약됩니다. 다음에 입력하는 글자부터 걸립니다. 반대로 <b> 안에서 굵게를 끄면 DOM 을 즉시 고치지 않고 다음 글자를 마크 밖으로 빼냅니다. 코어가 손대는 것은 그 첫 글자뿐이고, 이후는 브라우저 기본 입력입니다 — 계속 가로채면 한글 조합이 깨지기 때문입니다.
claims — 소유 판정
임의의 엘리먼트를 보고 "이건 내 마크다" 라고 주장합니다. 규칙이 이름에 그대로 있습니다 — 먼저 주장한 쪽이 가져갑니다.
claims(element: Element): boolean// 굵게 — 태그 이름만 봅니다
const tags = new Set(['B', 'STRONG'])
claims: (element) => tags.has(element.tagName)// 링크 — 주소가 통과하지 못하는 <a> 는 우리 것이 아닙니다 (평문으로 떨어집니다)
claims: (element) =>
element.tagName === 'A' && safeUrl(element.getAttribute('href') ?? '') !== null- 등록 순서대로 물어보고 처음
true를 낸 날개가 그 엘리먼트를 가져갑니다. 아무도 가져가지 않으면 껍데기가 벗겨지고 안의 글자만 남습니다. element.tagName은 대문자입니다.'b'와 비교하면 영원히 걸리지 않습니다.getComputedStyle을 쓰지 마세요. 상속값을 자기 것으로 오인합니다 — 굵은 제목 안의 보통 글자가 전부 굵게로 잡힙니다. 봐야 한다면 인라인style만 봅니다.- 그런데 인라인
style판정도 대개 헛일입니다. 들어오는 문에서style이 먼저 전량 제거되므로 실제 입력 경로에서는 불리지 않습니다. 기본 마크들이 태그만 보는 이유입니다. - 술어는 부수효과가 없어야 합니다. 스캔 도중 여러 번 불릴 수 있습니다.
normalizeAttributes — 속성 다시 조립
정규 태그로 바꿀 때 속성을 다시 계산합니다. 없으면 attributes 목록에 적힌 것을 그대로 옮깁니다.
normalizeAttributes?(element: Element): Record<string, string>값을 그대로 통과시키면 안 될 때 씁니다. 링크가 그 예입니다 — href 를 믿지 않고 언제나 safeUrl 로 다시 조립합니다.
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 처리가 날개 수만큼 복제됩니다. 문은 하나입니다.
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도 특별 취급하지 않습니다.claims에getComputedStyle을 쓰지 마세요. 상속값 오탐이 납니다.attributes를 넓히기 전에normalizeAttributes를 먼저 생각하세요. 살려야 하는 것이 "값" 이 아니라 "뜻" 이라면 다시 조립하는 쪽이 안전합니다.