npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@weing-dev/editor

v0.1.3

Published

Weing Editor Engine — headless editing core on a ProseMirror kernel with Weing-owned model/state/codec/react boundaries. M1 code-automation acceptance scope is met; product release certification is still open.

Downloads

455

Readme

@weing-dev/editor

Weing Editor Engine 의 헤드리스 코어. ProseMirror 를 저수준 편집 커널로 사용하되, 문서 모델·상태·codec·React 계약은 위잉이 소유한다.

기준 문서: docs/editor/weing-editor-engine-final-plan.md 설계 결정(ADR): docs/editor/adr/ — 0001(확장 ABI 미완), 0002(PoC export 전략), 0003(원자 트랜잭션·이벤트 semantics), 0004(meta 직렬화 경계·canonical 정규화·input rule 격리)

지금 무엇을 제공하는가 (문서 갱신 2026-08-29 · 검증 기준 2026-08-29)

이 패키지는 편집 엔진과 문서 계약을 제공하고, 그 위에 headless feature UI 와 compound 툴바·중립 아이콘 툴바를 제공한다. 편집 내용을 바로 읽고 조작할 수 있는 선택형 중립 기본 CSS도 제공하지만, 완성형 제품 셸·브랜딩·레이아웃·제품 테마는 소비자가 소유한다.

| 제공한다 | 제공하지 않는다 | | --- | --- | | canonical WeingDocument 모델 · 검증 · 정규화 | 완성형 제품 셸/디자인(브랜딩·레이아웃·제품 테마) | | 엔진(createWeingEditor)과 command·transaction 계약 | 레거시 Quill Editor 의 drop-in 대체 | | 전환기 compound facade(Editor.Root/Toolbar/IconToolbar/Input/Skeleton) | 레거시 저장 HTML 방언 유지(→ M3) | | Extension ABI v1(노드·mark·명령·keymap·codec·NodeView·plugin) | feature-set 선택(core/cms/blog 같은 갈래) | | 지원 확장 집합 factory(createSupportedEditorExtensions) | 확장을 고르는 UI·설정 표면 | | React binding(EditorProvider·EditorContent·selector 훅) | static renderer / SSR 경계, 동적 feature 로딩 | | feature subpath 열두 개(버블 서식·링크·수식·색상·글꼴·장식 표현·코드 언어·표 삽입 크기 편집·자산 삽입·미디어 정보·표 문맥·프리셋 툴바 — 제품 시각은 소비자 소유) | 그 밖의 완성된 제품 UI(디자인·레이아웃·고정 팔레트) | | 선택형 ./style.css(본문·의미 블록·표·목록·기본 텍스트/아이콘 툴바·블록 손잡이·부유 메뉴 최소값과 테마 변수) | 완성형 브랜드 테마·제품별 feature UI 디자인 | | semantic HTML 직렬화·유입과 표시 분기(serializeToHtml·parseSemanticHtml·detectEditorHtmlDialect) | 범용 HTML sanitizer·신뢰 판정 | | Quill HTML/Delta migration codec + 손실 보고 | shadow read·feature flag·telemetry·rollback | | 이미지 업로드 상태 머신, opt-in 파일/data URL paste·drop과 리사이즈 NodeView | 호스트 자산 라이브러리(기존 자산 재사용 picker)와 업로드 백엔드·보안 정책 |

공식 마일스톤 기준 위치는 M1 의 코드·자동화 인수 범위 완료(제품 릴리스 인증은 별도 OPEN)이며 M2~M4 범위의 일부가 선행 구현되어 있다.

현재 수준 (2026-08-29 · npm 배포·프리셋 툴바·배포 게이트 보강) — production rollout complete 가 아니다

E5-2~E5-5의 선택 UI에 E6-1 링크, E6-2 수식, E6-3 색상, E6-4 글꼴 편집과 E6-5 체크 항목 완료/해제가 더해졌다. 체크 항목 안의 접힌 캐럿에서 공용 툴바를 누르면 표식· aria-pressed·canonical 상태가 함께 바뀌고 undo 한 번으로 돌아온다. 범위 선택과 IME 조합 중 변경은 공통 경계에서 막으며 새 명령·subpath·의존성은 없다. E6-6은 기존 각주 이동 명령을 공용 툴바에 연결했다. 본문 각주 표식 또는 그 옆의 접힌 캐럿에서 각주 내용으로 이동을 누르면 대응 정의가 실제 뷰포트 안으로 스크롤되고 곧바로 입력할 수 있다. 범위·다른 atom·서로 다른 두 표식 사이의 모호한 캐럿은 거부하며 문서와 undo history는 바꾸지 않는다. E6-7은 밑줄·취소선의 색과 다섯 선 모양을 고르는 네이티브 패널을 열었다. 부분·혼합 선택은 명시 값을 고르기 전 잠기며 편집 DOM은 계산 스타일을 표시하지만 저장·실제 복사 HTML에는 검증된 data 속성만 남는다. E6-8은 코드 블록의 접힌 캐럿에서 검증된 언어를 적용하거나 해제하는 네이티브 패널을 열었다. 기존 setCodeBlockLanguage·activeBlock만 재사용하고, language id와 파생 class는 저장하되 구문 강조기·언어 로더는 소비자가 선택한다. E6-9는 표 밖의 접힌 캐럿에서 native 행·열 입력으로 필요한 크기의 표를 넣는 패널을 열었다. 기본값은 3×3, 범위는 행 1..1000·열 1..64이며, 정확한 2×4 한 transaction·첫 <th> focus/selection·undo를 인수했다. 공용 툴바·slash의 빠른 삽입은 계속 기본 3×3이다. E7-1은 기존 suggestion 메뉴에 정적 인라인 이모지를 합성했다. 문단·제목의 블록 시작 또는 공백 뒤 :smile을 입력해 이모지를 고르면 질의 삭제와 일반 텍스트 삽입이 한 transaction· 한 undo로 처리된다. URL·시각·공백 없는 콜론은 오인하지 않고 긴 문단도 caret 직전 유계 창만 읽는다. 원격 mention provider는 비동기 수명 계약 전까지 별도 후속이다. E7-2는 목록 항목 안의 접힌 캐럿에서 공용 툴바로 같은 목록의 항목을 위·아래 한 칸 옮긴다. bullet·ordered·task와 가장 안쪽 중첩 목록을 지원하며 항목 attrs·자식·selection을 한 transaction· 한 undo에 보존한다. pointer drag·다중 선택·표/FAQ·다른 부모 이동은 별도 후속이다. E8-1은 detectEditorHtmlDialect로 Quill·Weing HTML의 표시 CSS만 내용에 따라 나누고, CMS 게시물·FAQ·공지 상세에 두 wrapper를 연결했다. 선택형 CSS는 기본 툴바·포커스·그룹 구분· 블록 손잡이와 열한 개의 테마 변수를 더했고, Toolbar.items 부분집합·native 색 입력· fontSizeOptions를 Storybook에서 바로 체험한다. 판별은 sanitizer나 저장 방언 전환이 아니다. 게이트는 아래 검증 기준선의 수치를 CI가 매번 지킨다. 2026-08-14 현재는 여기에 선택형 style.css와 Storybook ContentStyleGuide· PresentationConfiguration이 더해져 표·목록, 인용·덧붙임·강조 상자·고지·기명·각주·질문/답변 같은 의미 콘텐츠가 초기화된 HTML처럼 보이지 않는다. 인용은 출처 문장, 덧붙임은 중립 보충 문단, 강조 상자는 톤을 가진 카드로 서로 구분한다. 이는 M2 사용성 기반의 선행 보강이지 운영 CMS 전환이나 E5 전체 완료 판정은 아니다.

M3-1에서 apps/cms-basic 소유 S3 어댑터가 기존 presign·PUT을 공개 WeingUploadProvider에 주입해 preview의 실제 이미지 업로드까지 연결했다. URL·AWS·Axios 계약은 패키지에 들어오지 않는다. M3-4는 같은 공개 controller factory를 선택적으로 받아 실제 파일과 단독 base64 data URL을 paste/drop 시작 위치에 한 undo로 넣는다. 일반 URL·혼합 HTML은 기존 유입을 보존하고 controller 하나의 다중 transfer는 Deferred다. 현재 CMS adapter는 image kind만 제한하며 MIME·크기·magic number·SVG 정책을 구현하지 않았다. 그럼에도 운영 CMS 작성·저장은 여전히 Quill이며, 작성·저장 화면 교체는 canonical 저장 계약·feature flag·telemetry·rollback·shadow read를 동반하는 M3 후속이다. @weing-dev/[email protected]은 배포용 dist의 ESM·선언·CSS를 열다섯 export로 제공하고 prepack에서 같은 산출물을 다시 만든다. EditorPresetToolbar는 블로그 허용 목록을 compact·expanded 두 배치로 그리며, 벽시계 카나리 3개는 임계값 변경 없이 일반 단위 스위트 뒤 단일 worker로 실행한다. E5-12 실기기· 스크린리더·native OS IME 수동 인증은 Deferred다. 실행 계획에 정의된 E6-1~E6-9와 E7-1~E7-2와 E8-1은 모두 Accepted이며, 다음 구조 슬라이스는 목록 pointer drag·표/FAQ·다른 부모, 다중 블록·원격 mention을 각각 독립 감사한 뒤 번호를 부여한다. 저장소 밖의 CSP·법무·외부 연동도 별도다. 축별 정리는 docs/editor/feature-weing-editor-structure.md 4부가 사실의 원천이다.

슬라이스 번호(M1-9c, 블로그 코어 Phase 5 등)는 아래 절 제목과 코드 주석에 남아 있는 작업 단위 식별자이며 공식 마일스톤의 하위 번호가 아니다. 마일스톤 상태표와 남은 게이트는 docs/editor/weing-editor-engine-final-plan.md §현재 상태 를 사실의 원천으로 삼는다.

통합 경로 (public integration path)

공개 진입점은 열다섯이다 — JavaScript 열넷(루트·React binding·선택 UI feature subpath 열두 개)과 선택형 CSS 하나다.

pnpm add @weing-dev/editor
import { /* 모델·엔진·codec·확장 */ } from "@weing-dev/editor";
import { /* React binding */ } from "@weing-dev/editor/react";
import { EditorBubbleMenu } from "@weing-dev/editor/react/bubble-menu"; // E5-2
import { EditorLinkEditor } from "@weing-dev/editor/react/link-editor"; // E6-1
import { EditorFormulaEditor } from "@weing-dev/editor/react/formula-editor"; // E6-2
import { EditorColorPicker } from "@weing-dev/editor/react/color-picker"; // E6-3
import { EditorFontPicker } from "@weing-dev/editor/react/font-picker"; // E6-4
import { EditorDecorationPicker } from "@weing-dev/editor/react/decoration-picker"; // E6-7
import { EditorCodeLanguagePicker } from "@weing-dev/editor/react/code-language"; // E6-8
import { EditorTableInsert } from "@weing-dev/editor/react/table-insert"; // E6-9
import { EditorAssetInsert } from "@weing-dev/editor/react/asset-insert"; // E5-3
import { EditorMediaDetails } from "@weing-dev/editor/react/media-details"; // E5-4
import { EditorTableMenu } from "@weing-dev/editor/react/table-menu"; // E5-5
import "@weing-dev/editor/style.css"; // 선택형 최소 기본 스타일

./style.css — 선택형 최소 기본 스타일

JavaScript가 CSS를 자동으로 불러오지 않는다. 소비자가 위 한 줄을 import하면 Editor.InputEditorContent가 내는 data-weing-editor-surface="true" 아래에만 문서 스타일이 적용된다. 본문 타이포그래피·제목·목록·인용·코드·표·미디어·placeholder와 의미 블록(덧붙임·고지·기명·강조 상자·질문/답변·각주·토글·목차·북마크·수식)을 구분한다. 인용은 인용선·기울임, 덧붙임은 덧붙임 라벨·점선 문단, 강조 상자는 톤 라벨·색상 카드다. sibling overlay인 slash·블록·선택 버블 메뉴는 각각의 data-weing-* 훅으로 불투명 배경과 z-index: 100을 얻는다. 제품은 이 규칙을 덮어쓰거나 CSS import 자체를 생략할 수 있다.

기본값은 :where(...)로 낮은 특이도를 유지한다. Editor.Toolbar의 기본 텍스트 버튼은 data-weing-toolbar-default-item 스킨을, Editor.IconToolbar은 패키지 소유 36px 아이콘·그룹 구분선·한 줄 내부 스크롤 스킨을 받는다. Editor.Toolbar.renderItem으로 직접 그린 버튼은 계속 소비자 스킨을 유지한다. 주요 커스텀 축은 다음과 같다.

| 축 | CSS custom property | | --- | --- | | 본문·표면 | --weing-editor-color, --weing-editor-muted, --weing-editor-border, --weing-editor-background, --weing-editor-surface-muted | | 강조 | --weing-editor-accent, --weing-editor-accent-contrast, --weing-editor-accent-soft | | 형태·레이어 | --weing-editor-radius, --weing-editor-shadow, --weing-editor-overlay-z-index |

Editor.Root에는 data-weing-editor-root, 기본 toolbar에는 data-weing-toolbar, 아이콘 변형에는 data-weing-editor-icon-toolbar, 항목에는 data-weing-toolbar-group-start, 블록 손잡이에는 data-weing-block-handle이 있어 제품 CSS가 더 구체적으로 덮어쓸 수 있다.

