Step 3-B — 슬래시 명령 메뉴 ⭐
상태: ✅ 완료 (2026-04-14) 목표: 명령어를 외우지 않고도 모든 편집 기능에 접근 — / 타이핑 → 필터링 가능한 부유 메뉴 → 키보드/마우스 선택
목차
UX 설계
트리거 조건
슬래시(/) 키 입력 시:
- ✅ 블록 시작 위치 (예: 빈 문단)
- ✅ 공백 바로 뒤 (예: "hello /")
- ❌ 코드 블럭 내부
- ❌ 이미 열려 있는 경우 (두 번째
/는 문자로 입력) - ❌ 단어 중간 (예: "ab/c")
상호작용
| 입력 | 동작 |
|---|---|
/ | 메뉴 열기, 문서에는 / 가 그대로 표시됨 |
| 글자 타이핑 | 문서에 추가되며 동시에 필터 쿼리 업데이트 |
↑ / ↓ | 선택 항목 이동 (순환) |
Enter | 선택 항목 실행 → / + 쿼리 삭제 후 명령 적용 |
Esc | 메뉴 취소, / + 쿼리는 문서에 남김 |
| 공백 입력 | 메뉴 자동 닫힘 (/ 를 그냥 텍스트로 사용한 경우) |
| 메뉴 외부 클릭 | 메뉴 닫힘 |
커서가 / 앞으로 이동 | 메뉴 닫힘 |
| 마우스 클릭 (메뉴 항목) | 실행 |
위치 조정
- 기본:
/바로 아래, 4px 간격 - 화면 오른쪽을 넘으면 왼쪽으로 밀기
- 화면 아래쪽을 넘으면
/위로 띄우기 (flip)
아키텍처
┌────────────────────────────────────────────┐
│ NewtilEditor (Custom Element, Light DOM) │
│ │
│ EditorView │
│ plugins: │
│ - history │
│ - buildInputRules (Markdown 자동변환) │
│ - buildKeymap (단축키) │
│ - buildSlashMenu ← 이 Step │
│ - baseKeymap │
│ │
│ buildSlashMenu 구조: │
│ - PluginKey("newtilSlashMenu") │
│ - state: { open, from, query, │
│ selectedIndex } │
│ - handleTextInput: "/" 감지 → 메뉴 열기 │
│ - handleKeyDown: ↑↓Enter Esc 처리 │
│ - apply: 문서 변경 시 쿼리 재계산 │
│ - view(): SlashMenuView 인스턴스 생성 │
│ │
│ SlashMenuView: │
│ - container = div, fixed position │
│ - document.body 에 append │
│ - update(): 상태에 따라 렌더/숨김/위치 │
│ - document mousedown → 외부 클릭 감지 │
└────────────────────────────────────────────┘왜 document.body 에 append 하는가
- Custom Element (Light DOM) 내부의 스택 컨텍스트 / overflow 에 영향받지 않음
position: fixed로 뷰포트 좌표 직접 사용 가능- BEM 네이밍 (
.newtil-editor__slash-menu) 으로 CSS 스코핑은 여전히 유효
플러그인 상태 흐름
상태 타입
interface SlashMenuState {
open: boolean;
from: number; // "/" 가 삽입된 doc 위치
query: string; // "/" 다음에 입력된 필터 텍스트
selectedIndex: number;
}메타(meta) 액션
트랜잭션으로 명시적 상태 전이:
{ type: "open", from }— 메뉴 열기{ type: "close" }— 닫기{ type: "select", index }— 선택 항목 이동
apply 로직
- 메타 있으면 메타 대로 상태 갱신
- 메뉴 열려 있고 문서 변경 시:
tr.mapping.map(prev.from)으로/위치 재계산- 커서가
/앞으로 이동 → 닫기 /와 커서 사이 텍스트가/로 시작 안 함 → 닫기- 쿼리에 공백 포함 → 닫기
- 그 외 → 쿼리만 업데이트
명령 실행
function executeCommand(view, cmd) {
const { from } = state;
const to = view.state.selection.from;
// 1. "/" + 쿼리 삭제 + 메뉴 닫기
view.dispatch(
view.state.tr.delete(from, to).setMeta(pluginKey, { type: "close" })
);
// 2. 명령 실행 (setBlockType, wrapInList 등)
cmd.run(view);
// 3. 포커스 복귀
view.focus();
}기본 명령 12개
| ID | 라벨 | 아이콘 | 구현 |
|---|---|---|---|
heading-1 | Heading 1 | H1 | setBlockType(heading, { level: 1 }) |
heading-2 | Heading 2 | H2 | setBlockType(heading, { level: 2 }) |
heading-3 | Heading 3 | H3 | setBlockType(heading, { level: 3 }) |
text | Text | ¶ | setBlockType(paragraph) |
bullet-list | Bullet list | • | wrapInList(bullet_list) |
ordered-list | Numbered list | 1. | wrapInList(ordered_list) |
quote | Quote | ❝ | wrapIn(blockquote) |
code-block | Code block | </> | setBlockType(code_block) |
divider | Divider | — | replaceSelectionWith(horizontal_rule.create()) |
link | Link | 🔗 | prompt() 으로 URL/텍스트 받기 → link mark |
image | Image | 🖼 | 로컬 파일 선택 → Data URL (취소 시 URL prompt fallback) |
video | Video | 🎬 | prompt() 으로 URL/poster 받기 → video node (스키마 확장 필요) |
각 명령은 스키마에 해당 노드/마크가 있을 때만 등록됨. 커스텀 스키마로 제한해도 안전하게 작동.
Video 지원을 위한 스키마 확장 (2026-04-14)
Markdown 표준에는 video 문법이 없으므로 prosemirror-markdown 의 기본 스키마를 확장했다:
src/schema.ts(신규): 기본 스키마에video노드 추가 (atom, block, draggable)- 속성:
src,poster,controls parseDOM규칙으로 HTML 클립보드 붙여넣기 인식
- 속성:
src/markdown.ts(신규): 기본 파서의 tokenizer + tokens 를 재사용하되 확장 스키마로MarkdownParser재생성. 직렬화기는video→<video src="..." controls></video>HTML 블록으로 출력.
라운드트립 한계: 현재 파서는 HTML 블록을 video 노드로 역파싱하지 않는다 (기본 html: false). 따라서 편집기 내에서 삽입한 video는 Markdown 문자열로 출력되지만, 해당 문자열을 다시 파서에 넣으면 video 노드는 복원되지 않는다 (HTML 블록이 텍스트로 유실). HTML 클립보드 붙여넣기로는 정상 복원됨.
완전한 라운드트립을 원할 경우 향후 html: true 옵션 + html_block 토큰 핸들러 추가로 해결 가능 (별도 작업으로 분리).
키워드 검색
각 명령은 keywords 배열을 가짐 (한글 + 영문):
{ id: "bullet-list", label: "Bullet list",
keywords: ["bullet", "list", "ul", "불릿", "리스트"] }필터링은 label + description + keywords 를 합쳐서 대소문자 무시 부분 문자열 매치:
/he→ Heading 1/2/3/불→ Bullet list/code→ Code block
추가된 파일
src/plugins/slash-menu.ts (신규)
전체 구현 — 200줄 남짓.
주요 export:
buildSlashMenu(schema, commands?)— Plugin 팩토리buildDefaultSlashCommands(schema)— 기본 11개 명령 생성SlashCommand타입 (사용자 커스텀 명령 추가용)
확장 API 준비됨:
const customCommands = [
...buildDefaultSlashCommands(schema),
{ id: "toc", label: "Table of Contents", run: (view) => { /* ... */ } },
];
const plugin = buildSlashMenu(schema, customCommands);다만 현재는 src/index.ts 에서 기본값만 사용. Step 4+ 에서 editor.registerCommand() API 노출 검토.
src/styles.css (확장)
슬래시 메뉴 관련 BEM 클래스:
.newtil-editor__slash-menu— 루트 컨테이너 (fixed, 그림자).newtil-editor__slash-menu-item— 버튼.newtil-editor__slash-menu-item[aria-selected="true"]— 키보드 포커스 상태.newtil-editor__slash-menu-icon— 28x28 아이콘 박스.newtil-editor__slash-menu-text— 라벨 + 설명 컨테이너.newtil-editor__slash-menu-label— 명령 이름.newtil-editor__slash-menu-desc— 한줄 설명.newtil-editor__slash-menu-empty— 검색 결과 없을 때
src/index.ts (수정)
import { buildSlashMenu } from "./plugins/slash-menu"export type { SlashCommand } from "./plugins/slash-menu"- 플러그인 배열에
buildSlashMenu(schema)추가 (baseKeymap 바로 앞)
index.html (수정)
힌트 박스 내용을 슬래시 메뉴 중심으로 갱신.
검증 결과
| 명령 | 결과 |
|---|---|
npm run typecheck | ✅ 오류 0건 |
npm run build | ✅ ESM 21.13KB + CJS 21.88KB + DTS 947B |
npm run dev | ✅ Vite HMR 정상 |
브라우저 수동 테스트 체크리스트
- [x] 빈 문단에서
/→ 메뉴 표시 - [x] 타이핑으로 필터 (
/he→ Heading 항목만) - [x]
↑/↓선택 이동, 순환 확인 - [x]
Enter로 선택 실행,/와 쿼리 모두 삭제됨 - [x]
Esc로 취소,/+ 쿼리는 텍스트로 남음 - [x] 메뉴 외부 클릭 시 닫힘
- [x] 공백 입력 시 자동 닫힘
- [x] Link 선택 →
prompt로 URL 입력 → 링크 삽입 - [x] Image 선택 → URL + alt 입력 → 이미지 삽입
- [x] Divider 선택 → 수평선 삽입
- [x] 한글 검색 (
/불,/인용) 동작 확인
이미지 파일 선택 (2026-04-15 업데이트)
Image 명령의 URL 프롬프트 방식을 로컬 파일 선택 우선 으로 교체. (Video 는 URL 방식 유지)
플로우:
- 명령 선택 →
<input type="file" accept="image/*">자동 클릭 - 파일 선택 →
FileReader.readAsDataURL로 Data URL 변환 → image 노드 삽입 - 파일 선택 취소 → URL 프롬프트로 대체 (원격 URL 입력 원하는 경우)
- URL 프롬프트도 취소 → 삽입 없음
신규 유틸 함수:
pickLocalFile(accept: string): Promise<File | null>— input[type=file] 기반,cancel이벤트로 취소 감지readFileAsDataURL(file: File): Promise<string>— FileReader Promise 래퍼deriveAltFromFilename(filename: string): string— "my-photo.jpg" → "my photo"
한계:
- Data URL 은 base64 인코딩으로 원본보다 약 33% 커짐 — 대용량 이미지는 문서 크기 폭증
- 서버 업로드가 아니므로 순수 클라이언트 시나리오에만 적합
- 대안: Step 이후
editor.onImageUpload콜백 API 추가 예정 (S3/CDN 연동)
알려진 제한
| 제한 | 해결 예정 |
|---|---|
| Data URL 로 인한 문서 크기 증가 | Step 이후 — 업로드 콜백 API (onImageUpload, onVideoUpload) 도입 |
URL 입력이 window.prompt() 라 UX 조악 | Step 4 (또는 별도) 에서 모달 도입 |
| 메뉴가 document.body 에 append 되어 iframe/shadow DOM 에디터와 격리 불완전 | 기본 사용 시 문제 없음. 필요 시 사용자가 container 옵션 지정할 수 있도록 확장 |
| 사용자 커스텀 명령 등록 API 노출 안 됨 | Step 4+ 검토 (editor.registerSlashCommand) |
| 선택 영역 기반 floating toolbar 없음 (인라인 서식 UI) | Step 4-A |
placeholder, readonly 속성 미지원 | Step 4-C |
| H4~H6 는 메뉴에 없음 (Markdown 타이핑으로만 가능) | 필요 시 명령 확장 |
설계 변경 이력
(없음 — 3-A 에서 확정한 Step 3 재정의 안을 그대로 구현)