블록과 블록 속성 만들기
문단 자리를 차지하는 덩어리, 곧 블록(block)은 block(하나) 또는 blocks(여럿)로 선언합니다. 태그를 바꾸지 않고 블록의 성질만 바꾸는 것은 블록 속성(block attribute), blockAttribute 입니다.
interface BlockSpec {
readonly tag: string
readonly attributes?: readonly string[]
readonly defaults?: Readonly<Record<string, string>>
claims(element: Element): boolean
readonly content?: ContentSpec
readonly nestable?: boolean
readonly empty?: boolean
readonly holds?: 'inline' | 'object'
caretEntry?(block: HTMLElement, back: boolean): HTMLElement | null
normalizeAttributes?(element: Element): Record<string, string>
toOutputHtml?(element: HTMLElement): void
fromSourceHtml?(element: HTMLElement): boolean
}가장 짧은 블록은 이만큼입니다.
export function heading(level: 1 | 2 | 3 | 4 | 5 | 6): NabiWing {
const tag = `h${level}`
const upper = tag.toUpperCase()
return {
id: `block-heading-${level}`,
group: 'block',
names: { ko: `제목 ${level}`, en: `Heading ${level}` },
icon: `H${level}`,
block: {
tag,
attributes: [],
claims: (element) => element.tagName === upper,
},
}
}block 과 blocks
block 은 규격이 하나뿐일 때의 편의 문법이고, 한 기능이 마크업을 여럿 소유(claim)하면 blocks 배열을 씁니다. 스캔은 block → blocks 순서로 물어봅니다.
block: spec // = blocks: [spec]
blocks: [imageSpec, cardSpec]토글 대표는 첫 규격이고, 어떤 규격이 잡았는지는 코어가 scanBlockSpec() 으로 가립니다.
tag — 정규 태그(canonical tag)
출력은 언제나 이 태그입니다. h1 · ul · table · pre · hr · img · iframe · details 가 번들 날개(wing)들이 소유한 태그입니다.
- 필터(filter)가 기본적으로 버리는 태그도 정규 태그로 선언하면 살아납니다 — 유튜브 날개가
iframe을 되살리는 방식이 그것입니다. 다만script·style·form은 누구도 살릴 수 없습니다. nabi-로 시작하는 태그는 정규 태그로 쓸 수 없습니다. 그것은 편집 중에만 사는 임시 마크업의 접두사입니다 (아래 참고).
attributes · defaults
attributes 는 살아남을 속성 이름, defaults 는 새로 만들 때 넣어 줄 속성입니다.
// 체크리스트 — 표식 속성만 살리고, 새로 만들 때 그 값을 넣습니다
attributes: ['data-nabi-list'],
defaults: { 'data-nabi-list': 'task' },// 유튜브 — 여기 적힌 이름만 살아남고, 값은 normalizeAttributes 가 다시 만듭니다
attributes: ['data-nabi-embed', 'data-nabi-video', 'src', 'title', 'allowfullscreen', 'loading'],- 목록에 없는 속성은 전부 떨어집니다.
attributes: []는 "태그만 남긴다" 입니다. defaults는 값을 채워 주는 칸이지 검사하는 칸이 아닙니다. 들어온 값을 다듬는 것은normalizeAttributes의 몫입니다.- 날개가 허용해도 필터가 막는 속성이 있습니다 (
on*같은 것).
claims — 소유 판정
claims(element: Element): boolean등록 순서대로 물어보고 처음 true 를 낸 규격이 그 엘리먼트를 가져갑니다. 아무도 가져가지 않으면 껍데기가 벗겨져 내용이 문단으로 내려앉습니다.
claims: (element) => element.tagName === 'BLOCKQUOTE'// 이미지 — 태그가 맞아도 주소가 통과해야 우리 것입니다
claims: (element) =>
element.tagName === 'IMG' && safeUrl(element.getAttribute('src') ?? '') !== null// 유튜브 — 태그를 보지 않고 "영상 id 를 뽑을 수 있는가" 로 판정합니다
claims: (element) => videoIdOf(element) !== null같은 태그를 나눠 쓸 때
원칙은 한 정규 태그에 한 소유자입니다. 다만 글머리 목록(ul)과 체크리스트 (ul[data-nabi-list="task"])처럼 속성으로 갈리는 공유는 정당합니다. 그때 두 조건을 지켜야 합니다.
주장 술어가 서로 배타적이어야 합니다. 한 엘리먼트를 둘이 함께 주장하면 안 됩니다.
ts// 글머리 — 체크 표식이 **없는** ul 만 claims: (element) => element.tagName === 'UL' && !element.hasAttribute(TASK_FLAG) // 체크리스트 — 표식이 **있는** ul 만 claims: (element) => element.tagName === 'UL' && element.getAttribute(TASK_FLAG) === TASK_VALUE이렇게 해 두면 등록 순서가 어떻든 서로를 가로채지 않습니다.
선언한
attributes집합이 서로 달라야 합니다. 출력에 남는 것은 태그와 허용 속성 뿐이라, 속성까지 같으면 저장된 HTML 을 다시 읽을 때 어느 쪽이었는지 알아낼 방법이 없습니다.
둘째 조건이 깨지면 코어가 등록 시점에 경고를 찍습니다. 첫째 조건은 검사하지 못합니다 — 술어는 임의의 함수라 겹치는지 알 수 없습니다. 지키는 것은 날개 작성자의 몫입니다.
holds — 글 블록(text block)인가 물건 블록(object block)인가
블록이 담는 것을 말합니다. 기본은 'inline' 입니다.
| 값 | 뜻 | 예 |
|---|---|---|
'inline' | 속이 인라인 내용(글자·마크(mark))이라 캐럿(caret)이 그 사이에 삽니다 | 문단 · 제목 · 리스트 항목 |
'object' | 블록 하나가 통째로 물건입니다 | 이미지 · 구분선 · 코드 · 표 · 접기 상자 |
NabiWing.inline 은 마크 규격이고 이것은 블록의 내용 모델입니다 — 이름이 겹치는 것은 둘 다 "인라인 내용" 을 가리키기 때문입니다.
block: {
tag: 'pre',
holds: 'object', // 속은 편집하지만 경계에서는 물건입니다
/* … */
}empty: true 면 적지 않아도 'object' 입니다. 코드·표처럼 속을 편집하는 물건도 있습니다 — 안으로 들어가면 글처럼 쓰고, 경계에서만 물건으로 다뤄집니다.
물건 블록에서 코어가 대신 해 주는 일
날개가 손댈 필요가 없는 공통 동작입니다.
- 선택 표시와 캐럿 감추기 — 통째로 골라지면
data-nabi-selected가 붙고 캐럿이 숨습니다. 속이 없는 물건은 클릭하면 코어가 통째로 고릅니다. 날개가 하지 않습니다. - 속에 캐럿이 있으면 옅은 테두리 (
data-nabi-active) — "지금 이 상자 안에서 쓰고 있다" 를 보이게 합니다. 진한 테두리가 붙는 통째 선택(whole selection)과 상호배타입니다. - 화살표로 드나들기 — 이웃 줄로 옮기고, 이웃이 없으면 빈 문단을 만들어 줍니다. 문서 첫 줄이 이미지·코드·표여도 그 위에 글을 쓸 수 있는 길입니다.
- 나가는 커맨드 — 속을 편집하는 물건 안에 캐럿이 있으면 상황 줄(context row)에 "위로 나가기 · 아래로 나가기" 가 코어가 얹은 채로 뜹니다. 모바일에는 방향키가 없어서, 이것이 없으면 코드 상자가 문서의 처음이자 끝일 때 갇힙니다. 서드파티 날개가 빠뜨려도 덫이 생기지 않게 코어가 냅니다.
- 툴바와 메뉴가 물러납니다 — 물건이 선택된 동안 툴바는 통째로 숨고
@메뉴는 열리지 않습니다. - 글자 입력과 삭제 — 물건 위에 덮어쓰지 않고 새 문단을 만들어 거기에 씁니다. 통째로 선택된 물건에
Delete·Backspace를 누르면 빈 문단으로 갈아 끼웁니다. - 보조기기 알림 — 캐럿이 숨는 상태라
aria-live영역이 "이미지 선택됨" 을 알립니다.
이 표식들은 전부 편집 화면 전용이라 출력값에는 나가지 않습니다.
empty — 내용을 갖지 않는 블록
hr·이미지·임베드처럼 안에 아무것도 들어가지 않는 블록입니다.
block: { tag: 'hr', attributes: [], empty: true, claims: (el) => el.tagName === 'HR' }- 편집 화면에서
contenteditable="false"가 걸려 캐럿이 아예 들어가지 않습니다. 이 속성은 허용 속성이 아니라 출력값에는 절대 나가지 않습니다. - 캐럿이 못 들어가므로, 이 블록을 찾는 커맨드는 블록 자신과 바로 앞 형제를 함께 봐야 합니다. 꽃(flower)의
findAdjacentBlock이 그 규칙을 담고 있습니다.
commands: [
deleteBlockCommand({
id: 'youtube-delete',
names: { ko: '영상 삭제', en: 'Delete video' },
find: (context) => findAdjacentBlock(context, isEmbed),
}),
],content — 안쪽 구조 (ContentSpec)
리스트나 표처럼 자식을 갖는 블록의 재귀적 구조입니다.
interface ContentSpec {
readonly tag: string
readonly alternates?: readonly string[]
readonly attributes?: readonly string[]
readonly defaults?: Readonly<Record<string, string>>
readonly unique?: boolean
readonly rectangular?: boolean
readonly content?: ContentSpec
}content 가 없는 자리가 말단 칸입니다 — 실제로 글을 쓰는 곳입니다.
// 리스트 — 한 겹
content: { tag: 'li', attributes: [] }// 표 — 세 겹
content: {
tag: 'tbody',
unique: true,
content: {
tag: 'tr',
rectangular: true,
content: { tag: 'td', alternates: ['th'], attributes: [] },
},
}| 칸 | 하는 일 |
|---|---|
tag | 이 자리에 오는 정규 태그. 다른 태그가 오면 이것으로 바꿉니다 |
alternates | 같은 자리에 와도 되는 다른 태그. 표의 th, 접기 상자의 summary |
attributes | 이 태그에서 살릴 속성 |
defaults | 새로 만들 때 넣어 줄 속성 (체크 항목의 data-nabi-checked="false") |
unique | 같은 태그 형제를 첫 번째로 몰아넣습니다 — thead/tbody 로 갈라져 들어올 때 |
rectangular | 형제끼리 자식 수를 맞춥니다 — 모자란 쪽을 빈 칸으로 채웁니다 |
content | 한 겹 더 들어가는 구조. 없으면 여기가 말단 칸입니다 |
rectangular 가 필요한 이유는 밖에서 붙여넣은 표가 성한 채로 오지 않기 때문입니다. colspan/rowspan 은 허용 속성이 아니라 벗겨지고, 사이트에 따라 행마다 칸 수가 애초에 다릅니다. 채우는 쪽으로만 고칩니다 — 글자가 든 칸은 지우지 않고 가장 긴 행에 맞춥니다.
말단 칸이 담는 것은 인라인 내용뿐입니다
표 칸(td)에 이미지·구분선 같은 물건 블록을 넣으면 필터가 표 밖으로 끌어냅니다 (지우지 않으므로 내용은 잃지 않고 격자도 안 깨집니다). 리스트 항목만 예외로 하위 목록을 품습니다 — 그것이 nestable 입니다.
말단이 몇 겹인지가 Enter 의 뜻도 가릅니다. 한 겹이면 문단과 토글로 오가고, 두 겹 이상이면 Enter 는 줄바꿈만 합니다.
nestable — 리스트 중첩
말단 항목 안에 컨테이너가 다시 들어갈 수 있는지입니다.
nestable: true- 진짜 마크업입니다 —
<ul><li>항목<ul><li>하위</li></ul></li></ul>. 필터가 그 중첩을 살려 둡니다. Tab은 위 형제의 하위로,Shift+Tab은 부모의 다음 형제로 보냅니다. 최상위에서 내어쓰면 문단이 됩니다. 다만 키를 가로채는 것은 날개입니다 — 코어는Tab을 모릅니다 (indentListItems를keys에 이어 씁니다).- 글머리·번호·체크리스트 셋 다 중첩됩니다. 체크 상태는 항목의 속성이라 중첩에서도 그대로 남습니다.
- 표처럼 여러 겹 구조(
content.content)에는 쓰지 않습니다.
caretEntry — 밖에서 캐럿이 닿았을 때
화살표로 이웃한 물건에 닿거나 틈(gap)에서 물건 쪽으로 파고들 때, 캐럿을 어디에 놓을지를 정합니다.
caretEntry?(block: HTMLElement, back: boolean): HTMLElement | null돌려준 엘리먼트의 맨 앞(back 이면 맨 뒤)에 캐럿이 놓입니다.
// 표 — 첫(마지막) 말단 칸으로 들어갑니다
caretEntry: (block, back) => {
const cells = block.querySelectorAll<HTMLElement>('td, th')
return (back ? cells[cells.length - 1] : cells[0]) ?? null
}// 코드 — 블록 자신. 여기 온 사람은 대개 코드를 고치려는 것입니다
caretEntry: (block) => block// 접기 상자 — 접혀 있으면 제목으로. 접힌 내용은 화면에 없으므로
// 그리로 보내면 캐럿이 사라진 것처럼 보입니다
caretEntry: (block, back) => {
const summary = summaryOf(block)
if (!block.hasAttribute('open')) return summary
const cells = [...block.children] as HTMLElement[]
return (back ? cells[cells.length - 1] : cells[0]) ?? summary
}- 선언하지 않거나
null을 돌려주면 통째로 선택됩니다. 이미지·구분선·임베드가 그 기본값을 씁니다. - 속을 쓸 수 있어도 고르는 쪽이 나은 물건(통째로 지우는 일이 훨씬 잦은 임베드 카드 같은 것)은
null을 돌려줍니다. - 코어는 이것을 다른 칸으로 추론하지 않습니다.
content나empty는 "안에 어떤 태그가 오는가"·"내용을 갖지 않는다" 를 말하는 칸이지 캐럿 진입점을 말하는 칸이 아닙니다. 뜻이 겹칠 뿐이라 읽기 전용 표나 캡션이 진입점인 카드에서 어긋납니다.
normalizeAttributes — 속성 다시 조립
normalizeAttributes?(element: Element): Record<string, string>값을 그대로 통과시키지 않고 다시 만들어야 할 때 씁니다. 없으면 attributes 목록만 그대로 옮깁니다.
// 유튜브 — 넘어온 src 를 쓰지 않고 영상 id 11자만 뽑아 주소를 새로 조립합니다.
// 그래서 src 에 무엇이 들어와도 출력은 언제나 같은 모양입니다.
normalizeAttributes(element) {
const id = videoIdOf(element)
return id ? embedAttributes(id) : {}
}// 접기 상자 — open="true" 로 들어와도 남는 것은 open="" 하나입니다
normalizeAttributes(element) {
return element.hasAttribute('open') ? { open: '' } : {}
}// 이미지 — 어디서 들어온 <img> 든 여기서 모양이 채워집니다
normalizeAttributes(element) {
const source = safeUrl(element.getAttribute('src') ?? '')
if (!source) return {}
return {
src: source,
alt: element.getAttribute('alt') ?? '',
'data-nabi-width': String(widthOf(element)),
'data-nabi-align': alignOf(element),
}
}빈 객체를 돌려주면 그 블록은 살아남지 못합니다 — 주소를 못 믿을 때의 정답입니다.
toOutputHtml — 나가는 겉옷 입히기
soul 을 outputHtml 로 옮기는 훅(hook)입니다. getHtml() 직전에 불립니다.
toOutputHtml?(element: HTMLElement): void"내 마크업이 바깥 세상에서 어떤 모양인가" 는 그 날개만 압니다. 이미지 폭은 style="max-width:…" 로, 체크 상태는 <input type="checkbox" disabled> 로 나갑니다.
// 이미지 — 폭을 인라인으로 굳히고 우리 속성을 뗍니다
toOutputHtml(element) {
const width = element.getAttribute('data-nabi-width')
if (width === null) return // 이미 outputHtml 형태 — 두 번 내보내도 같아야 합니다
element.setAttribute(
'style',
`display:block;height:auto;max-width:${widthOf(element)}%;` + (element.getAttribute('style') ?? ''),
)
element.removeAttribute('data-nabi-width')
}- 엘리먼트를 제자리에서 고칩니다. 돌려주는 값은 없습니다.
- soul 속성을 outputHtml 로 옮겼으면 그 속성을 스스로 뗍니다.
- 중첩 컨테이너는 재귀할 필요가 없습니다. 코어가 모든 엘리먼트를 한 번씩 방문합니다.
- 두 번 돌려도 같아야 합니다. 이미 outputHtml 형태인 엘리먼트에는 아무것도 하지 않도록, 맨 앞에서 그것을 알아보고 빠져나오세요 (위 예의
if (width === null) return).
어디까지 자립시킬지는 날개가 정합니다
기준은 "그것이 없으면 뜻이 바뀌는가" 입니다. 가운데 정렬이 풀리면 다른 문서지만, 표 테두리가 없어도 표는 표입니다.
| 굳힙니다 | 굳히지 않습니다 |
|---|---|
| 정렬 · 이미지 폭 · 체크 상태 · 코드 색 | 제목 크기 · 표 테두리 · 구분선 모양 · 첨자 크기 · 첨부 클립 |
겉옷이 아예 없는 칸도 있습니다. 드롭 캡(data-nabi-dropcap)·코드 언어 (data-nabi-lang)·첨부 표식(data-nabi-file)은 모양이 아니라 표시라 저장값에 그대로 남습니다. 그러면 왕복이 저절로 무손실입니다.
fromSourceHtml — 들어오는 겉옷 벗기기
outputHtml 을 soul 로 되돌리는 훅입니다. 필터보다 먼저, 들어오는 모든 엘리먼트에 대해 이 훅을 선언한 규격들에게 등록 순서대로 물어봅니다.
fromSourceHtml?(element: HTMLElement): boolean자기 outputHtml 을 알아보면 soul 로 고치고 true 를 돌려줍니다 — 그러면 나머지에게는 묻지 않습니다.
// 체크리스트 — "항목이 체크박스로 시작하는 목록" 모양을 되읽습니다
fromSourceHtml(element) {
if (element.tagName !== 'UL' || element.hasAttribute(TASK_FLAG)) return false
const items = [...element.children].filter((child) => child.tagName === 'LI')
const boxOf = (item: Element) => {
const first = item.firstElementChild
return first?.tagName === 'INPUT' && first.getAttribute('type') === 'checkbox' ? first : null
}
if (items.length === 0 || !items.some((item) => boxOf(item) !== null)) return false
element.setAttribute(TASK_FLAG, TASK_VALUE)
for (const item of items) {
const box = boxOf(item)
item.setAttribute(TASK_CHECKED_ATTR, String(box?.hasAttribute('checked') ?? false))
box?.remove()
}
return true
}claims와 별개인 이유가 있습니다. outputHtml 은 정규 형태가 아니라서 그대로 물으면 엉뚱한 규격이 가져갑니다 — 체크 표식이 벗겨진ul을 글머리 목록이 가져가 버립니다.- 값은 반드시 CSSOM(
element.style)으로 읽습니다. 원시 문자열을 직접 파싱하면 파서 차이를 노린 공격이 열립니다. - 이 훅이 놓쳐도 뒤에 도는 필터가 낯선 것을 전부 벗깁니다.
- 들어오는 값을 보정하는 데도 씁니다. 접기 상자는 요약(
<summary>)이 없는 남의<details>에 빈 요약을 만들어 넣습니다 — 없으면 접을 자리가 사라지기 때문입니다.
blockAttribute — 태그는 그대로, 성질만
정렬은 새로운 종류의 블록이 아닙니다. 문단이든 제목이든 이미지든 어디에나 붙을 수 있는 표시라, 태그가 아니라 속성으로 다룹니다.
interface BlockAttributeSpec {
readonly attribute: string
readonly value: string
readonly appliesTo?: readonly string[]
toOutputHtml?(element: HTMLElement): void
fromSourceHtml?(element: HTMLElement): boolean
}blockAttribute: {
attribute: 'data-nabi-align',
value: 'center',
toOutputHtml: (element) => appendStyle(element, 'text-align:center;'),
fromSourceHtml: (element) => element.style.textAlign === 'center',
}attribute 와 value
- 같은
attribute를 쓰는 날개들은 서로 배타적입니다. 가운데 정렬을 켜면 오른쪽 정렬은 저절로 꺼집니다. 세 정렬 날개가data-nabi-align한 칸을 나눠 씁니다. - 켜져 있는 값을 다시 누르면 속성 자체가 떨어집니다.
- 필터는 등록된 값만 통과시킵니다. 이름과 값이 둘 다 등록돼 있어야 하므로
data-nabi-align="javascript:…"같은 것은 남지 않습니다. - 기본값이라도 명시적인 값을 두는 쪽이 낫습니다. 왼쪽 정렬이 그렇습니다 — 값이 없으면 "왼쪽으로 되돌리기" 를 누를 수 없고, 저장값에서 정렬을 잃습니다.
값이 여럿일 때 — blockAttributes
blockAttribute 는 blockAttributes: [spec] 의 편의 문법입니다. block 과 blocks 의 관계와 정확히 같고, 함께 쓰면 단수가 먼저 스캔됩니다.
값마다 날개를 따로 두는 길과 갈립니다. 정렬은 셋을 따로 두어 툴바에 버튼 셋이 서고, 글자 크기는 한 날개가 값 넷(xs·sm·lg·xl)을 들고 버튼 한 자리에 섭니다. 값 여럿이 버튼 한 자리를 나눠 쓰는 자리가 복수형이 필요한 곳입니다.
blockAttributes: [
{ attribute: 'data-nabi-size', value: 'xs', /* … */ },
{ attribute: 'data-nabi-size', value: 'sm', /* … */ },
/* … */
]- 배타성은 단수와 같습니다 — 같은
attribute이므로 하나를 켜면 나머지가 꺼집니다. - 여럿을 선언하면
toggle()이false를 돌려줍니다. 어느 값인지 알 수 없기 때문입니다. 고르는 문은 견본판(palette)이나 자기 UI 입니다. - 툴바 노출은 값 하나라도 지금 블록에 허용되면 버튼을 보입니다.
appliesTo
이 속성을 붙일 수 있는 블록 태그입니다. 비우면 모든 블록입니다.
appliesTo: ['p'] // 드롭 캡 — 루트 직계 문단 전용- 버튼 노출도 이 칸을 봅니다. 제목이나 리스트에 캐럿이 있으면 툴바에서도
@메뉴에서도 버튼이 숨습니다. "보이는데 눌러도 아무 일이 없는" 자리가 생기지 않습니다. - 말단 칸(
li·td)에는 블록 속성이 애초에 남지 않습니다. 필터가 그 칸 규격의 허용 목록(allow-list)만 남기기 때문입니다.
toOutputHtml · fromSourceHtml
블록 규격의 같은 이름 훅과 규칙이 같습니다. 다만 걸리는 시점이 다릅니다.
toOutputHtml은 이 속성이 이 값으로 붙어 있는 블록마다 불립니다. 부르고 나면 코어가 그 속성을 뗍니다 — 직접 뗄 필요가 없습니다.fromSourceHtml은 이 속성이 아직 없을 때 불립니다.true를 돌려주면 코어가 속성을 달아 줍니다.
// 정렬 — 글 블록은 text-align, 물건 블록은 마진으로 굳습니다
toOutputHtml: (element) => {
if (MOVED.has(element.tagName)) appendStyle(element, MOVED_MARGINS[value])
else appendStyle(element, `text-align:${value};`)
},
fromSourceHtml: (element) => {
if (MOVED.has(element.tagName)) {
const { marginLeft, marginRight } = element.style
if (value === 'center') return marginLeft === 'auto' && marginRight === 'auto'
/* … */
}
return element.style.textAlign === value
},한 속성의 번역은 한 곳에서 합니다. 이미지 규격이 폭만 굳히고 정렬은 건드리지 않는 것이 그 때문입니다 — 남은 data-nabi-align 은 정렬 날개가 마진으로 바꿉니다.
겉옷을 두지 않는 선택
드롭 캡은 두 훅을 아예 두지 않습니다. 모양이 아니라 표시라서 속성이 그대로 나가고 그대로 들어옵니다. 내보낸 값이 다른 에디터에서 드롭 캡으로 보일 필요는 없다고 판단한 것입니다.
편집 중에만 사는 마크업
업로드 자리표시자(placeholder)처럼 편집 화면에만 있고 출력값에는 없어야 하는 마크업에는 규약이 넷 있습니다. 하나만 빠져도 임시 마크업이 문서로 샙니다.
| 규약 | 어기면 | |
|---|---|---|
| ① | nabi- 접두사 태그를 씁니다 (EPHEMERAL_PREFIX) | 보통 태그는 등록된 블록이 되어 필터를 그냥 통과합니다 |
| ② | 텍스트 노드를 두지 않습니다. 글자는 속성에 담고 CSS 가 그립니다 | 필터가 껍데기를 벗기며 내용만 남기므로 그 글자가 문서로 샙니다 |
| ③ | 끝날 때까지 commit() 을 부르지 않습니다 | 되돌리기(undo) 스냅샷과 onChange 값에 자리표시자가 스칩니다 |
| ④ | 잠금을 풀기 전에 반드시 걷어냅니다 (성공·실패·정리 세 갈래 모두) | 남은 상자가 다음 필터 통과 때 소리 없이 사라집니다 |
①만 지키면 나머지는 코어가 받쳐 줍니다 — 필터가 어떤 자리에서도 접두사 태그를 벗기고, 정규화가 그것을 문단으로 감싸지 않으며, 마지막 잠금이 풀릴 때 남아 있으면 콘솔 경고를 찍습니다.
②의 예외는 엘리먼트 자식입니다. 미리보기 <img> 는 붙여도 됩니다 — 텍스트가 아니라서 껍데기를 벗겨도 글자가 남지 않습니다.
빠지기 쉬운 함정
empty블록을 찾을 때context.block만 보지 마세요. 캐럿이 들어가지 않으므로 바로 앞 형제도 함께 봐야 합니다 (findAdjacentBlock).toOutputHtml을 두 번 돌려도 같아야 합니다. 이미 변환된 모양을 알아보고 빠져나오는 줄을 맨 앞에 두세요.fromSourceHtml에서 문자열을 직접 파싱하지 마세요. 반드시element.style로 읽습니다.- 말단 칸에 물건 블록을 넣지 마세요. 필터가 밖으로 끌어냅니다.
caretEntry를content·empty로 대신할 수 있다고 생각하지 마세요. 뜻이 겹칠 뿐 다른 칸입니다.- 블록 맨 앞에서
Enter를 가져갈 때는 조심하세요. 코어는 쪼개지 않고 밀어냅니다 — 위에 빈 문단이 생기고 블록은 껍데기째 내려갑니다.keys로Enter를 가져가는 날개는 맨 앞이면 코어에 넘겨야 합니다. 단 빈 블록은 넘기지 마세요 — 빈 블록은 캐럿이 항상 맨 앞이라, 넘기면Enter가 영원히 위에 문단만 쌓습니다.