Quill·Weing HTML 소비 표시 분기

import { detectEditorHtmlDialect } from "@weing-dev/editor";

const isWeing = detectEditorHtmlDialect(html) === "weing";

<div
  className={isWeing ? undefined : "ql-snow ql-container"}
  data-weing-editor-surface={isWeing ? "true" : undefined}
  data-weing-editor-content={isWeing ? "true" : undefined}
>
  <div
    className={isWeing ? "ProseMirror" : "ql-editor editor"}
    dangerouslySetInnerHTML={{ __html: html }}
  />
</div>;

판정 순서는 명시적 data-weing-* → Quill ql-*/data-list → Weing 구조 태그다. 아무 표식도 없는 일반 HTML은 기존 화면을 보존하도록 Quill로 돌아간다. 이 함수는 표시 CSS만 고르며 HTML을 정화하거나 신뢰해도 된다고 판정하지 않는다. 외부 HTML은 기존 서버·소비자 sanitizer 경계를 그대로 지나야 한다.

./react/bubble-menu — 선택 영역 버블 서식 메뉴 (E5-2)

루트와 ./react 는 이것을 재수출하지 않는다(ADR 0013 결정 3). 버블을 쓰지 않는 소비자는 이 모듈을 한 바이트도 받지 않으며, 그래서 Editor compound 에도 없다 — 켜는 방법은 합성뿐 이다. 내는 것은 값 하나(EditorBubbleMenu)와 타입 하나(EditorBubbleMenuProps)다.

import { Editor } from "@weing-dev/editor/react";
import { EditorBubbleMenu } from "@weing-dev/editor/react/bubble-menu";

<Editor.Root>
  <Editor.Toolbar />
  <Editor.Input aria-label="본문" />
  {/* 합성 위치는 자유다 — 키보드 진입은 DOM 순서가 아니라 Alt+F10 이 소유한다 */}
  <EditorBubbleMenu />
</Editor.Root>;
  • 비어 있지 않은 텍스트 선택에서만 뜬다. 캐럿·노드 선택·표 칸 선택에서는 DOM 자체가 없고, 표 칸 안의 텍스트 선택에서는 뜬다.
  • 항목은 기존 카탈로그의 param 없는 inline 8개(bold·italic·underline·strike· subscript·superscript·inline-code·clear-formatting)이며, 새 명령을 만들지 않는다. 잠김·눌림 판정은 Editor.Toolbar같은 계산을 쓴다.
  • 키보드 진입은 Alt+F10(툴바가 보일 때만), 편집 표면 복귀는 Escape 다. Tab/Shift+Tab 의 목적지는 계약이 아니다 — 커널 keymap 과 DOM 순서가 정한다.
  • props: label(안쪽 툴바 이름, 기본 "선택 영역 서식") · renderItem(Editor.Toolbar 와 같은 계약) + 나머지 HTMLAttributes. aria-label받지 않는다(이름 소유자는 label 하나). style 은 그대로 펴되 좌표·노출(position/top/left/right/bottom/visibility)은 버블이 마지막에 덮는다.
  • 시각(배경·테두리·그림자)은 소비자 CSS 가 소유한다. 버블은 좌표와 data-weing-bubble-menu · data-weing-bubble-placement(above/below)만 낸다.

./react/link-editor — 링크 편집 패널 (E6-1)

루트와 ./react는 재수출하지 않는다. 글자 범위 또는 기존 링크 안 캐럿에서만 나타나며 기존 checkLinkHref·setLink·unsetLink를 그대로 사용한다.

import { Editor } from "@weing-dev/editor/react";
import { EditorLinkEditor } from "@weing-dev/editor/react/link-editor";

<Editor.Root>
  <Editor.Input aria-label="본문" />
  <EditorLinkEditor />
</Editor.Root>;
  • URL과 새 창 열기를 입력해 적용하고, 현재 링크 값을 수정하거나 해제한다.
  • 위험한 URL은 적용 전에 거부하며 _blank 출력의 rel="noopener noreferrer"는 codec이 파생한다.
  • 적용·수정·해제는 각각 한 undo 단위다. 읽기 전용에서는 패널 값은 보이되 모든 조작이 잠긴다.
  • 기본 CSS는 data-weing-link-* 훅에 중립적인 입력·버튼·상태 표시만 제공한다.

./react/formula-editor — 수식 편집 패널 (E6-2)

루트와 ./react는 재수출하지 않는다. 현재 글자 위치에 검증된 한 줄 원문을 넣고, 본문의 기존 수식을 고르면 같은 패널에서 교체·삭제한다.

import { Editor } from "@weing-dev/editor/react";
import { EditorFormulaEditor } from "@weing-dev/editor/react/formula-editor";

<Editor.Root>
  <Editor.Input aria-label="본문" />
  <EditorFormulaEditor />
</Editor.Root>;
  • 기존 isWeingFormulaLatex·insertFormula·deleteSelectedFormula를 그대로 사용한다.
  • 코드 블록·표 셀 범위·다른 atom·IME 조합에서는 실행하지 않으며 읽기 전용은 잠긴다.
  • 삽입·교체·삭제는 각각 한 undo 단위다. KaTeX·MathJax 조판은 소비자가 선택한다.
  • 기본 CSS는 data-weing-formula-* 훅의 입력·원문 미리보기·버튼·상태·선택 윤곽만 제공한다.

./react/color-picker — 글자색·배경색 패널 (E6-3)

루트와 ./react는 재수출하지 않는다. 텍스트 선택 또는 현재 캐럿의 typography 상태를 네이티브 색 입력 두 개에 연결하고, 기존 색상 적용·해제 명령과 값 검증만 재사용한다.

import { Editor } from "@weing-dev/editor/react";
import { EditorColorPicker } from "@weing-dev/editor/react/color-picker";

<Editor.Root>
  <Editor.Input aria-label="본문" />
  <EditorColorPicker />
</Editor.Root>;
  • 글자색·배경색을 각각 적용·해제하며 선택이 바뀌면 아직 적용하지 않은 입력값을 버린다.
  • 읽기 전용·지원하지 않는 선택·IME 조합에서는 실행하지 않고, 각 변경은 한 undo 단위다.
  • 편집 DOM은 검증된 색만 표시한다. serializeToHtml과 native copy는 계속 data-weing-*만 내고 inline style을 저장하거나 복사하지 않는다.
  • 기본 CSS는 data-weing-color-picker-* 훅의 두 색 입력·버튼·상태 표시만 제공한다. 패널은 이미 공개된 --weing-editor-background·--weing-editor-surface-muted·--weing-editor-color· --weing-editor-border·--weing-editor-radius를 따르며, 앱별 dark token 값과 제품 팔레트·대비 정책은 소비자가 소유한다.

./react/font-picker — 글꼴·글자 크기 패널 (E6-4)

루트와 ./react는 재수출하지 않는다. 텍스트 선택 또는 현재 캐럿의 typography 상태를 네이티브 선택 상자 두 개에 연결하고 기존 글꼴·크기 적용/해제 명령과 중앙 값 검증만 재사용한다.

import { Editor } from "@weing-dev/editor/react";
import { EditorFontPicker } from "@weing-dev/editor/react/font-picker";

<Editor.Root>
  <Editor.Input aria-label="본문" />
  <EditorFontPicker fontSizeOptions={[12, 16, 24, 36]} />
</Editor.Root>;
  • 기본 글꼴 토큰은 sans-serif·serif·monospace이며 선택형 style.css가 세 토큰을 시스템 고딕·명조·고정폭 stack으로 즉시 표시한다. 호스트는 fontOptions의 안전 토큰·이름과 같은 data-weing-font CSS를 덮어써 브랜드 폰트와 웹폰트 로딩을 교체할 수 있다.
  • 글자 크기는 중앙 FONT_SIZE_POINTS의 닫힌 pt 집합만 선택한다. fontSizeOptions로 그 안의 부분집합·순서를 정할 수 있고 중복·유효하지 않은 런타임 값은 제외한다. 현재 문서 값이 목록 밖이면 현재 크기 항목으로 보존하며 임의 CSS 문자열은 받지 않는다.
  • 선택이 바뀌면 미적용 초안을 버리고, 적용·해제 뒤 해당 선택 상자에 focus가 남는다.
  • 읽기 전용·직접 engine.setEditable(false)·mark를 허용하지 않는 코드 블록·IME 조합에서는 실행하지 않는다. 각 문서 변경은 한 undo 단위다.
  • 편집 DOM은 검증된 data-weing-font·data-weing-font-size를 소비자 CSS로 표시하고, semantic/native copy HTML은 inline style을 저장하거나 복사하지 않는다.

./react/decoration-picker — 밑줄·취소선 표현 패널 (E6-7)

루트와 ./react는 재수출하지 않는다. 텍스트 선택 또는 현재 캐럿에서 밑줄·취소선 대상과 선 색, solid·dotted·dashed·wavy·double을 네이티브 컨트롤로 고른다.

import { Editor } from "@weing-dev/editor/react";
import { EditorDecorationPicker } from "@weing-dev/editor/react/decoration-picker";

<Editor.Root>
  <Editor.Input aria-label="본문" />
  <EditorDecorationPicker />
</Editor.Root>;
  • 기존 setUnderlineDecoration·setStrikeDecorationactiveDecoration만 재사용한다. 색 모드 글자색 따르기는 canonical color: null이다.
  • 일부에만 선이 있거나 필드 값이 섞이면 혼합으로 표시하고, 사용자가 필요한 값을 명시하기 전 적용을 잠근다.
  • readOnly·직접 editable-off·IME·실제 CellSelection·inline code·code block-only 선택에서는 실행하지 않는다. 적용 뒤 패널 focus와 편집 선택을 유지한다.
  • 편집 markView는 검증된 data 속성을 계산 스타일로만 옮긴다. semantic/native copy HTML은 data-weing-*-color/style을 보존하되 inline style을 저장하거나 복사하지 않는다.

./react/code-language — 코드 블록 언어 선택 패널 (E6-8)

루트와 ./react는 재수출하지 않는다. 코드 블록의 접힌 캐럿에서 기존 setCodeBlockLanguageactiveBlock을 네이티브 select·적용 버튼에 연결한다.

import { Editor } from "@weing-dev/editor/react";
import { EditorCodeLanguagePicker } from "@weing-dev/editor/react/code-language";

<Editor.Root>
  <Editor.Input aria-label="본문" />
  <EditorCodeLanguagePicker />
</Editor.Root>;
  • 기본 언어 목록을 제공하며 소비자 option은 id/label을 trim한 뒤 invalid 항목을 버리고 중복 id는 첫 항목만 쓴다. 현재 문서 값이 목록 밖이어도 그대로 표시하고 조용히 바꾸지 않는다.
  • 접힌 TextSelection이 코드 블록 안에 있을 때만 연다. 범위·다른 블록·Node/CellSelection·readOnly· 직접 editable-off·IME 조합에서는 실행하지 않고, handler가 실행 직전 같은 경계를 다시 검사한다.
  • 언어 적용과 언어 없음 해제는 각각 한 transaction·한 undo 단위다. 적용 뒤 select focus와 편집 selection을 유지한다.
  • canonical language id와 language-<id> class만 제공한다. 구문 강조·언어 자동 감지·언어별 parser/formatter와 loader는 소비자가 맡는다.
  • feature subpath 증분은 raw 5,170 · gzip 1,660 bytes이며 루트·./react 재수출은 없다.
  • picker Chromium 회귀와 SC-CODE-LANGUAGE-01언어 없음 직접 해제와 undo 뒤 TypeScript 복원을 확인한다. Storybook ProductAcceptance Playwright는 TypeScript 적용 뒤 undo로 language: null에 돌아오는 경로까지만 확인한다.

./react/table-insert — 표 삽입 크기 선택 패널 (E6-9)

루트와 ./react는 재수출하지 않는다. 표 밖의 접힌 캐럿에서 기존 insertTable의 행·열 params를 native number input 두 개와 삽입 버튼에 연결한다.

import { Editor } from "@weing-dev/editor/react";
import { EditorTableInsert } from "@weing-dev/editor/react/table-insert";

<Editor.Root>
  <Editor.Input aria-label="본문" />
  <EditorTableInsert />
</Editor.Root>;
  • 행·열 기본값은 각각 3, 허용 범위는 행 1..1000, 열 1..64다. 빈 값·소수·범위 밖 값은 보정하지 않고 native validity와 기존 상수로 거부한다.
  • 표 밖 접힌 캐럿에서만 실행한다. 범위·Node/CellSelection·표 안·readOnly·직접 editable-off·IME 조합에서는 잠기고, handler가 live selection·readOnly·editable·composition·canExecute를 다시 검사해 stale 실행을 막는다.
  • 성공은 기존 insertTable{ rows, columns }로 정확히 한 번 dispatch한다. 2×4라면 첫 행 <th> 네 칸과 둘째 행 <td> 네 칸이 생기고 .ProseMirror가 focus되며 PM·DOM selection은 첫 <th> 안에 있다. undo 한 번으로 문서와 selection이 삽입 전으로 돌아온다.
  • 공용 툴바·slash의 params 없는 빠른 삽입은 계속 기본 3×3이며 SC-TABLE-01이 그 shape를, SC-TABLE-09가 패널의 2×4·focus·undo를 지킨다.
  • feature subpath 증분은 raw 4,223 · gzip 1,393 bytes이며 루트·./react 재수출은 없다.
  • Storybook ProductAcceptance는 데스크톱과 390px에서 native 키보드 순회·2×4·focus·HTML·undo· overflow 0·axe serious/critical 위반 0을 확인한다. 이는 실기기 숫자 키패드·touch·focus나 정식 Firefox/WebKit·pixel visual regression 인증이 아니다.

