Skip to content

Step 3-B — 슬래시 명령 메뉴 ⭐

상태: ✅ 완료 (2026-04-14) 목표: 명령어를 외우지 않고도 모든 편집 기능에 접근 — / 타이핑 → 필터링 가능한 부유 메뉴 → 키보드/마우스 선택


목차

  1. UX 설계
  2. 아키텍처
  3. 플러그인 상태 흐름
  4. 기본 명령 11개
  5. 추가된 파일
  6. 검증 결과
  7. 알려진 제한

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 스코핑은 여전히 유효

플러그인 상태 흐름

상태 타입

ts
interface SlashMenuState {
  open: boolean;
  from: number;         // "/" 가 삽입된 doc 위치
  query: string;        // "/" 다음에 입력된 필터 텍스트
  selectedIndex: number;
}

메타(meta) 액션

트랜잭션으로 명시적 상태 전이:

  • { type: "open", from } — 메뉴 열기
  • { type: "close" } — 닫기
  • { type: "select", index } — 선택 항목 이동

apply 로직

  1. 메타 있으면 메타 대로 상태 갱신
  2. 메뉴 열려 있고 문서 변경 시:
    • tr.mapping.map(prev.from) 으로 / 위치 재계산
    • 커서가 / 앞으로 이동 → 닫기
    • / 와 커서 사이 텍스트가 / 로 시작 안 함 → 닫기
    • 쿼리에 공백 포함 → 닫기
    • 그 외 → 쿼리만 업데이트

명령 실행

ts
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-1Heading 1H1setBlockType(heading, { level: 1 })
heading-2Heading 2H2setBlockType(heading, { level: 2 })
heading-3Heading 3H3setBlockType(heading, { level: 3 })
textTextsetBlockType(paragraph)
bullet-listBullet listwrapInList(bullet_list)
ordered-listNumbered list1.wrapInList(ordered_list)
quoteQuotewrapIn(blockquote)
code-blockCode block</>setBlockType(code_block)
dividerDividerreplaceSelectionWith(horizontal_rule.create())
linkLink🔗prompt() 으로 URL/텍스트 받기 → link mark
imageImage🖼로컬 파일 선택 → Data URL (취소 시 URL prompt fallback)
videoVideo🎬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 배열을 가짐 (한글 + 영문):

ts
{ 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 준비됨:

ts
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 방식 유지)

플로우:

  1. 명령 선택 → <input type="file" accept="image/*"> 자동 클릭
  2. 파일 선택 → FileReader.readAsDataURL 로 Data URL 변환 → image 노드 삽입
  3. 파일 선택 취소 → URL 프롬프트로 대체 (원격 URL 입력 원하는 경우)
  4. 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 재정의 안을 그대로 구현)