diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue
index 8f45a89..64ad3c7 100644
--- a/components/admin/AdminMarkdownEditor.vue
+++ b/components/admin/AdminMarkdownEditor.vue
@@ -55,6 +55,7 @@ import {
insertMarkdownLinesAtSlashTarget,
parseSlashInput,
resolveSlashCommand,
+ shouldCaptureSlashMenuKeyboardAction,
shouldIgnorePendingMediaSlashCommit
} from '../../lib/markdown-slash-commands.js'
import {
@@ -1276,37 +1277,49 @@ onMounted(() => {
if (liveSlashVisible.value) {
const slashKeyboardAction = getSlashMenuKeyboardAction(event.key)
+ const slashCommandCount = visibleLiveSlashCommands.value.length
if (slashKeyboardAction === 'navigate') {
- event.preventDefault()
- const count = visibleLiveSlashCommands.value.length
-
- if (count) {
- startLiveSlashKeyboardNavigation()
-
- liveSlashHighlightedIndex.value = getNextSlashHighlightedIndex({
- currentIndex: liveSlashHighlightedIndex.value,
- commandCount: count,
- key: event.key
- })
+ if (!shouldCaptureSlashMenuKeyboardAction({
+ action: slashKeyboardAction,
+ commandCount: slashCommandCount
+ })) {
+ return
}
+ event.preventDefault()
+ event.stopPropagation()
+ startLiveSlashKeyboardNavigation()
+
+ liveSlashHighlightedIndex.value = getNextSlashHighlightedIndex({
+ currentIndex: liveSlashHighlightedIndex.value,
+ commandCount: slashCommandCount,
+ key: event.key
+ })
+
return
}
if (slashKeyboardAction === 'apply') {
- event.preventDefault()
const command = visibleLiveSlashCommands.value[liveSlashHighlightedIndex.value]
- if (command) {
- applyLiveSlashCommand(command)
+ if (!shouldCaptureSlashMenuKeyboardAction({
+ action: slashKeyboardAction,
+ commandCount: slashCommandCount
+ }) || !command) {
+ clearLiveSlashState()
+ return
}
+ event.preventDefault()
+ event.stopPropagation()
+ applyLiveSlashCommand(command)
return
}
if (slashKeyboardAction === 'cancel') {
event.preventDefault()
+ event.stopPropagation()
cancelLiveSlashCommand()
return
}
@@ -2517,6 +2530,7 @@ const onLiveSlashApply = (payload) => {
const command = resolveSlashCommand(parsed?.query ?? liveSlashQuery.value)
if (!command) {
+ onLiveSlashEnd({ sourceLine: payload?.sourceLine })
return
}
diff --git a/components/content/ContentMarkdownEditableInline.vue b/components/content/ContentMarkdownEditableInline.vue
index b5a2fcc..4eeeccb 100644
--- a/components/content/ContentMarkdownEditableInline.vue
+++ b/components/content/ContentMarkdownEditableInline.vue
@@ -13,7 +13,10 @@ import {
LIVE_SELECTION_BRIDGE_KEY,
selectEditableElementContents
} from '../../lib/markdown-live-selection.js'
-import { parseSlashInput } from '../../lib/markdown-slash-commands.js'
+import {
+ parseSlashInput,
+ resolveSlashCommandFromInputValue
+} from '../../lib/markdown-slash-commands.js'
const props = defineProps({
/** 인라인 마크다운 원문 */
@@ -1340,7 +1343,7 @@ const onKeydown = (event) => {
}
}
- if (props.slashMenuActive) {
+ if (props.slashMenuActive && event.key !== 'Enter') {
return
}
@@ -1388,15 +1391,26 @@ const onKeydown = (event) => {
const isComposingEnter = event.key === 'Enter'
&& (event.isComposing || event.keyCode === 229 || isComposingText.value)
- if (event.key === 'Enter' && !event.shiftKey && !isComposingEnter && parseSlashInput(readEditorValue())) {
- event.preventDefault()
- event.stopPropagation()
- event.stopImmediatePropagation?.()
- emit('slash-apply', {
- sourceLine: props.sourceLine,
- value: readEditorValue()
- })
- return
+ if (event.key === 'Enter' && !event.shiftKey && !isComposingEnter) {
+ const editorValue = readEditorValue()
+ const parsedSlashInput = parseSlashInput(editorValue)
+
+ if (parsedSlashInput) {
+ const command = resolveSlashCommandFromInputValue(editorValue)
+
+ if (command) {
+ event.preventDefault()
+ event.stopPropagation()
+ event.stopImmediatePropagation?.()
+ emit('slash-apply', {
+ sourceLine: props.sourceLine,
+ value: editorValue
+ })
+ return
+ }
+
+ emit('slash-end', { sourceLine: props.sourceLine })
+ }
}
if (event.key === 'Enter' && enterAction) {
diff --git a/docs/history.md b/docs/history.md
index ca9282c..a5bb9d3 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,3 +1,7 @@
+## 2026-07-28 v1.11.110 — 슬래시 메뉴는 실행할 후보가 있을 때만 입력을 잡는다
+
+슬래시 메뉴가 열려 있다는 이유만으로 Enter를 먼저 막으면 `/ㄴㅇ`처럼 명령 후보가 없는 일반 텍스트도 줄바꿈할 수 없다. 슬래시 입력은 명령 실행 단축키이기 전에 문서 안에 남을 수 있는 문자이므로, 메뉴가 키보드 이벤트를 가로채는 기준을 “표시할 후보가 있는가”로 좁힌다. 후보가 없으면 메뉴 상태만 정리하고 라이브·소스 편집기의 일반 줄바꿈과 커서 이동 흐름을 그대로 따른다.
+
## 2026-07-27 v1.11.109 — 슬래시 메뉴 취소는 같은 입력에만 유지한다
슬래시 메뉴를 ESC로 닫은 뒤 같은 줄에서 다시 열리지 않도록 억제하는 처리는 필요하지만, 줄 번호만 기준으로 억제하면 사용자가 `/`를 지웠다가 다시 입력해도 새 입력으로 인식하지 못한다. 취소는 “현재 입력을 잠시 무시”하는 의미이지 “그 줄의 슬래시 메뉴를 계속 금지”하는 의미가 아니므로, 억제 상태에 취소 당시 줄 내용을 함께 저장하고 줄 내용이 바뀌면 억제를 해제한다.
diff --git a/docs/map.md b/docs/map.md
index 248a0f9..4ce1c7a 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -74,7 +74,7 @@
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
| lib/markdown-table.js | 공개 Markdown 표 행·구분선·정렬·블록 파싱 |
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
-| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의, 미디어 명령 action·모달 종류·적용 계획 판별, 슬래시 메뉴 제외 줄 Set·하이라이트 인덱스·방향키 순환 갱신·키보드 동작 분류, 미디어 명령 지연 commit 무시·슬래시 원문 줄 교체 삽입, 적용 후 포커스·textarea 선택 위치 계산 helper |
+| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의, 미디어 명령 action·모달 종류·적용 계획 판별, 입력값 기준 명령 해석, 슬래시 메뉴 제외 줄 Set·하이라이트 인덱스·방향키 순환 갱신·키보드 동작 분류·후보 개수 기준 키보드 가로채기, 미디어 명령 지연 commit 무시·슬래시 원문 줄 교체 삽입, 적용 후 포커스·textarea 선택 위치 계산 helper |
| lib/markdown-slash-menu-position.js | 관리자 Markdown-first 에디터 슬래시 메뉴 뷰포트 위치 보정 helper |
| lib/markdown-slash-menu-scroll.js | 관리자 Markdown-first 에디터 슬래시 메뉴 선택 항목 표시용 scrollTop 계산 helper |
| lib/markdown-inline.js | 공개·관리자 공통 인라인 마크다운 파서, 링크·굵게·기울임·인라인 코드·첨자·인라인 색상 토큰(`::color[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `
` 줄바꿈 처리, 라이브 멀티라인 본문의 기본 불릿/번호 목록 HTML 변환과 contenteditable 목록 마커 기준 커서 offset 계산·직렬화 |
@@ -140,7 +140,7 @@
| tests/markdown-editor-keyboard.test.js | 관리자 Markdown 에디터 전역 단축키 판별 단위 회귀 테스트 |
| tests/markdown-source-selection.test.js | 관리자 Markdown 에디터 소스 textarea 선택·치환·줄 변환 helper 단위 테스트 |
| tests/markdown-editor-media.test.js | 관리자 Markdown 에디터 미디어 선택 helper의 종류·선택 가능 여부·미리보기·선택 상태·삽입 계획·업로드 정규화·블록 생성 단위 회귀 테스트 |
-| tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·미디어 action·모달 종류·적용 계획·제외 줄 Set 갱신·하이라이트 인덱스 보정·방향키 순환·키보드 동작 분류·포커스 대상·textarea 선택 위치·원문 줄 교체 삽입 단위 회귀 테스트 |
+| tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·입력값 기준 명령 해석·미디어 action·모달 종류·적용 계획·제외 줄 Set 갱신·하이라이트 인덱스 보정·방향키 순환·키보드 동작 분류·후보 개수 기준 키보드 가로채기·포커스 대상·textarea 선택 위치·원문 줄 교체 삽입 단위 회귀 테스트 |
| tests/markdown-slash-menu-scroll.test.js | 관리자 슬래시 메뉴 선택 항목 scrollTop 보정 단위 테스트 |
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태·줄 내용 기준 억제 관리 단위 테스트 |
| tests/useAdminMarkdownHistory.test.js | 관리자 Markdown 에디터 히스토리 composable의 기록·undo/redo·그룹화·보관 개수 단위 테스트 |
@@ -257,7 +257,7 @@
| components/admin/AdminMarkdownWriteSurface.vue | 관리자 Markdown 에디터 소스 작성 표면 UI(textarea, 라인 번호 gutter, 업로드 상태 배지, DOM 참조 노출) |
| components/admin/AdminPostSettingsSidebar.vue | 관리자 게시물 작성/수정 우측 설정 사이드바(Post URL·상태·발행 시각·요약·태그·대표 이미지 표시·추천 글·검색엔진 노출·본문 통계·삭제 버튼·블록 설정 패널 오버레이) |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 저장 payload 기준 미저장 비교·라우트/브라우저 이탈 확인, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
-| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 라이브러리 탭은 `/admin/api/media?paged=1` 서버 페이지 응답·파일명/사용처 제목 검색·번호형 페이지 이동, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·갤러리 선택 순서 배지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 검색 결과 수와 무관한 고정 높이 패널, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |
+| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 라이브러리 탭은 `/admin/api/media?paged=1` 서버 페이지 응답·파일명/사용처 제목 검색·번호형 페이지 이동, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·갤러리 선택 순서 배지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 검색 결과 수와 무관한 고정 높이 패널, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘 ON/OFF 토글·비활성 아이콘 영역·배경색·코드 언어 셀렉트·줄번호 ON/OFF 토글·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
diff --git a/docs/spec.md b/docs/spec.md
index bb7f07c..9a921b5 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -777,7 +777,7 @@ components/content/
- 라이브 모드 갤러리 이미지를 블록 사이 얇은 삽입선(또는 문서 맨 아래 삽입선)에 드롭하면 해당 위치에 단일 이미지 마크다운 줄을 삽입하고 갤러리에서 제거한다(`extract-gallery-image`). 갤러리에 이미지가 1장만 남으면 갤러리 블록을 단일 이미지 줄로 바꾸고, 0장이면 갤러리 블록을 제거한다.
- `ProseImage`는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다.
- 인용(`>`) 블록은 첫 인용 줄에 `> [!bg=yellow]` 또는 `> {bg=yellow}` 옵션 줄을 두면 해당 줄은 숨기고 블록 배경을 바꾼다. 지원 배경 프리셋은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이며, 옵션이 없으면 회색 기본 인용 스타일을 쓴다.
-- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 ``·`` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. 인용·콜아웃 멀티라인 편집 영역도 일반 인라인 렌더러를 사용해 색상·굵게·링크 등 인라인 문법을 표시한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하되, 코드 fenced 블록 본문 안에서는 `/`를 일반 텍스트로 둔다. 슬래시 메뉴를 ESC로 닫으면 취소 당시 줄 내용이 유지되는 동안만 다시 열리지 않고, `/`를 지웠다가 다시 입력하는 등 줄 내용이 바뀌면 새 입력으로 보고 다시 연다. 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). `Cmd+Shift+K`는 소스 모드와 라이브 모드에서 현재 줄을 삭제하며, 소스 모드에서 여러 줄이 선택되어 있으면 선택 범위가 걸친 줄을 함께 삭제한다. 코드·콜아웃·토글 블록 내부에서는 커서가 있는 본문 줄을 삭제하고, 남은 본문 줄이 1개뿐이면 fenced 블록 전체를 삭제한다. `Cmd/Ctrl+Z`는 에디터 본문 변경을 되돌리고, `Cmd/Ctrl+Shift+Z` 또는 `Ctrl+Y`는 다시 실행한다. 콜아웃 옵션은 첫 줄 `:::callout emoji=none bg=blue title="주의사항"`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)·`title`로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 기본 줄번호 숨김이고, 줄번호 표시를 켜면 선언 줄에 `line-numbers`를 저장한다. 기존 `linenos`, `nolinenos`, `no-line-numbers` 토큰은 읽기 호환으로만 유지한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
+- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 ``·`` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. 인용·콜아웃 멀티라인 편집 영역도 일반 인라인 렌더러를 사용해 색상·굵게·링크 등 인라인 문법을 표시한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하되, 코드 fenced 블록 본문 안에서는 `/`를 일반 텍스트로 둔다. 슬래시 메뉴에 일치하는 명령 후보가 없으면 Enter·방향키를 메뉴가 가로채지 않고 일반 텍스트 줄바꿈·커서 이동으로 처리한다. 슬래시 메뉴를 ESC로 닫으면 취소 당시 줄 내용이 유지되는 동안만 다시 열리지 않고, `/`를 지웠다가 다시 입력하는 등 줄 내용이 바뀌면 새 입력으로 보고 다시 연다. 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). `Cmd+Shift+K`는 소스 모드와 라이브 모드에서 현재 줄을 삭제하며, 소스 모드에서 여러 줄이 선택되어 있으면 선택 범위가 걸친 줄을 함께 삭제한다. 코드·콜아웃·토글 블록 내부에서는 커서가 있는 본문 줄을 삭제하고, 남은 본문 줄이 1개뿐이면 fenced 블록 전체를 삭제한다. `Cmd/Ctrl+Z`는 에디터 본문 변경을 되돌리고, `Cmd/Ctrl+Shift+Z` 또는 `Ctrl+Y`는 다시 실행한다. 콜아웃 옵션은 첫 줄 `:::callout emoji=none bg=blue title="주의사항"`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)·`title`로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 기본 줄번호 숨김이고, 줄번호 표시를 켜면 선언 줄에 `line-numbers`를 저장한다. 기존 `linenos`, `nolinenos`, `no-line-numbers` 토큰은 읽기 호환으로만 유지한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
- 라이브 모드 공통 편집기는 한글 IME 조합 확정 Enter를 문단·빈 줄의 분리, 제목·목록의 아래 줄 삽입, 토글 제목의 본문 이동, 인용·콜아웃·코드·토글 본문의 텍스트 줄바꿈에 동일하게 연결한다. IME가 확정 Enter의 `keydown`을 전달하지 않고 `keyup`만 남기는 경우에도 조합 종료 직후 Enter로 보고 같은 동작을 실행한다. Shift+방향키 범위 선택은 `ContentMarkdownRenderer` Selection Bridge가 인접 편집 블록까지 확장한다. 단일 줄 블록의 Shift+위/아래는 커서 위치와 상관없이 현재 열 기준으로 이전·다음 편집 블록까지 확장하고, 멀티라인 편집 영역은 첫 줄·마지막 줄 경계에서 `data-source-line`~`data-source-line-end` 줄 범위로 탐색한다. `Cmd/Ctrl+A` 1회는 현재 블록 전체, 짧은 시간 안 2회는 라이브 본문 전체를 선택한다. 교차 블록·전체 선택 상태에서 **Backspace**·**Delete**·**Cmd/Ctrl+X**는 선택 범위를 마크다운 줄로 변환해 `content-replace`로 본문을 갱신한다. 콜아웃·인용 내부 전체 선택 삭제는 블록을 제거하지 않고 빈 본문 줄을 남긴다. 블록 이동 단축키는 Shift 조합일 때 실행하지 않으며, 선택 제스처가 우선한다. 콜아웃 본문은 하나의 멀티라인 편집 영역으로 유지해 `Shift+방향키` 선택이 내부 여러 줄을 가로지를 수 있게 한다. 라이브 멀티라인 블록은 브라우저 기본 DOM 줄 생성 대신 텍스트 값 치환 방식으로 줄바꿈을 삽입하며, contenteditable 값을 읽을 때 끝 줄바꿈을 보존해 첫 줄 빈 줄과 후행 빈 줄이 소스·라이브 모드 전환 또는 저장 중 유실되지 않게 한다. 인용 마지막 줄에서 아래 방향키를 누르면 외부 빈 문단을 만들고 커서를 해당 문단으로 이동하지만, 콜아웃 아래 방향키는 본문 줄을 새로 만들지 않는다.
- 인라인 마크다운은 Obsidian식 `$...$` 첨자 토큰과 `::color[텍스트]` 색상 토큰을 지원한다. `$H_2O$`는 `H`+아래첨자 `2`+`O`, `$2^8$`은 `2`+위첨자 `8`, `$_B^AR$`는 아래첨자 `B`와 위첨자 `AR`로 렌더링한다. 첨자 본문에 공백·기호가 필요하면 `$_{...}$`, `$^{...}$` 형식도 허용한다. 색상 토큰은 `::gray[덜 중요한 말]`, `::red[**중요한 말**]`처럼 저장하며 `gray`, `blue`, `green`, `yellow`, `red`, `purple`만 허용한다. 색상 토큰 내부의 굵게·기울임·인라인 코드·링크·첨자와 `**[링크](url)**` 같은 굵은 링크는 재귀적으로 렌더링한다.
- 라이브 모드 `:::` fenced 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다.
diff --git a/docs/update.md b/docs/update.md
index 3f8bf41..8e2171c 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,11 @@
# 업데이트 이력
+## v1.11.110
+
+- 관리자 Markdown 에디터에서 일치하는 슬래시 명령이 없는 `/ㄴㅇ` 같은 입력이 Enter 줄바꿈을 막던 문제 수정.
+- 슬래시 메뉴 키보드 이벤트는 표시할 명령 후보가 있을 때만 Enter·방향키를 가로채도록 보정.
+- 슬래시 명령 후보 없음·키보드 가로채기 조건 단위 테스트 추가.
+
## v1.11.109
- 관리자 Markdown 에디터에서 `/` 슬래시 메뉴를 ESC로 닫은 뒤 줄 내용을 바꾸고 다시 `/`를 입력해도 메뉴가 다시 열리지 않던 문제 수정.
diff --git a/lib/markdown-slash-commands.js b/lib/markdown-slash-commands.js
index 022a520..1b8b56b 100644
--- a/lib/markdown-slash-commands.js
+++ b/lib/markdown-slash-commands.js
@@ -219,6 +219,21 @@ export const resolveSlashCommand = (query) => {
return exact ?? matches[0]
}
+/**
+ * 편집 값에서 적용 가능한 슬래시 명령을 해석한다.
+ * @param {string} value - 편집 값
+ * @returns {MarkdownSlashCommand|null} 명령
+ */
+export const resolveSlashCommandFromInputValue = (value) => {
+ const parsed = parseSlashInput(value)
+
+ if (!parsed) {
+ return null
+ }
+
+ return resolveSlashCommand(parsed.query)
+}
+
/**
* 미디어 선택 모달을 여는 슬래시 명령인지 확인한다.
* @param {string} action - 명령 action
@@ -379,6 +394,27 @@ export const getSlashMenuKeyboardAction = (key) => {
return ''
}
+/**
+ * 슬래시 메뉴가 키보드 이벤트를 직접 가로채야 하는지 확인한다.
+ * @param {Object} options - 판별 옵션
+ * @param {'navigate'|'apply'|'cancel'|''} options.action - 키보드 동작
+ * @param {number} options.commandCount - 현재 표시된 명령 개수
+ * @returns {boolean} 가로채기 여부
+ */
+export const shouldCaptureSlashMenuKeyboardAction = ({ action, commandCount }) => {
+ if (action === 'cancel') {
+ return true
+ }
+
+ const count = Number.isFinite(commandCount) ? Math.max(0, Math.floor(commandCount)) : 0
+
+ if (action === 'navigate' || action === 'apply') {
+ return count > 0
+ }
+
+ return false
+}
+
/**
* 라이브 모드 미디어 슬래시 명령 처리 중 지연 commit을 무시할지 확인한다.
* @param {Object} options - 판별 옵션
diff --git a/package-lock.json b/package-lock.json
index 65b77f1..e292290 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sori.studio",
- "version": "1.11.109",
+ "version": "1.11.110",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
- "version": "1.11.109",
+ "version": "1.11.110",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/package.json b/package.json
index f22831b..02ec0ee 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "1.11.109",
+ "version": "1.11.110",
"private": true,
"type": "module",
"imports": {
diff --git a/tests/markdown-slash-commands.test.js b/tests/markdown-slash-commands.test.js
index f89fd3f..7327eaf 100644
--- a/tests/markdown-slash-commands.test.js
+++ b/tests/markdown-slash-commands.test.js
@@ -14,6 +14,8 @@ import {
parseSlashInput,
removeSlashSuppressedLine,
resolveSlashCommand,
+ resolveSlashCommandFromInputValue,
+ shouldCaptureSlashMenuKeyboardAction,
shouldIgnorePendingMediaSlashCommit
} from '../lib/markdown-slash-commands.js'
@@ -29,6 +31,9 @@ test('슬래시 입력은 단일 줄 명령만 파싱한다', () => {
test('정확한 슬래시 명령과 미디어 액션을 판별한다', () => {
assert.equal(resolveSlashCommand('image')?.action, 'media-image')
assert.equal(resolveSlashCommand('img')?.id, 'image')
+ assert.equal(resolveSlashCommandFromInputValue('/image')?.id, 'image')
+ assert.equal(resolveSlashCommandFromInputValue('/ㄴㅇ'), null)
+ assert.equal(resolveSlashCommandFromInputValue(' /image'), null)
assert.equal(isMediaSlashAction('media-image'), true)
assert.equal(isMediaSlashAction('lines'), false)
assert.equal(getMediaPickerKindFromSlashAction('media-image'), 'image')
@@ -146,6 +151,33 @@ test('슬래시 메뉴 키보드 입력은 메뉴 동작으로 분류한다', ()
assert.equal(getSlashMenuKeyboardAction('Tab'), '')
})
+test('슬래시 메뉴 키보드 입력은 적용 가능한 명령이 있을 때만 가로챈다', () => {
+ assert.equal(shouldCaptureSlashMenuKeyboardAction({
+ action: 'navigate',
+ commandCount: 2
+ }), true)
+ assert.equal(shouldCaptureSlashMenuKeyboardAction({
+ action: 'apply',
+ commandCount: 1
+ }), true)
+ assert.equal(shouldCaptureSlashMenuKeyboardAction({
+ action: 'navigate',
+ commandCount: 0
+ }), false)
+ assert.equal(shouldCaptureSlashMenuKeyboardAction({
+ action: 'apply',
+ commandCount: 0
+ }), false)
+ assert.equal(shouldCaptureSlashMenuKeyboardAction({
+ action: 'cancel',
+ commandCount: 0
+ }), true)
+ assert.equal(shouldCaptureSlashMenuKeyboardAction({
+ action: '',
+ commandCount: 1
+ }), false)
+})
+
test('슬래시 명령 줄에 미디어를 삽입할 때 원문 줄을 교체한다', () => {
const markdown = [
'첫 줄',