./react/asset-insert — 자산 삽입 패널 (E5-3)

루트와 ./react 는 이것도 재수출하지 않는다. 내는 것은 값 하나(EditorAssetInsert)와 타입 하나(EditorAssetInsertProps)다. 업로드 상태 머신은 기존 createWeingUploadController 를 그대로 쓰며, 컨트롤러는 호스트가 만들어 소유한다.

import { useEffect, useMemo, useRef } from "react";
import { createWeingUploadController } from "@weing-dev/editor";
import { Editor } from "@weing-dev/editor/react";
import { EditorAssetInsert } from "@weing-dev/editor/react/asset-insert";

function Composer() {
  // 컨트롤러는 **호스트가 만들고 호스트가 끝낸다** — 패널은 destroy 하지 않는다.
  const controller = useMemo(() => createWeingUploadController(myUploadProvider), []);

  // 파기는 microtask 로 미루고 재실행이 취소한다 — StrictMode 의 setup→cleanup→setup 에서
  // 살아남을 인스턴스를 먼저 죽이지 않기 위해서다(`useEditor` 가 엔진에 쓰는 규약과 같다).
  const pendingDestroy = useRef<(() => void) | null>(null);
  useEffect(() => {
    pendingDestroy.current?.();
    pendingDestroy.current = null;
    return () => {
      let cancelled = false;
      pendingDestroy.current = () => {
        cancelled = true;
      };
      queueMicrotask(() => {
        if (cancelled) return;
        pendingDestroy.current = null;
        controller.destroy();
      });
    };
  }, [controller]);

  return (
    <Editor.Root resolveAssetSource={toDeliveryUrl}>
      <Editor.Input aria-label="본문" />
      <EditorAssetInsert controller={controller} />
    </Editor.Root>
  );
}

destroy() 를 부르지 않으면 화면이 사라진 뒤에도 진행 중이던 provider 작업과 그 시도가 살아 있다AbortSignal 이 abort 되지 않으므로 전송이 계속된다. (패널 자신의 구독은 useSyncExternalStore 가 언마운트에서 해제하므로 그것은 남지 않는다.) 패널이 대신 정리하지 않는 이유는 소유권이 하나여야 하기 때문이다 — 패널은 리마운트될 수 있고, 그때마다 남의 업로드를 끊으면 호스트가 시도를 이어갈 방법이 없다.

  • 흐름은 명시적 2단계다: 파일 선택은 업로드만 시작하고, 문서는 사용자가 "문서에 넣기" 를 눌렀을 때만 바뀐다. 마운트·리렌더·리마운트만으로 문서가 바뀌는 경로가 없다.
  • 삽입은 [declareAssets, insertImage|insertVideo] 한 트랜잭션이라 되돌리기 한 번에 블록과 자산 선언이 함께 사라진다.
  • alt 없이 넣을 수 없다. 이미지는 설명을 쓰거나 "장식 이미지" 를 직접 선택해야 하고 (장식이면 alt=""), 동영상은 설명이 필수다.
  • props: controller(필수) · kind("image"/"video", 기본 image) · accept(대화상자 필터, 기본은 kind 별 안전 목록 — SVG 없음) · label + 나머지 HTMLAttributes.
  • 파일 검증(MIME·크기·magic number·SVG)의 권위 있는 판정은 provider 의 prepare 다. accept 는 대화상자 힌트일 뿐 보안 경계가 아니다.
  • 시각은 소비자 CSS 가 소유한다. 패널은 data-weing-asset-* 표식만 낸다.
  • M3-1의 실제 CMS 예시는 apps/cms-basic/src/lib/editor/weing-s3-upload-adapter.ts다. 이 파일이 기존 S3 계약을 주입할 뿐 editor 패키지는 해당 API를 import하지 않으므로 다른 호스트는 provider와 resolver를 통째로 교체할 수 있다.

Editor.Root 붙여넣기·드롭 업로드 (M3-4)

패널의 명시적 2단계 흐름과 별개로, 선택적 createUploadController factory를 넘기면 편집 표면에 붙여넣거나 드롭한 실제 파일과 단독 base64 data: URL 하나를 바로 업로드할 수 있다.

const createTransferUploadController = useCallback(
  () => createWeingUploadController(myUploadProvider),
  [],
);

<Editor.Root
  resolveAssetSource={toDeliveryUrl}
  createUploadController={createTransferUploadController}
>
  <Editor.Input aria-label="본문" />
</Editor.Root>;
  • factory는 첫 자산 입력 때 한 번 호출된다. 반환한 controller는 Root 엔진이 소유하고 종료 때 destroy()하므로 이 경로에는 별도 host cleanup이 필요 없다.
  • 업로드를 시작한 selection/drop 위치는 그 사이 생긴 transaction mapping을 따라간다. 성공하면 [declareAssets, insertImage|insertVideo|insertFile] 한 transaction으로 들어가 undo 한 번에 자산 선언과 노드가 함께 사라진다.
  • 일반 http(s) URL은 fetch하지 않는다. 텍스트·표와 섞인 HTML도 가로채지 않고 기존 paste/drop 경로로 보내며, 실패했거나 이미 한 transfer가 진행 중인 인식 data URL은 평문으로 새지 않게 소비한다.
  • MIME·크기·magic number·SVG 정책은 계속 provider의 prepare 책임이다. 현재 CMS M3-1 adapter는 image kind만 제한하며 이 보안 검사를 구현하지 않았다. 다른 host는 provider와 resolver를 교체할 수 있고, 여러 transfer queue는 Deferred다.

./react/media-details — 미디어 정보 패널 (E5-4)

루트와 ./react 는 이것도 재수출하지 않는다. 내는 것은 값 하나(EditorMediaDetails)와 타입 하나(EditorMediaDetailsProps)다. 문서에 이미 있는 image·video 하나를 고른 상태에서 그 미디어의 대체 텍스트캡션을 고치고, "장식 이미지" 를 사람이 명시적으로 고를 수 있게 한다. 삽입 경로는 없다 — 그것은 ./react/asset-insert 가 소유한다.

import { Editor } from "@weing-dev/editor/react";
import { EditorMediaDetails } from "@weing-dev/editor/react/media-details";

<Editor.Root resolveAssetSource={toDeliveryUrl}>
  <Editor.Toolbar />
  <Editor.Input aria-label="본문" />
  {/* 편집 영역 **뒤**에 두는 것이 좋다 — 앞에 두면 본문에서 Shift+Tab 이 툴바에 닿지 못한다 */}
  <EditorMediaDetails />
</Editor.Root>;
  • 미디어를 고른 동안에만 DOM 이 있다. 캐럿·범위 선택·figure 선택·externalVideo 선택에서는 아무 것도 그리지 않으므로, 본문 타이핑 중 이 패널의 렌더는 0회다.
  • 저장은 클릭 핸들러 안에서만 일어나고, 실제로 달라진 것만 모아 [setMediaAlt, setMediaCaption|removeMediaCaption] 한 dispatch = undo 1단위로 보낸다. 바뀐 것이 없으면 저장 버튼이 잠긴다(빈 undo 단위를 만들지 않는다).
  • alt: null 을 새로 만들지 않는다. setMediaAlt 는 문자열만 받으며, "" 는 "사람이 장식이라고 판정했다" 는 canonical 표현이다(<img alt="">). 설명이 없는 과거·유입 이미지는 안내를 보여 주되 캡션만 고치는 편집을 막지 않는다.
  • 동영상에는 장식 선택지가 없다altaria-label 로 나가므로 빈 값은 이름 없는 재생 요소가 된다. 저장하려면 비어 있지 않은 값이 필요하다.
  • 서식 있는 캡션은 평문화하지 않는다. 그 캡션의 입력은 native readonly 이고 "본문에서 직접 고쳐 주세요" 를 안내한다. 같은 상태에서 대체 텍스트 저장은 그대로 동작한다.
  • 저장에 성공하면 focus 는 그 순간 활성인 컨트롤로 간다(장식이면 체크박스, 그 밖에는 대체 텍스트 입력). 편집 표면에는 .focus() 하지 않는다.
  • props: label(<legend> 내용, 기본 "미디어 정보") + 나머지 FieldsetHTMLAttributes. disabled받지 않는다(읽기 전용에서 우리가 켠다) · aria-label 도 받지 않는다 (이름 소유자는 <legend> 하나).
  • 시각은 소비자 CSS 가 소유한다. 패널은 data-weing-media-* 표식만 낸다.

./react/table-menu — 표 문맥 UI (E5-5)

루트와 ./react 는 이것도 재수출하지 않는다. 내는 것은 값 하나(EditorTableMenu)와 타입 하나(EditorTableMenuProps)다. 표 안에 선택이 있을 때만 나타나는 표 편집 도구이며, 항목은 기존 카탈로그의 인-테이블 12개를 그대로 쓴다(table-insert 는 없다 — 삽입은 메인 툴바가 계속 소유한다). 새 명령·새 상태·새 CSS 를 만들지 않는다. ids 로 그 12개를 좁힐 수는 있다 (프리셋에서 뺀 표 명령이 문맥 행에도 남지 않게 하는 통로다). 넓히는 문은 아니다 — 무엇을 주든 카탈로그 조회를 거치므로 없는 ID 는 조용히 빠진다.

import { Editor } from "@weing-dev/editor/react";
import { EditorTableMenu } from "@weing-dev/editor/react/table-menu";

<Editor.Root>
  <Editor.Toolbar />
  <Editor.Input aria-label="본문" />
  {/* 표 안 선택에서만 나타난다. 편집 영역 **뒤**에 두는 것이 좋다(Shift+Tab 목적지) */}
  <EditorTableMenu />
</Editor.Root>;
  • 부유 배치를 하지 않는다. 흐름 안의 평범한 <div> 하나이며 position·좌표·portal· z-index·scroll/resize 리스너가 없다 — 어디에 놓을지는 호스트가 합성으로 정한다. 칸 범위 선택(CellSelection)은 ProseMirror 가 DOM 선택을 숨기므로 앵커로 쓸 좌표 사실 자체가 없다.
  • 상태는 뿐이다: 표 밖·준비 전·항목 0개면 DOM 자체가 없고, 그 밖에는 존재한다. 읽기 전용에서도 보인다 — 12개가 전부 잠기고 aria-description 이 그 이유를 말한다.
  • 잠김·이유·aria-*·roving·mousedown 선택 보존은 Editor.Toolbar같은 계산이다.
  • 키보드 진입은 Shift+F10(네이티브 문맥 메뉴 관례). 표 에서는 소비하지 않으므로 브라우저의 문맥 메뉴가 그대로 살아 있고, 표 안에서는 우리가 가져가 메뉴 둘이 겹치지 않는다. Tab/Shift+Tab(칸 이동)과 Alt+F10(버블)은 건드리지 않는다.
  • 메뉴 안의 Escape 는 문서를 바꾸지 않고 편집 표면으로 초점을 되돌린다(표면은 그대로 있다). 표 삭제로 표면이 사라질 때는 초점이 메뉴 안에 있었을 때만 편집 표면으로 되돌린다 — 바깥 초점은 훔치지 않는다.
  • props: label(안쪽 툴바 이름, 기본 "표 편집 도구") · renderItem(Editor.Toolbar 와 같은 계약) + 나머지 HTMLAttributes. aria-label·children·items받지 않는다.
  • 시각은 소비자 CSS 가 소유한다. 표면은 data-weing-table-menu 표식만 낸다.

@weing-dev/editor/react런타임 export 전부(src/react/index.ts 기준):

| 갈래 | export | | --- | --- | | 전환기 compound facade | Editor, EditorRoot, EditorToolbar, EditorIconToolbar, EditorInput, EditorSkeleton, EditorSuggestionMenu | | Provider · 편집 표면 | EditorProvider, EditorContent | | 훅 · 스토어 | useEditor, useEditorEngine, useEditorSelector, createSelectorStore |

EditorEditorRoot/EditorToolbar/EditorIconToolbar/EditorInput/EditorSkeleton/EditorSuggestionMenu 를 묶은 compound 객체이며, 같은 컴포넌트를 이름 있는 export 로도 낸다(둘은 같은 것을 가리킨다). 훅 useSuggestionState 도 함께 나간다.

타입 export 는 위 목록과 별개다EditorProviderProps, SelectorStore, EditorChangePayload, EditorLegacyImportResult, EditorRootProps, EditorToolbarProps, EditorIconToolbarProps, EditorToolbarItemState, EditorToolbarItemProps, EditorInputProps, EditorSkeletonProps, EditorSuggestionMenuProps, EditorSuggestionItemProps, EditorSuggestionItemStateexport type 으로 나간다.

확장은 두 갈래로 설치된다

| 갈래 | 무엇 | 소비자가 하는 일 | | --- | --- | --- | | 엔진 자동 built-inbuiltInExtensions() | blockquote, image(+resize NodeView), video(+NodeView), externalVideo, formula, callout, figure, faq, footnote, table | 없음. 엔진이 언제나 스스로 먼저 합성한다(src/state/engine.ts) | | 소비자 opt-inCreateEditorOptions.extensions | 코어 명령 집합(coreExtension()) + 소비자 자체 확장 | 직접 넘겨야 한다. 이 필드는 optional 이라 빠뜨려도 컴파일·실행이 된다 |

코어 명령(굵게·제목·목록·링크·실행취소 …)은 엔진이 자동으로 넣어 주지 않는다. 무엇을 넣어야 하는지 추측하지 않도록 지원 집합을 만드는 factory 하나를 공개한다 — M1-18 지원 bootstrap 계약이다.

import { createSupportedEditorExtensions } from "@weing-dev/editor";

const engine = useEditor({ extensions: createSupportedEditorExtensions() });
  • 돌려주는 것은 소비자가 설치해야 하는 것만이다. 엔진이 이미 자동 합성하는 built-in 은 포함하지 않는다 — 다시 넣으면 같은 이름의 기여가 겹쳐 ExtensionCompositionError 로 합성이 실패한다(결정 C1).
  • 호출마다 새 배열·새 확장 객체를 만든다. 반환값에 자기 확장을 push 해도 다른 호출 결과나 이미 만들어진 에디터에 영향이 가지 않는다(공유 mutable singleton 없음).
  • feature-set 선택이 아니다. core/cms/blog 같은 갈래를 제공하지 않으며 앞으로도 현재 구조에서는 만들지 않는다 — 엔진이 블로그 기능까지 포함한 built-in 을 항상 넣으므로, 갈래를 만들면 이름만 다르고 실제 조합은 같은 거짓 선택지가 된다.
// 소비자 확장을 더할 때도 지원 집합 위에 얹는다.
const engine = useEditor({
  extensions: [...createSupportedEditorExtensions(), myExtension()],
});

왜 이 계약이 필요한가: extensions 를 생략하면 CORE_COMMANDS 가 하나도 등록되지 않는다. 미등록 command 는 canExecutefalse 를 돌려주므로(unknown command → 배치 전체 거부), 툴바 버튼이 조용히 비활성 상태로 렌더된다. 예외도 경고도 없다.

전환기 compound facade — Editor.Root/Toolbar/IconToolbar/Input/Skeleton (M1)

훅을 직접 조립하는 대신 화면 하나를 바로 세울 수 있는 compound 표면이다. extensions 를 주지 않으면 createSupportedEditorExtensions() 를 설치하므로 위의 함정에 빠지지 않는다.

"use client";
import { Editor } from "@weing-dev/editor/react";
import { EditorPresetToolbar } from "@weing-dev/editor/react/preset-toolbar";

<Editor.Root initialDocument={doc} placeholder="내용을 입력하세요" onChange={({ document, html }) => save(document)}>
  <EditorPresetToolbar />
  <Editor.Skeleton className="editor-skeleton" />
  <Editor.Input aria-label="본문 편집" />
</Editor.Root>

이것은 레거시 Quill Editor 의 drop-in 대체가 아니다

@weing-dev/ui-kit-primitive/editorEditor.Root/Input/Skeleton이름만 닮았다. prop 이름이 다르고(initialValueinitialDocument/initialHtml), 무엇보다 되돌려 주는 HTML 방언이 다르다. 소비처는 어댑터를 거쳐 옮겨온다. 이것은 "기존 화면을 신규 엔진으로 표현할 수 있는가" 라는 M1 게이트를 닫기 위한 전환기 표면이다.

참고로 레거시에는 Editor.Toolbar 컴포넌트가 애초에 없다(Quill 내장 툴바 + toolbarOptions prop + Editor.TOOLBAR 상수). 즉 여기의 Toolbar 는 호환 표면이 아니라 신규 표면이다.

| 컴포넌트 | 역할 | 주요 props | | --- | --- | --- | | Editor.Root | 엔진을 소유하고 Provider·onChange 를 배선한다 | initialDocument | (initialHtml + onLegacyImport), placeholder, readOnly, extensions, resolveAssetSource, createUploadController, onChange, documentId + div attrs | | Editor.Toolbar | 합성 카탈로그를 그린다 | items(기본 engine.getToolbarItems()), renderItem + div attrs | | Editor.IconToolbar | Editor.Toolbar의 실행·상태·roving 계약을 재사용하는 명령 전용 한 줄 아이콘 변형 | items(생략 시 BASIC_TOOLBAR_PRESET의 명령 ID만 선택), renderItem 제외 div attrs | | Editor.Input | 편집 표면(EditorView)을 마운트한다 | div attrs (aria-label 등) | | Editor.Skeleton | 편집 표면이 붙기 전에만 보인다 | div attrs |

툴바 UI 프리셋

프리셋은 화면에 버튼을 그대로 나열하는 배열이 아니라 사용할 수 있는 기능의 허용 목록이다. EditorPresetToolbar가 허용된 기능을 일반적인 작성 도구 구조로 배치한다. 기본값은 비블로그용 BASIC_TOOLBAR_PRESET이고, 블로그 화면은 BLOG_TOOLBAR_PRESET을 명시한다.

  • 기본 행(한 줄): 프리셋이 허용한 실행 취소/재실행, 문단 형식(본문·제목 1~6) 선택, 글자 크기 선택, 굵게/기울임/취소선, 목록, 정렬 선택 하나, 링크, 색상, 이미지 업로드, 그리고 더보기
  • 더보기(팝오버, layout="compact"): 인용, 강조 상자와 톤, 문단 역할, FAQ·각주·표·구분선· 토글·목차·북마크·외부 영상 삽입처럼 가끔 쓰는 명령 — 기본 행이 가져가지 않은 나머지 전부가 여기로 온다. layout="expanded"에서는 이 명령들이 주 툴바에 직접 나열된다
  • 문맥 행: 표의 행·열·머리글·병합/분할/삭제와 캡션 편집/제거. 현재 선택이 맞을 때만 나타나고, 맞지 않으면 DOM 자체가 없다(빈 상자도 남기지 않는다). 표 조작 열둘은 표 편집 팝오버 뒤에 접혀 있어 문맥 행 자체도 버튼 몇 개로 끝난다

정렬 넷은 버튼 넷이 아니라 선택 컨트롤 하나이고, 문단 형식도 마찬가지다 — 배타 선택은 "지금 무엇인가"를 자리 하나로 말하는 편이 정확하다. 그 외의 실행 버튼은 전부 아이콘이다: 명령 버튼은 Editor.IconToolbar가 그리고, 링크·색상·이미지 팝오버 트리거도 같은 아이콘 맵을 쓰며, 이름은 aria-label·설명은 hover/focus에서 보이는 native title이 갖는다(별도 tooltip 구현이 없다).

배치 정책 — layout

접느냐 펴느냐는 소비자 화면의 사정이므로 정책 축 하나로만 고른다. 기능 목록은 어느 쪽에서도 preset 하나가 정한다(허용 목록에서 ID를 빼면 두 배치 모두에서 사라진다).

| layout | 표면 | | --- | --- | | "compact"(기본값) | 위 세 영역 그대로. 비문맥 고급 명령은 더보기 팝오버 안에 아이콘으로 접혀 있다 | | "expanded" | 더보기 트리거도 팝오버 DOM도 만들지 않는다. compact에서 더보기에 들어가던 명령을 주 툴바에 아이콘으로 직접 나열하고, 좁아지면 줄바꿈한다 |

문맥 행(표·캡션)은 두 배치에서 동일하다 — 선택이 맞을 때만 DOM이 생기고, 어느 목록에도 섞이지 않는다. 루트 요소에 data-weing-preset-layout="compact|expanded"가 붙고 펼친 목록은 data-weing-preset-expanded-toolbar로 구분되므로, 소비자 CSS는 두 배치를 안정적으로 분기할 수 있다. 타입은 EditorPresetToolbarLayout으로 공개한다.

import { BLOG_TOOLBAR_PRESET } from "@weing-dev/editor";
import {
  EditorPresetToolbar,
  type EditorPresetToolbarLayout,
} from "@weing-dev/editor/react/preset-toolbar";

// 기본값 — 8개 기본 명령과 색상 컨트롤.
<EditorPresetToolbar />

// 블로그 작성 화면 — 고급 명령까지 아이콘으로 펼친다.
<EditorPresetToolbar
  preset={BLOG_TOOLBAR_PRESET}
  layout="expanded"
  uploadController={imageUploadController}
/>

프리셋 항목은 대부분 카탈로그 항목 ID이고, BASIC의 color-picker를 포함해 대응 명령이 없는 값 입력 기능(font-size-select·link-editor·color-picker·image-upload)만 같은 계약의 기능 ID로 적는다. 따라서 프리셋에서 ID를 빼면 기본 행·더보기·문맥 행 어디에서도 그 기능이 사라지고, ID를 더해도 모든 기능이 기본 행에 쏟아지지 않는다. 배치 상수는 "어디에 놓을지"만 알고 "무엇을 낼지"는 프리셋이 정한다. 제품은 저장소처럼 런타임 의존성만 연결하고 툴바를 다시 조립하지 않는다. 루트 진입점은 동결된 readonly ID 배열 BASIC_TOOLBAR_PRESET·BLOG_TOOLBAR_PRESET과 저수준 커스텀 툴바용 카탈로그 선택 함수 selectToolbarItems를 공개한다.

import {
  BASIC_TOOLBAR_PRESET,
  BLOG_TOOLBAR_PRESET,
  CORE_COMMANDS,
  createSupportedEditorExtensions,
  selectToolbarItems,
  type WeingEditorExtension,
} from "@weing-dev/editor";
import { Editor, useEditorEngine } from "@weing-dev/editor/react";
import { EditorPresetToolbar } from "@weing-dev/editor/react/preset-toolbar";

function PresetIconToolbar({ ids, label }: { readonly ids: readonly string[]; readonly label: string }) {
  const engine = useEditorEngine();
  return <Editor.IconToolbar items={selectToolbarItems(engine.getToolbarItems(), ids)} aria-label={label} />;
}

const customExtension: WeingEditorExtension = {
  name: "custom-toolbar",
  toolbar: [{
    id: "custom-id",
    group: "custom",
    order: 10,
    command: CORE_COMMANDS.toggleBold,
    label: "커스텀 굵게",
  }],
};
const customExtensions = [...createSupportedEditorExtensions(), customExtension];
const customPreset = [...BASIC_TOOLBAR_PRESET.filter((id) => id !== "italic"), "custom-id"];

function ToolbarPresetExamples() {
  return (
    <>
      <Editor.Root>
        <EditorPresetToolbar />
        <Editor.Input aria-label="기본 본문" />
      </Editor.Root>
      <Editor.Root>
        <EditorPresetToolbar
          preset={BLOG_TOOLBAR_PRESET}
          uploadController={imageUploadController}
        />
        <Editor.Input aria-label="블로그 본문" />
      </Editor.Root>
      <Editor.Root extensions={customExtensions}>
        <PresetIconToolbar ids={customPreset} label="커스텀 서식 도구" />
      </Editor.Root>
    </>
  );
}

selectToolbarItems는 ID 순서를 보존하고 없는 ID와 두 번째 이후 중복을 건너뛴다. 입력은 바꾸지 않고 반환 배열을 동결한다. BASIC은 기존 축약 명령 8개에 color-picker 기능 ID를 더하며, EditorPresetToolbar 기본값이 이를 실제 색상 컨트롤로 해석한다. 저수준 Editor.IconToolbar는 명령 서술자만 받아 기능 ID를 가짜 버튼으로 만들지 않는다. BLOG는 블로그 작성에 필요한 명령과 값 입력 기능을 모두 허용하며, EditorPresetToolbar가 위 세 영역으로 나눈다. 링크·색상·이미지는 popover 안의 기존 feature UI이고, 정렬은 하나의 선택 컨트롤이다. 이미지 패널(EditorAssetInsert)을 기본 행에 그대로 두지 않는 이유는 업로드가 성공하는 순간 파일 이름·설명 입력·상태 안내가 펼쳐져 한 줄 툴바를 부풀리기 때문이다 — 패널의 의미는 그대로 두고 놓이는 자리만 팝오버로 옮겼다. 업로드 controller가 없으면 이미지 버튼은 이유를 포함한 비활성 상태다. 표 문맥 12개는 EditorTableMenu가 그대로 맡으므로(포커스 복귀·Shift+F10 진입·IME 규약이 거기 있다) 프리셋 툴바는 그 표면에 ids로 허용 목록만 좁혀 넘긴다.

이 프리셋은 작성 툴바 표시만 바꾼다. 홈페이지·소비 표시는 패키지 style.css와 기존 semantic HTML/정적 자산 출력을 그대로 재사용하며, 저장 방언·업로드 보안·운영 전환은 별도 M3 경계다.

Editor.IconToolbar는 패키지가 소유하는 36px 아이콘 전용 버튼을 그린다. 항목의 aria-label·aria-disabled·aria-pressed·aria-current·tabIndex·실행 handler는 Editor.Toolbar에서 그대로 받고, native title은 기능명·그룹과 정확한 잠김 사유를 담는다. 내장 아이콘 누락은 DOM 회귀가 전체 카탈로그로 막고, 사용자 확장은 item.icon으로 내장 아이콘을 별칭하거나 알 수 없는 경우 보이는 텍스트 fallback을 받는다. 표시 그룹명 대신 그룹 시작 구분선만 남기고, 좁은 폭은 한 줄 내부 스크롤로 처리해 문서를 밀지 않는다. Storybook GroupedToolbar는 전체 카탈로그 기본값만 더하는 얇은 Editor.IconToolbar 어댑터다.

div attrs 에서 onChange의도적으로 제외했다 — facade 의 onChange 와 이름이 겹치기 때문이다. react-hook-form 의 field 를 통째로 스프레드하는 사용법은 지원하지 않는다.

placeholder 표시에는 CSS 가 필요하다

엔진은 빈 문서(canonical empty — 최상위 자식이 빈 paragraph 하나)의 그 블록에 표시자만 얹는다. 문자열을 텍스트 노드로 넣지 않는 이유는 그 순간 안내 문구가 selection·IME 조합·복사 경로에 끼어들기 때문이다(kernel/placeholder.ts).

| 편집 DOM 표시자 | 값 | | --- | --- | | class | weing-placeholder | | 속성 | data-weing-placeholder="<호스트가 넘긴 문자열 그대로>" |

가장 짧은 통합은 선택형 기본 CSS를 불러오는 것이다.

import "@weing-dev/editor/style.css";

이를 생략한 완전 headless 소비자는 아래와 같은 규칙을 직접 제공해야 한다. 규칙이 없으면 속성은 붙어 있어도 사용자는 빈 화면을 보고 "아직 안 만들어진 화면" 으로 읽는다.

/* 제품 CSS로 직접 구현할 때의 최소 예. */
.ProseMirror .weing-placeholder::before {
  content: attr(data-weing-placeholder);
  float: left;
  height: 0;
  pointer-events: none;
  /* 흰 배경 대비 4.83:1. `#9ca3af` 는 약 2.5:1 이라 WCAG 2.2 §1.4.3 본문 최소(4.5:1) 미만이다 —
     안내 문구는 장식이 아니라 이 화면의 유일한 설명이므로 읽히지 않으면 빈 화면과 같다. */
  color: #6b7280;
}
  • 표시 조건은 빈 문서 + 편집 가능이다. 첫 글자에 사라지고 다시 비우면 돌아온다. 읽기 전용 에서는 나타나지 않는다 — 쓸 수 없는 표면에 "여기에 쓰라" 는 거짓말이기 때문이다.
  • 빈 문자열 "" 과 200자 초과는 표시 없음으로 수렴한다(자르지 않는다).
  • 기본 규칙은 packages/editor/src/style.css, 제품 예시와 화면 회귀는 Storybook ContentStyleGuidepackages/editor/e2e/storybook-screens.spec.ts에 있다.

초기 내용은 둘 중 하나다

initialDocument(canonical)와 initialHtml(레거시 Quill HTML)은 타입에서 상호 배타이며 둘 다 주면 렌더가 명시적 오류로 실패한다 — 암묵적 우선순위를 두지 않는다.

  • initialHtml읽기 1회다. migrateQuillHtml 로 최초 1회만 변환하며 이후 prop 변경은 무시한다. 그래서 onLegacyImport필수다: 성공·손실·실패를 effect 에서 정확히 한 번 받는다(StrictMode 에서도 한 번). 타입뿐 아니라 런타임에서도 콜백이 함수가 아니면 실패한다.
  • 변환이 실패하면 편집 가능한 빈 문서로 떨어진다. 타이밍을 정확히 말하면 엔진은 렌더 단계에서 이미 만들어지고 통지는 그 뒤 effect 에서 온다 — "보고가 먼저" 가 아니다. 계약은 순서가 아니라 누락 불가다: 실패가 조용히 사라지는 경로는 없다.

onChange — 문서가 바뀐 트랜잭션에서만, HTML 은 지연 계산

onChange={(payload) => {
  payload.document; // 그 트랜잭션 직후의 canonical 문서(deep-frozen)
  payload.html;     // 그 문서를 직렬화한 semantic HTML — 처음 읽을 때 1회 계산
}}
  • selection-only · meta-only · 실패한 명령(no-op)에서는 발화하지 않는다.
  • html트랜잭션 시점의 문서에 묶인다. payload 를 보관했다가 나중에 읽어도 그때의 문서가 아니라 당시 문서가 나온다. 읽지 않으면 직렬화 비용이 아예 발생하지 않는다(§4.4).
  • 이 HTML 은 semantic HTML 이지 Quill 방언이 아니다. 레거시 필드에 되쓰면 저장된 방언이 바뀐다. 그 전환을 운영에 적용하는 일(shadow read · feature flag · telemetry · rollback · canonical storage rollout)은 M3 범위다.
  • resolveAssetSource 는 엔진 계약과 같이 생성 시점 전용이다. 최초 1회 값이 편집 표면과 payload HTML 양쪽에 쓰이며 이후 렌더에서 다른 함수를 넘겨도 무시된다 — 그래야 "화면이 그린 것" 과 "저장하려는 HTML" 이 갈라지지 않는다. 런타임에 해석을 바꾸려면 해석기 안의 캐시를 갱신하고 engine.refreshAssetSources() 를 부른다.

Toolbar 는 params 를 지어내지 않는다

서술자가 담은 정적 params(heading-2{ level: 2 } 등)를 그대로 넘기거나, 없으면 넘기지 않는다. 런타임에만 알 수 있는 params 를 요구하는 명령(setLink·insertImage·setTextColor …)은 애초에 카탈로그에 없다 — "눌러도 언제나 실패하는 버튼" 을 만들지 않으려는 결정이며, 그런 진입점은 제품의 입력·피커 플로우가 소유한다.

기본 버튼은 type="button", aria-label, aria-disabled={!canExecute} 를 갖고, 활성 상태는 data-weing-toolbar-active="true|false" 스타일링 훅으로 드러낸다.

mousedown 을 막아 선택과 포커스가 편집 표면에 남고, 키보드(Enter/Space)로 누르면 포커스가 버튼에 남는다(툴바 키보드 이동을 깨지 않는다 — 강제로 에디터로 되돌리지 않는다). 실행 경로는 onClick 하나뿐이다.

Toolbar 키보드·접근성 (WAI-ARIA toolbar 패턴)

결정 근거는 ADR 0005-toolbar-roving-focus-and-toggle-semantics 에 있다.

| 축 | 계약 | | --- | --- | | tab stop | 툴바 전체에 1개. 마지막 포커스 항목 → 없으면 실행 가능한 첫 항목 → 전부 비활성이면 첫 항목 | | 가로(기본) | ArrowRight 다음 · ArrowLeft 이전 · Home/End 처음·끝 | | 세로(aria-orientation="vertical") | ArrowUp/ArrowDown · Home/End. dir 로 뒤집히지 않는다 | | RTL(가로) | 화살표는 물리적 화면 방향을 따른다 — ArrowRight 는 index 감소, ArrowLeft 는 증가 | | wrapping | 항상 감긴다(항목 수와 무관한 한 정책). Home/End 는 RTL 에서도 배열의 처음·끝 | | 비활성 항목 | 숨기지 않고 순회에도 남는다(aria-disabled="true"). pointer·Enter·Space 어느 경로로도 실행되지 않는다 | | 실행 후 focus | 실행한 항목에 남는다. Tab 한 번으로 편집 표면에 복귀한다(DOM 순서 Toolbar → Input) |

⚠ 기본 버튼은 native disabled 를 더 이상 만들지 않는다

비활성 항목이 native disabled 면 포커스를 받지 못해 툴바 순회에서 통째로 사라진다 — 사용자가 "그 기능이 없다" 와 "지금은 못 쓴다" 를 구별할 수 없다. 그래서 focusable-but-disabled (aria-disabled="true")로 바꿨다. button:disabled 로 스타일링하던 소비자는 [aria-disabled="true"] 로 옮겨야 한다.

버튼 상태는 세 부류가 각자의 축으로 말한다

WeingToolbarItem.isActive 는 toggle 상태만 뜻하지 않는다 — 표 행/열 동작, FAQ·각주 삽입, 캡션 제거처럼 "지금 그 문맥 안인가" 를 표시하는 데도 쓰인다. 그래서 isActive 에서 aria-pressed 를 기계적으로 파생하지 않고, 서술자가 명시한 의미를 따른다.

interface WeingToolbarItem {
  readonly semantics?: "action" | "toggle" | "choice"; // 생략 = "action"
}

| semantics | 무엇 | 보조기술 | 시각 훅 | | --- | --- | --- | --- | | "toggle" | 같은 명령이 켜고 끄는 이진 토글 | aria-pressed="true\|false" | data-weing-toolbar-active | | "choice" | 서로 배타적인 선택지 중 현재 값 | aria-current="true\|false" | data-weing-toolbar-active + data-weing-toolbar-current | | "action"(기본) | 한 번 실행되는 동작 | 선택 상태 없음 | data-weing-toolbar-context (문맥만) |

  • 현재 toggle: 인라인 mark 7종, 목록 3종, code-block, blockquote.
  • 현재 choice: paragraph·heading-1~6·align-*·text-direction-*·paragraph-role-*· callout-tone-*. 눌러서 끌 수 없으므로 aria-pressed 를 붙이지 않는다(W3C APG Button Pattern 은 그것을 두 상태 토글에만 쓰라고 한다). aria-current 는 정석 radio 패턴 (role="radio" + aria-checked + radiogroup)의 직접 대체가 아니라, 그 승격 전까지 현재 선택값이 아예 전달되지 않는 상태를 없애는 잠정 축이다([ADR 0005 §남은 격차]).
  • action 은 선택 표시를 갖지 않는다. 표 행 추가 같은 일회성 버튼이 "이미 적용됨" 으로 보이면 사용자는 누를 필요가 없다고 읽는다. 문맥 강조가 필요하면 [data-weing-toolbar-context="true"] 로 스타일링한다 — 예전에 data-weing-toolbar-active 로 그렸다면 이 선택자로 옮겨야 한다.
  • 이미 골라진 choice 는 잠기지 않는다. 엔진은 "바꿀 것이 없다" 를 canExecute === false 로 답하지만, 그것을 aria-disabled 로 옮기면 "골라져 있는데 잠겨 있다" 는 모순이 된다. 이 판정은 렌더·tab stop·state 가 한 곳에서 계산된 같은 값을 본다.
  • 생략은 action 과 같으므로 기존 확장은 손댈 필요가 없다.

잠긴 항목은 잠겼는지도 말한다

aria-disabled="true" 만으로는 "고장" 과 "지금은 쓸 수 없음" 을 구별할 수 없다. 기본 렌더러는 잠긴 항목에만 다음을 붙인다(쓸 수 있는 항목에는 키 자체가 없다).

<button aria-disabled="true"
        aria-description="되돌릴 편집이 없음"
        title="실행 취소 — 되돌릴 편집이 없음">

문구는 조건마다 다르다. 하나로 고정하면 "실행 취소 — 현재 선택에서는 사용할 수 없음" 처럼 사실과 다른 안내가 된다(선택을 바꿔도 풀리지 않는다).

| 조건 | TOOLBAR_DISABLED_REASONS 키 | 문구 | | --- | --- | --- | | 문서가 읽기 전용 | readOnly | 읽기 전용이라 편집할 수 없음 | | IME 조합 중 | composing | 입력 조합 중에는 사용할 수 없음 | | 되돌릴 편집이 없음 | nothingToUndo | 되돌릴 편집이 없음 | | 다시 실행할 편집이 없음 | nothingToRedo | 다시 실행할 편집이 없음 | | 그 외(블록·선택 조건) | context | 현재 선택에서는 사용할 수 없음 |

읽기 전용이 다른 모든 조건을 덮고, 그다음은 조합 중 잠금이다. 같은 이유로 "골라진 choice 는 잠그지 않는다" 는 예외도 편집 가능하고 조합 중이 아닐 때만 적용된다 — 읽기 전용이나 조합 중에는 현재 선택값도 함께 잠긴다.

renderItemstate.disabledReason(잠기지 않았으면 undefined)으로 같은 값을 받는다 — custom 툴바가 자기 문구를 짓지 않고 이것을 그대로 쓰면 두 통로가 갈라질 수 없다.

custom renderItemstate.itemProps 를 스프레드한다

호출 형태 renderItem(item, state) 는 그대로다. state 에 다음이 가산됐다.

| 필드 | 뜻 | | --- | --- | | itemProps | 항목 요소에 그대로 스프레드하는 props(roving tabIndex, aria-label, aria-disabled, toggle 이면 aria-pressed · choice 면 aria-current, 잠겼으면 aria-description, data-weing-toolbar-*, onMouseDown, onClick) | | semantics | 해석된 버튼 의미("action" | "toggle" | "choice") | | active | 선택 상태 — toggle 의 켜짐 또는 choice 의 현재값. action 은 언제나 false | | contextActive | action지금 의미를 갖는 자리인가(표 안·FAQ 안 …). toggle·choice 는 false | | disabledReason | 잠긴 이유 문구. 잠기지 않았으면 undefined | | tabbable | 이 항목이 툴바의 유일한 tab stop 인지 |

<Editor.Toolbar
  renderItem={(item, state) => (
    // 기본 렌더러와 **같은** roving·a11y 계약을 그대로 얻는다.
    <button type="button" {...state.itemProps} className={state.active ? "is-active" : undefined}>
      <Icon name={item.icon} />
    </button>
  )}
/>
  • itemProps 를 스프레드하지 않으면 그 항목은 roving 순회에서 빠진다. 툴바는 [data-weing-toolbar-item] 을 DOM 순서로 훑어 이동하므로, 그 data 속성이 없는 요소는 툴바가 보지 못한다. ref 를 되돌려 줄 필요는 없다 — 스프레드한 요소가 포커스 가능하기만 하면 된다.
  • onClick 은 비활성 항목에서 명령을 실행하지 않는다. 직접 state.run 을 부르는 경우도 같다.
  • state.disabled 로 native disabled 를 붙이는 기존 코드도 그대로 동작한다(그 선택은 소비자의 것이다) — 다만 그 항목은 위의 이유로 툴바 순회에서 빠진다.

반복 실행 가능한 검증 명령

pnpm --filter @weing-dev/editor test:a11y   # 실 Chromium — axe 위반 0 + 툴바 키보드 계약
  • src/react/toolbar-a11y.browser.test.tsx — 툴바 + 편집 표면(기본/비활성/readOnly/세로/ aria-pressed 켜진 상태)에 대해 axe 위반 0.
  • src/react/toolbar-keyboard.browser.test.tsxShift+Tab 진입 → Arrow/Home/EndEnter 실행 → semantic HTML 반영 → Tab 복귀.
  • 정책 행렬(가로/세로/RTL/wrapping/disabled/semantics/custom renderer)은 jsdom 쪽 src/react/toolbar-roving.dom.test.tsx 가 고정한다.

증거 등급. axe 는 자동 판정 가능한 위반만 본다. "위반 0" 은 접근성 인증이 아니라 회귀 방지선이다. Storybook editor-weing-editor-m1--toolbar-keyboardplaycomponent interaction fixture 이며, static build 통과는 접근성 증거가 아니다. 보조기술 실사용·Safari/Firefox 커버리지는 릴리스 인증 트랙의 OPEN 항목으로 남아 있다.

/ 명령 검색과 인라인 이모지 — suggestion (E1-1 · E7-1)

결정 근거는 ADR 0006-suggestion-state-focus-and-execution 에 있다.

import {
  createEmojiSuggestionSource,
  createSlashSuggestionSource,
} from "@weing-dev/editor";

<Editor.Root
  initialDocument={doc}
  suggestionSources={[createSlashSuggestionSource(), createEmojiSuggestionSource()]}
>
  <Editor.Toolbar />
  <Editor.SuggestionMenu />   {/* 닫혀 있으면 DOM 을 하나도 만들지 않는다 */}
  <Editor.Input aria-label="본문 편집" />
</Editor.Root>

suggestionSources 는 필수다 — 생략하면 기능 자체가 없다. createWeingEditorEditor.Root 둘 다 opt-in 이며, 켜지 않은 소비자의 동작은 E1-1 이전과 한 글자도 다르지 않다(키 경로·ARIA·번들 동작 전부).

언제 열리는가

slash는 빈 텍스트 선택이고 현재 textblock 이 paragraph 이며 / 가 그 문단의 첫 문자이고 caret 이 /query 바로 뒤일 때 열린다. 이모지는 빈 선택의 paragraph·heading에서 블록 시작 또는 공백 뒤 :query로 열린다. https:·12:30·문장:smile은 열리지 않는다. 두 source 모두 query 안에 공백·줄바꿈이 있거나 길이가 WEING_SUGGESTION_MAX_QUERY_LENGTH(64 code point)를 넘으면 닫히고, 읽기 전용과 IME 조합 중에는 열리지 않는다.

| close reason | 언제 | | --- | --- | | escape | 사용자가 Escape 를 눌렀다. /query 와 문서·history 는 그대로다 | | executed | 항목을 실행했다 | | selection-change | caret 이 trigger 범위를 벗어났다 | | trigger-removed | / 가 지워졌거나 query 가 계약을 벗어났다 | | read-only | 편집이 잠겼다 | | programmatic | 소비자가 closeSuggestion() 을 불렀다 |

Escape 로 닫은 같은 trigger session 은 곧바로 다시 열리지 않는다. caret 이 범위를 벗어나 감지가 끊기면 억제가 풀리고, 새로 만든 / 는 다시 열 수 있다.

키보드

ArrowDown/ArrowUp 이동(양끝에서 감긴다), Home/End 처음·끝, Enter 실행, Escape 닫기. 처리했을 때만 preventDefault 한다 — Left/Right/Backspace/Delete· printable·플랫폼 편집 shortcut·Tab 은 가로채지 않고, 결과가 0건이면 Enter 도 소비하지 않는다(그래야 평소처럼 문단이 나뉜다).

menu 를 열고 이동하고 실행하는 동안 DOM focus 는 편집 표면에 남는다. 열려 있는 동안 편집 표면은 role="combobox" 로 승격되고 aria-expanded/aria-controls/aria-activedescendant/ aria-autocomplete 로 popup 과 연결된다. 닫히면 전부 정리되고 role="textbox" 로 돌아간다. 결과가 0건이면 listbox 가 아니라 role="status" 로 "검색 결과 없음" 을 알린다(빈 listbox 는 규격 위반이다).

기본 항목 14개

전부 기존 command 를 그대로 가리킨다. 한글 label 과 한글·영문 keyword 를 함께 갖는다.

이 제목은 E4-3 까지 12개 로 적혀 있었지만 표에는 실제로 13개가 있었다(E4-1 의 divider· E4-2 의 toggle 이 더해질 때 제목을 함께 고치지 않았다). E4-3 의 toc 를 더하면서 실측 14개로 바로잡는다 — SC-SLASH-01suggestion-menu.browser.test.tsx 가 같은 수를 센다.

| id | label | effect | | --- | --- | --- | | paragraph | 본문 | delete-only/query 만 지운다 | | heading-1·2·3 | 제목 1·2·3 | setHeading { level } | | bullet-list | 글머리 기호 목록 | toggleBulletList | | ordered-list | 번호 목록 | toggleOrderedList | | check-list | 체크 목록 | toggleCheckList | | blockquote | 인용문 | toggleBlockquote | | code-block | 코드 블록 | toggleCodeBlock | | table | 표 | TABLE_COMMANDS.insertTable | | callout | 강조 상자 | CALLOUT_COMMANDS.wrapInCallout | | divider | 구분선 | DIVIDER_COMMANDS.insertHorizontalRule(E4-1) | | toggle | 토글 | TOGGLE_COMMANDS.insertToggle(E4-2) | | toc | 목차 | TOC_COMMANDS.insertTableOfContents(E4-3) |

본문 이 delete-only 인 이유: setParagraph 를 넣으면 이미 문단인 자리에서 그 command 가 "바뀌는 것이 없다" 며 false 를 돌려주고, all-or-nothing 규약(ADR 0003)이 query 삭제까지 되돌린다 — 눌렀는데 아무 일도 일어나지 않는다. 그래서 의도를 타입으로 말한다.

현재 문맥에서 실행할 수 없는 항목은 목록에 나타나지 않는다. 후보 필터가 실행과 같은 배치 (/query 삭제 + command)를 dry-run 하므로, "이 문단에서는 표를 넣을 수 없다" 류의 일반적인 문맥 불일치가 메뉴에 노출되지 않는다.

그렇다고 실패가 불가능한 것은 아니다. dry-run 과 실제 실행 사이에는 시간이 있고, 그 사이에 문서·selection·읽기 전용 여부·확장이 참조하는 외부 상태가 바뀔 수 있다. 그래서 실제 실행은 언제나 다시 판정하며 실패할 수 있다. 계약은 "실패가 없다" 가 아니라 "실패해도 아무 것도 남지 않는다" — 배치가 통째로 abort 되므로 문서·selection·version·이벤트·history·suggestion 상태가 하나도 바뀌지 않는다(ADR 0003).

비용: 후보 하나의 dry-run 비용은 그 command 가 무엇을 건드리느냐로 갈린다. 기본 slash 항목 12개처럼 선택 범위 안만 바꾸는 로컬 명령getDraftDelta 의 selection-local fast path 를 타 문서 전체 크기와 무관하다(step 수 · 선택이 걸친 top-level 범위 크기에 비례). 반면 선택 범위 밖을 건드리거나 전역인 command 는 정확성을 위해 최상위 블록 full scan 으로 폴백하므로 최상위 블록 수에 비례한다. catalog 에 전역 명령을 넣으면 그 항목의 dry-run 이 keystroke 마다 full scan 을 유발한다.

실행은 하나의 transaction 이다

/query 제거와 대상 command 는 같은 커널 세션에 누적된다 → docChanged 이벤트 1회, version +1, undo 1단위. undo 한 번이면 실행 전 문단과 /query 가 함께 돌아오고 redo 한 번이면 결과가 돌아온다. 실패(대상 command 불가·stale range)는 문서·selection·version· 이벤트·history·suggestion 상태를 부분 변경하지 않는다.

엔진 표면 (React 없이도 쓴다)

const engine = createWeingEditor({ suggestionSources: [createSlashSuggestionSource()] });
engine.getSuggestionState();        // 값이 그대로면 같은 참조
engine.moveSuggestion("next");      // "next" | "previous" | "first" | "last"
engine.setActiveSuggestionItem(id); // pointer hover
engine.executeSuggestionItem(id?);  // 생략하면 active
engine.closeSuggestion();

createWeingEditorEditor.Root 둘 다 opt-in(기본값 없음)이다. 켜는 순간 Enter/Escape/화살표가 특정 조건에서 다른 뜻을 갖는데, Editor.SuggestionMenu 를 렌더하지 않은 화면에서는 그 변화가 보이지 않는 채로 일어난다 — 아무것도 없는 화면에서 Enter 가 문단을 나누지 않는 것처럼 보인다. 기존 소비자가 코드를 고치지 않고 그 변화를 맞는 것은 additive 가 아니므로 두 표면 모두 명시를 요구한다(ADR 0006 §2).

suggestion-only 변화는 subscribe 구독자에게 통지되지만 getSnapshot().version·문서·selection· history·WeingTransactionEvent바꾸지 않는다. 닫힌 상태의 이동·실행, 존재하지 않는 id, 결과 0건의 실행은 전부 false 이고 아무 상태도 바뀌지 않는다.

custom renderItem

toolbar 의 itemProps 와 같은 형태다.

<Editor.SuggestionMenu
  renderItem={(item, state) => (
    <button type="button" {...state.itemProps}>
      <Icon name={item.icon} />
      {item.label}
    </button>
  )}
/>

itemProps 에는 id(편집 표면의 aria-activedescendant 가 가리키는 값)·role="option"· aria-selected·tabIndex: -1·onMouseDown(selection 보존)·onMouseEnter·onClick 이 들어 있다. 스프레드하지 않으면 그 항목은 ARIA 연결과 실행 계약을 잃는다.

React render 예산

/ 열림·query 변경·Arrow 이동·Escape 동안 Editor.Input React render 는 0회다 — menu 컴포넌트만 suggestion store 를 구독한다(useSuggestionState).

상태 파생 중 텍스트 읽기와 anchor 계산은 현재 textblock 과 유계 catalog 만 보므로 10,000 block 문서에서도 문서 크기에 비례하지 않는다(kernel/suggestion-scan-bounds.dom.test.ts 가 회전 노드 수로 고정한다). 후보 필터의 dry-run 은 command 종류로 갈린다 — 기본 slash 항목처럼 선택 범위 안만 바꾸는 로컬 명령은 문서 크기와 무관하지만, 선택 범위 밖·전역 command 는 정확성을 위해 최상위 블록 full scan 으로 폴백한다.

편집 표면의 접근성

ARIA 는 Editor.Input 이 그리는 host <div> 가 아니라 커널이 그 안에 만드는 실제 .ProseMirror[contenteditable] 에 붙는다 — 보조기술이 편집 대상으로 보는 것이 그쪽이기 때문이다.

| 속성 | 값 | | --- | --- | | role | textbox | | aria-multiline | true | | aria-readonly | readOnly 를 따라간다 | | aria-label / aria-labelledby / aria-describedby | Editor.Input 에 준 값이 그대로 옮겨간다 |

  • 이름이 하나도 없으면 안전한 기본값 본문 편집 을 준다.
  • aria-labelledby 가 있으면 기본 aria-label 을 만들지 않는다. 이름 계산 규격(accname)에서 aria-labelledbyaria-label 보다 먼저 평가되어 이기므로 기본값을 만들어도 이름이 바뀌지는 않는다 — 다만 읽히지도 않을 속성이 DOM 에 남아 "무엇이 이름인가" 를 흐릴 뿐이다. 소비자가 aria-label명시하면 그대로 싣는다(둘 다 준 것은 소비자의 의도다).
  • prop 변경·readOnly 토글·문서 트랜잭션 뒤에도 유지된다(적용은 멱등이라 DOM 변이도, React 렌더도 추가로 일으키지 않는다).

생명주기

  • 엔진 identity 는 커밋된 수명 동안 안정적이고 실제 언마운트에서 정확히 1회 파기된다 (useEditor 의 StrictMode 규약).
  • engine.mount 가 실패하면 소유권 토큰을 반드시 놓아 준다 — 실패한 시도가 표면을 영원히 점유해 재시도가 "이미 마운트됨" 으로 막히지 않는다. 그때 readiness 도 올리지 않는다.
  • Editor.Skeletonaria-hidden="true" 는 소비자가 덮을 수 없다(빈 자리표시자를 보조기술에 읽히게 만드는 실수를 막는다).
  • Editor.Input 은 하나만 둔다. 둘이면 커널이 두 번째를 조용히 삼켜 빈 div 를 남기는 대신 명시적 오류를 낸다.
  • Editor.Skeleton 은 편집 표면이 실제로 붙었는지를 따르며 마운트 시 한 번만 바뀐다 (트랜잭션마다 깜빡이지 않는다). Editor.Input 이 없으면 계속 보인다.
  • readOnly 는 마운트 이후 바뀌어도 setEditable 로 동기화되며 문서를 건드리지 않는다.
  • client 전용이다. 서버 렌더에서는 migrateQuillHtml 이 던지지 않고 구조화된 실패를 돌려주므로 hydration 이 깨지지 않는다. 렌더 단계는 순수 계산뿐이고 통지·마운트·구독은 전부 effect 에서 일어난다.

최상위 블록 handle·menu — block controls (E2-1)

캐럿이나 마우스가 가리키는 최상위 블록 하나에 handle 을 띄우고, 그 메뉴에서 본문·제목 1~3 변환, 복제, 삭제를 실행한다. 결정 근거는 ADR 0007.

opt-in 이다

<Editor.Root blockTargeting="root">
  <Editor.Toolbar />
  <Editor.BlockControls.Root>
    <Editor.BlockControls.Handle />
    <Editor.BlockControls.Menu />
  </Editor.BlockControls.Root>
  <Editor.Input />
</Editor.Root>

blockTargeting 을 생략하면 controller 자체가 만들어지지 않고 pointer 리스너도 추가 알림도 없다. "root" 외의 값은 생성 시점에 던진다 — 오타를 조용히 끄면 소비자는 켰다고 믿는데 handle 이 끝내 나타나지 않는다. showHandle·enableMenu 같은 boolean 을 누적하지 않는 이유는 조합 폭발 때문이며, 무엇을 target 으로 삼는가는 하나의 정책 축이라 열거로 표현한다.

target 은 최상위 블록 하나다

  • 중첩 선택(listItem·callout·table cell·FAQ)은 그것을 포함하는 최상위 블록으로 접힌다.
  • selection 이 둘 이상의 최상위 블록에 걸치면 target 이 없다 — pointer 가 한 블록 위에 있어도 handle 이 나타나지 않는다. 그 판정은 커널의 명시적 신호이며, "selection 을 읽을 수 없다"(미마운트· 해석 실패·pointer-only)와 구별된다. 후자에서는 hover 만으로도 handle 이 선다.
  • footnoteList 는 제외한다(복제는 refId 유일성을, 삭제는 reference 를 깨뜨린다). 이것은 종류 판정이므로 caret 이 각주 목록 안에 있어도 다른 블록의 hover 는 살아 있다.
  • 우선순위는 동결 > keyboard 선호 > hover > selection 이다. menu 가 열려 있으면 열 때의 target 이 동결되어, 고르는 동안 마우스가 스쳐 간 옆 블록으로 몰래 바뀌지 않는다.

handle 위치

블록의 논리 시작 바깥(LTR 왼쪽 · RTL 오른쪽)에 놓는다. 좌표는 엔진이 주는 블록 anchor 이고, 상자 자신의 크기를 반영한 최종 배치는 React 가 layout effect 에서 실측해 적용한다 — handle 폭은 소비자의 children·CSS 가 정하므로 엔진이 알 수 없다. 자기 크기를 포함해 뷰포트 안으로 clamp 하며 (menu 도 같다), 바깥에 자리가 없으면 음수로 밀지 않고 가장자리에 붙인다. 새 floating 의존성은 없다.

편집 블록에서 handle 로 마우스를 옮기는 gap 에서는 hover 를 즉시 버리지 않는다 — requestAnimationFrame 한 번 + 120ms grace window(명세 상한 200ms) 뒤에만 selection 으로 폴백하며, 재진입·언마운트가 그 예약을 취소한다. 한 프레임만으로는 손 속도·여백 폭 때문에 자주 모자라 "누르려던 버튼이 손 아래에서 사라진다".

scroll/resize 재측정은 rAF 단위로 합치고, Editor.Input 이 붙은 뒤에만 listener 를 건다.

키보드·ARIA (WAI-ARIA menu button 패턴)

  • handle 은 실제 <button type="button">, 이름은 블록 메뉴 열기, aria-haspopup="menu"· aria-expanded·aria-controls.
  • Enter/Space/ArrowDown 은 첫 실행 가능 항목, ArrowUp 은 마지막 실행 가능 항목으로 연다.
  • 변환 네 항목은 같은 group 의 menuitemradio(aria-checked), 복제·삭제는 menuitem 이다. 현재 종류와 실행 불가 항목은 감추지 않고 focus 가능한 aria-disabled="true" 로 남는다.
  • ArrowDown/ArrowUp 은 disabled 를 포함해 순회하고 끝에서 감긴다. Home/End 는 첫·마지막.
  • Escape 는 menu 만 닫고 handle 로 focus 를 되돌린다. 이 복귀는 "우리가 옮긴 focus" 로 표시되므로 keyboard 진입으로 오해되지 않는다 — pointer 로 연 menu 를 Escape 로 닫아도 handle 이 caret 쪽 블록으로 점프하지 않는다.
  • Tab/Shift+Tab가로채지 않는다. keydown 에서 menu 를 지우면 브라우저의 tab order 계산이 우리 손에 넘어가므로, focus 가 실제로 옮겨진 뒤(focusout) 닫는다. Tab 은 편집 표면으로, Shift+Tab 은 handle 로 간다.
  • 바깥을 누르면 닫히되 사용자의 새 focus 를 빼앗지 않는다. 그 pointer 를 삼키지 않으므로 누른 곳이 그대로 focus 를 받는다. 이유는 outside-pointerprogrammatic 과 구별된다.
  • 실행 성공 뒤 focus 는 handle 이 아니라 contenteditable 로 돌아가고, handle 은 결과 블록에 남는다 — 변환 다음에 곧바로 복제·삭제를 이어서 할 수 있다.
  • 조합(IME) 중에는 handle 도 menu 도 없다. compositionstart 는 문서 트랜잭션을 만들지 않으므로 커널이 별도 훅으로 알리며, 그 훅은 block controls 를 켠 소비자에게만 설치된다.

실행 계약

한 동작 = 한 커널 세션 = 한 트랜잭션이다 → docChanged 1회, version +1, undo 1단위. 동작에 필요한 selection 이동도 같은 트랜잭션에 들어 있어 undo 한 번이 문서와 selection 을 함께 되돌린다. 변환은 기존 CORE_COMMANDS.setParagraph/setHeading같은 구현을 지나므로 selection command 와 문서 결과가 같다. 복제는 type·attrs·content·marks 를 보존하고 자산은 같은 선언을 참조한다. 삭제는 문서에 편집 가능한 본문이 남도록 보장하며, trailing footnoteList 가 있으면 빈 문단을 목록 앞에 넣는다. 실패(readOnly·조합 중·stale target·canonical 거부)는 아무 것도 남기지 않는다 — 계약은 "실패가 없다" 가 아니라 "실패해도 부분 변경이 0" 이다.

엔진 표면 (React 없이도 쓴다)

const engine = createWeingEditor({ blockTargeting: "root" });
engine.getBlockControlsState();          // 값이 그대로면 같은 참조
engine.openBlockControls("first");       // "first" | "last"
engine.moveBlockControls("next");
engine.executeBlockControlsItem(id?);    // 생략하면 active
engine.escapeBlockControls();            // Escape — handle 은 남는다
engine.dismissBlockControls();           // 바깥 pointer — focus 를 옮기지 않는다
engine.attachBlockControls();            // UI 가 (다시) 붙었다
engine.detachBlockControls();            // UI 가 사라졌다 — hover·pin·열림을 버린다

target.anchornull 일 수 있고 그래도 target 은 유효하다 — 좌표를 요구하는 것은 그리는 쪽뿐이다. 그때 Editor.BlockControls.HandleMenu둘 다 아무 DOM 도 내지 않는다(menu 만 남기면 aria-labelledby 가 없는 id 를 가리키고, 좌표 없는 position: fixed 가 화면 좌상단에 메뉴를 만든다). 엔진 조작은 그대로 동작한다. 공개 snapshot 에 ProseMirror 타입·DOM node·내부 내부 좌표·identity token 은 없다.

target 이 아직 유효한지는 노드 객체의 정체성으로 재검증한다 — 커널이 WeakMap<PMNode, symbol> 로 발급한 token 을 참조 비교하므로 충돌이 구조적으로 불가능하고, symbol 은 직렬화되지 않으므로 소비자가 저장해 다음 세션에 재사용하는 길(= 사실상의 persistent block id)이 타입 수준에서 막힌다.

hover·menu 변화는 구독자를 깨우지만 getSnapshot().version·문서·selection·history· WeingTransactionEvent바꾸지 않고, 그 동안 Editor.Input React render 도 0회다.

styling hook

data-weing-block-controls · data-weing-block-handle · data-weing-block-menu · data-weing-block-menu-item(+-kind/-active) · data-weing-block-target-type · data-weing-block-placement. 위치(position: fixed 와 좌표)는 패키지가 잡으므로 소비자 style 로 덮지 않는다 — 덮으면 viewport clamp 와 RTL 배치가 어긋난다. handle/menu DOM 은 canonical JSON · semantic HTML · clipboard 어디에도 나타나지 않는다.

같은 부모 블록 재정렬 — block reorder (E3-1)

E2-1 이 만든 그 target 을 그대로 잡아 doc 의 직접 자식끼리 순서를 바꾼다. pointer drag · handle 스코프 Alt+화살표 · menu 의 이동 항목 세 경로가 같은 이동에 도달한다. 결정 근거는 ADR 0008.

상태: Accepted (2026-08-01) — Claude Opus 구현 · Codex 독립 검증 완료 (작업 명세).

variant 로만 켜진다 — 생략의 계약은 "거의 같다" 가 아니라 없음이다

<Editor.Root blockTargeting="root" blockReorder="same-parent">
  <Editor.BlockControls.Root>
    <Editor.BlockControls.Handle />
    <Editor.BlockControls.Menu />
    <Editor.BlockControls.DropIndicator />
  </Editor.BlockControls.Root>
  <Editor.Input />
</Editor.Root>

blockReorder 를 생략하면 getBlockReorderState()언제나 같은 참조의 idle snapshot 이고, 이동·drag 메서드는 전부 false 인 inert 계약이며, menu 에 이동 항목이 생기지 않고(기존 6항목의 수·순서·id 불변), handle 의 pointer 경로가 E2-1 과 동일하다(추가 리스너 0 · 제스처 추적 0 · 구독 비용 0 · Alt+화살표 미청취 · live region 미렌더).

"same-parent" 외의 값, 그리고 blockTargeting !== "root" 조합은 생성 시점에 던진다. 조용히 truthy 로 켜지 않으며, 실패한 생성이 kernel·listener·controller 를 남기지 않는다.

DropIndicator 합성은 기능의 on/off 가 아니다. 빼면 끌고 가는 동안 선이 보이지 않을 뿐, 제스처·상태·이동·keyboard/menu 경로·headless moveBlockByStep() 은 그대로 동작한다. 되돌리려면 blockReorder 자체를 빼야 한다.

제스처와 drop 판정

  • Pointer Events + setPointerCapture(실패 시 ownerDocument 폴백). HTML5 DnD 를 쓰지 않는 이유는 ADR 0008 §1 — PM 이 이미 그 이벤트들의 주인이고 취소를 우리가 소유하지 못한다.

  • 임계값은 체비쇼프 거리로 mouse/pen 4px · touch 8px. 그 전에 떼면 클릭이라 E2-1 대로 menu 가 열린다. 넘긴 뒤의 pointerup 은 click 을 1회만 억제한다(억제를 남겨 두면 키보드 Enter/Space 가 만든 click 까지 조용히 막힌다).

  • pointerdown 은 기본 차단하지 않는다. Pointer Events Level 3 §11pointerdownpreventDefault()호환 마우스 이벤트(mousedown 등)를 억제한다. 다만 같은 절은 click·auxclick·contextmenu 는 호환 마우스 이벤트가 아니며 pointer event 의 preventDefault 가 그 발생 여부에 영향을 주어서는 안 된다(MUST NOT) 고 규정한다 — 그러므로 "막으면 click 이 사라진다" 는 표준 근거가 아니다.

    우리가 막지 않는 이유는 셋이다: ① 편집 selection 보호는 mousedown 이 이미 하므로 중복이고(E2-1 계약 그대로), ② 호환 mousedown 을 소비자·라이브러리가 관찰할 수 있게 보존하며, ③ 실제 interaction driver 의 activation 경로를 보존하기 위해서다 — Storybook userEvent.click 경로에서 실제로 관찰된 회귀가 있었다(그 경로는 지켜야 하지만, 이를 규범적 브라우저 click 동작으로 일반화하지는 않는다).

    실제로 막는 것은 임계값을 넘겨 begin() 이 성공한 뒤의 cancelable pointermove 뿐이고, touch 스크롤 가로채기는 touch-action: none 이 담당한다. stopPropagation() 은 어디서도 넣지 않는다 — 소비자 상위 핸들러가 살아 있어야 한다.

  • drop 판정은 3단: elementFromPoint + 유계 상승 → posAtCoords → first/last 경계 폴백. 그래서 블록 사이 gap · root 안쪽 여백 · 마지막 블록 아래 빈 공간에서도 자리가 잡힌다.

  • posAtDOM 은 같은 블록 위에서 다시 치지 않는다. DOM→문서 좌표 매핑은 PM 자료구조상 최상위 블록 수에 비례하므로(문서 뒤쪽일수록 비싸다) {요소, version} 캐시로 다른 블록에 진입할 때만 1회 치른다. hover·캐럿 경로와 drag 1단이 같은 입구를 쓴다 — 한쪽만 캐시하면 drag 가 프레임 마다 다시 치게 되고, 그것이 실제로 있었던 회귀다. 회귀 고정은 벽시계가 아니라 호출 계수다.

  • 결과가 같은 자리에는 선을 그리지 않는다(자기 자신 / 직전+after / 직후+before). 실행도 false 이고 빈 undo 를 만들지 않는다.

  • footnoteList 는 source 도 drop 참조도 아니며, 그 위 좌표는 직전 본문 블록의 after 로 접힌다 — 유효 drop 위치의 상한은 언제나 마지막 본문 블록 뒤다.

  • autoscroll 은 유계다: 가장자리 48px 대역 · 프레임당 24px 상한 · element 와 window 양 경로 · 체이닝 없음 · rAF 언제나 1개 · 끝에 닿거나 값이 안 움직이면 그 방향을 잠근다(잠금은 대상이 바뀌면 풀린다).

이동 primitive

ReplaceAroundStep 1 step(structure: false)으로 source 블록을 gap 으로 보존해 옮긴다 → docChanged 1회 · version +1 · undo 1단위. tr.mapping 이 selection 을 따라 옮기므로 source 내부의 TextSelection 범위 · NodeSelection · 표 CellSelection종류까지 보존된다. 이동 뒤에도 그 블록은 같은 PMNode 객체이고 기존 identity token 이 새 좌표에서 유효하다.

delete+insert 를 쓰지 않는 이유는 삭제가 selection 을 먼저 무너뜨리고 삽입이 되살리지 않기 때문이다(ADR 0008 §3). canonical gate 는 최후 방어선으로 그대로 있고, 거부되면 배치가 통째로 실패해 부분 변경이 0 이다.

상태 — 별도 store

WeingBlockControlsState한 글자도 바뀌지 않았다. 재정렬은 별도 타입으로만 노출되므로 default 없는 exhaustive switch 를 쓴 기존 소비자가 깨지지 않는다.

const engine = createWeingEditor({
  blockTargeting: "root",
  blockReorder: "same-parent",
});
engine.getBlockReorderState();          // 값이 그대로면 같은 참조
engine.beginBlockDrag(pointerType?);    // 임계값을 넘긴 뒤에만
engine.updateBlockDrag({ x, y });       // DOM 노드가 아니라 **좌표**
engine.commitBlockDrag();
engine.cancelBlockDrag();
engine.moveBlockByStep("up" | "down");  // keyboard·menu 와 같은 경로

좌표를 받는 이유: pointer capture 중에는 event.target 이 handle 로 고정돼 그 아래 무엇이 있는지 알 수 없다 — hit-test 는 커널이 소유한다. 구독 창은 기존 engine.subscribe() 를 재사용하고, React 는 useBlockReorderState() 가 같은 창으로 useSyncExternalStore 를 건다.

관찰 불가능한 조합이 있다. beginBlockDrag() 는 coalesced 창 안에서 menu 를 먼저 닫고 전이 하므로 어떤 구독자도 controls.open && reorder.dragging 을 보지 못한다. menu open/close 도 같은 창으로 묶여 한 동작 = 한 통지다(재정렬 미활성 경로의 통지 수는 바뀌지 않는다).

drag 중에는 문서 트랜잭션 0 이고 같은 참조·같은 relation 위 pointermove알림 0회다.

취소는 "되돌리기" 가 아니라 "아무 일도 없었음"

Escape · pointercancel · lostpointercapture · readOnly 전환 · compositionstart · visibilitychange(hidden) · source stale · unmount · engine.destroy() 가 전부 취소다. dispatch 0 · version 그대로 · 빈 undo 0 이며 listener·rAF·timer·pointer capture 를 예약할 때의 Window 회수한다.

취소를 시작하는 주체는 둘이다. 제스처가 시작하는 경로(Escape·pointercancel·unmount)와 엔진이 시작하는 경로(외부 cancelBlockDrag() · readOnly · 조합 시작 · source stale)가 있다. 후자는 updateBlockDrag()false 를 돌려주는 것으로만 드러나므로, 제스처는 일반 pointermove 와 autoscroll 의 스크롤 후 재판정 양쪽에서 그 값을 읽어 즉시 정리한다 — 그러지 않으면 전역 리스너가 남아 죽은 drag 를 위해 계속 기본 동작을 막고, 가장자리에서 페이지가 저 혼자 미끄러진다. autoscroll 은 프레임 맨 앞에서도 liveness 를 확인하므로 취소 뒤 한 프레임도 더 밀지 않는다.

접근성

  • handle 이름은 그대로 블록 메뉴 열기 다(드래그는 pointer 동작이라 이름을 바꾸면 키보드 사용자 에게 거짓 약속이 된다). 대신 활성일 때만 aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown".
  • Alt+화살표handle 에 focus 가 있을 때만 듣는다. 편집 표면 keymap 과 CORE_BOUND_KEYS아무 것도 추가하지 않는다. 수식어 없는 화살표는 그대로 menu 를 연다.
  • menu 이동 항목은 role="menuitem"(선택 상태가 없는 동작), 경계에서는 focus 가능한 aria-disabled="true". WeingBlockMenuItemKind union 은 불변이고 이동은 id 기반 분기다.
  • 경계 no-op 은 조용하지 않다 — 인식한 조합이므로 preventDefault 는 유지하고 유계 안내를 낸다.
  • BlockControls.Root 가 재정렬 활성 시 정확히 하나의 시각적으로 숨긴 role="status" (aria-live="polite" · aria-atomic="true") 를 소유한다. 소비자에게 Announcer 합성을 요구 하지 않는다. region 노드는 재마운트하지 않고 sequence 를 안쪽 노드의 key 로 내려 같은 문구도 재공지된다. pointer drag 중에는 공지하지 않고 결과만 1회이며 sequencepointermove 로 증가하지 않는다.
  • 문장은 고정 템플릿·유계 길이다. 문서 내용도 순서 번호도 넣지 않는다(그 값을 만들려면 형제를 세야 한다). 사전에 없는 블록 종류는 블록 으로 접힌다.
  • 목적격 조사는 앞말의 받침으로 고른다본문을 · 표를 · 코드를 · fallback 블록을. 이 문장은 사용자가 실제로 듣는 최종 문구이므로 표을(를) 같은 문법 회피 표기를 남기지 않는다 (한글 음절의 종성 인덱스로 판정하고, 한글이 아닌 라벨은 로 접는다 — 예: FAQ를).
  • focus 는 실행 경로로 갈린다 — 변환·복제·삭제와 drag 커밋은 contenteditable 로, menu 의 이동과 Alt+화살표 는 handle 로. 이동 뒤에도 편집 표면으로 보내면 연속 이동이 불가능해진다.

styling hook

data-weing-block-dragging(handle) · data-weing-block-drop-indicator · data-weing-block-drop-relation · data-weing-block-drop-placement · data-weing-block-reorder-status(live region). indicator 는 별도 CSS 없이도 보이도록 기본 2px · currentColor · position: fixed · pointer-events: none 이며 제품 색상·굵기를 강제하지 않는다 — 굵기·색은 소비자 style 이 이긴다.

pointer-events: none 만은 소비자 style 로 덮을 수 없다. 그것은 표현이 아니라 기능의 전제다. 선이 hit-test 에 잡히면 drop 판정 1단의 elementFromPoint 가 root 직접 자식 대신 선 자신을 돌려주고, 그 판정을 버리고 2단으로 내려가므로 선이 가리는 자리에서만 비용과 결과가 달라진다. position: fixed 도 같은 이유로 패키지가 잡는다(좌표를 쓰는 layout effect 가 같은 자리에서 강제한다). 색을 덮는 것과 달리 덮으면 기능이 죽는다. handle 에는 touch-action: none 이 기본으로 붙는다(없으면 브라우저가 세로 스크롤 제스처로 가로채 pointercancel 이 즉시 온다). 이 DOM 들은 canonical JSON · semantic HTML · clipboard 어디에도 나타나지 않는다.

이 슬라이스가 닫지 않은 것

중첩 컨테이너 내부 재정렬(리스트 항목·표 행/열·FAQ 항목·각주 정의), 다중 블록 일괄 이동, 다른 부모/컨테이너로의 이동, drag 로 블록 쪼개기/합치기, persistent block id, writing-mode: vertical-*, 가상 스크롤 편집 표면.

인증하지 않은 것: 45fps(로컬 Chromium frame interval 진단 로그만 — CI 단언 아님)와 모바일 실기기 터치(자동 증거는 Chromium 합성 pointerType: "touch" 까지). 둘 다 docs/scaffold/_overview/deferred-followups.md 에 누적했다.

의미론적 구분선 — divider (E4-1)

블로그 블록 팩의 첫 항목이다. opt-in 이 아니다dividerExtension() 은 엔진이 항상 합성하는 built-in 이므로 소비자가 따로 등록할 것이 없다.

문서 계약 — 이름 하나가 전부다

canonical 노드 이름은 horizontalRule 이고 UX 라벨은 "구분선" 이다. attrs 도 자식도 없는 leaf 블록이며 저장 HTML 은 속성 없는 <hr> 하나다.

{ "type": "horizontalRule" }
  • attrs 선언이 아예 없다. 그래서 class·style·id·data-*·onclick 은 문서 경계를 통과할 자리가 없다 — 지우는 것이 아니라 담을 곳이 없다. 계약 밖 attr 을 실은 문서는 validateWeingDocument 가 거부한다.
  • isLeaf 가 명시 선언이므로 자식을 실은 { type: "horizontalRule", content: [...] } 도 거부된다.
  • divider style variant(점선·굵기·색)는 범위 밖이다. 표현은 소비 UI 의 결정이고, attr 을 하나라도 열면 문서가 임의 표현 문자열을 담는 표면이 된다(§model/divider).

어디에 놓일 수 있는가

본문 어휘(BODY_BLOCK_TYPES)에 들어가므로 그 어휘에서 파생되는 컨테이너 전부에서 같은 의미로 허용된다 — 문서 루트 · callout · faqAnswer · footnoteDefinition · 표 칸. 표 칸에서 빠지지 않는 이유는 칸이 배제하는 둘(table·figure)이 우회 중첩 경로를 만들기 때문인데, 구분선은 leaf 라 어떤 중첩도 열지 않아서다.

blockquotelistItem 은 넓히지 않았다. 그 둘은 본문 어휘에서 파생되지 않고 자기 자식 목록을 직접 선언하는 컨테이너이며, E4-1 은 기존 구조 불변식을 우회하지 않는다.

삽입 — insertHorizontalRule

engine.execute({ type: DIVIDER_COMMANDS.insertHorizontalRule });

params 를 받지 않는다(받을 값이 없다). 계약:

  • 성공은 문서 변경 1회 · version +1 · undo 한 단위다.
  • 실패·읽기 전용·IME 조합 중에는 문서도 history 도 그대로다.
  • 이어서 쓸 자리를 남긴다. 구분선이 부모의 마지막 자식이 되면 그 뒤에 빈 문단을 같은 트랜잭션으로 함께 넣고 캐럿을 그 안으로 옮긴다. 뒤에 이미 무언가가 있으면 새 문단을 만들지 않고 다음 텍스트 자리로만 옮긴다.
  • 지금 자리가 블록을 받지 못하면 false 다. 판정은 insertTable·insertGeneratedBlock같은 규칙(선택을 담은 가장 안쪽 블록 컨테이너에게 묻는다)이므로, 인용문 안에서는 거부되고 코드 블록 안에서는 문서 루트에 놓이며 코드가 갈린다(표 삽입과 정확히 같은 동작이다).

insertBlockNode("horizontalRule", {}) 로 충분하지 않은가: 그 primitive 는 선택을 PM 기본 mapping 에 맡긴다. 구분선이 문서의 마지막 블록이 되면(빈 문단에서 /구분선 을 실행한 경우가 정확히 그것이다) 뒤에 텍스트 자리가 없어 mapping 이 구분선 자신을 고른 NodeSelection 으로 수렴하고, 사용자가 한 글자를 치면 방금 넣은 구분선이 교체된다.

툴바·slash

  • 툴바 후보 divider-insert(group divider)는 일회성 동작(action) 이다. aria-pressedaria-current 도 붙지