슬래시 메뉴 재입력 억제 보정

This commit is contained in:
2026-07-27 19:05:08 +09:00
parent e5e4febbbc
commit a0e126b2a9
9 changed files with 67 additions and 16 deletions

View File

@@ -882,8 +882,10 @@ const syncWriteSlashState = () => {
}
const line = activeLogicalLineIndex.value
const lines = (writeModeContextValue.value ?? markdownValue.value ?? '').split('\n')
const lineValue = lines[line] ?? ''
if (isLiveSlashLineSuppressed(line)) {
if (isLiveSlashLineSuppressed(line, lineValue)) {
if (liveSlashSourceLine.value !== null) {
clearLiveSlashState()
}
@@ -891,7 +893,7 @@ const syncWriteSlashState = () => {
return
}
const lines = (markdownValue.value ?? '').split('\n')
releaseLiveSlashLine(line)
if (isCodeFenceBodyLine(lines, line)) {
onLiveSlashEnd({ sourceLine: line })
@@ -2352,7 +2354,10 @@ const onLiveSlashUpdate = (payload) => {
return
}
if (isLiveSlashLineSuppressed(payload.sourceLine)) {
const lines = (markdownValue.value ?? '').split('\n')
const lineValue = lines[payload.sourceLine] ?? ''
if (isLiveSlashLineSuppressed(payload.sourceLine, lineValue)) {
if (liveSlashSourceLine.value !== null) {
clearLiveSlashState()
}
@@ -2360,7 +2365,7 @@ const onLiveSlashUpdate = (payload) => {
return
}
const lines = (markdownValue.value ?? '').split('\n')
releaseLiveSlashLine(payload.sourceLine)
if (isCodeFenceBodyLine(lines, payload.sourceLine)) {
onLiveSlashEnd({ sourceLine: payload.sourceLine })
@@ -2420,7 +2425,8 @@ const cancelLiveSlashCommand = () => {
const line = liveSlashSourceLine.value
if (line !== null) {
suppressLiveSlashLine(line)
const lines = (markdownValue.value ?? '').split('\n')
suppressLiveSlashLine(line, lines[line] ?? '')
}
clearLiveSlashState()

View File

@@ -16,14 +16,15 @@ import {
* liveSlashMenuRef: import('vue').Ref<HTMLElement|null>,
* liveSlashInsertLine: import('vue').Ref<number|null>,
* liveSlashSuppressedLines: import('vue').Ref<Set<number>>,
* liveSlashSuppressedLineValues: import('vue').Ref<Record<string, string>>,
* liveSlashKeyboardNav: import('vue').Ref<boolean>,
* liveSlashVisible: import('vue').ComputedRef<boolean>,
* liveSlashSuppressedLineList: import('vue').ComputedRef<number[]>,
* visibleLiveSlashCommands: import('vue').ComputedRef<import('../lib/markdown-slash-commands.js').MarkdownSlashCommand[]>,
* clearLiveSlashState: () => void,
* openLiveSlashMenu: (sourceLine: number, query: string) => void,
* isLiveSlashLineSuppressed: (line: number) => boolean,
* suppressLiveSlashLine: (line: number) => void,
* isLiveSlashLineSuppressed: (line: number, lineValue?: string) => boolean,
* suppressLiveSlashLine: (line: number, lineValue?: string) => void,
* releaseLiveSlashLine: (line: number) => void,
* resetLiveSlashHighlightedIndex: () => void,
* clampLiveSlashHighlightedIndex: (commandCount: number) => void,
@@ -39,6 +40,7 @@ export const useAdminMarkdownSlashMenu = () => {
const liveSlashMenuRef = ref(null)
const liveSlashInsertLine = ref(null)
const liveSlashSuppressedLines = ref(new Set())
const liveSlashSuppressedLineValues = ref({})
const liveSlashKeyboardNav = ref(false)
let liveSlashKeyboardNavTimer = null
@@ -72,15 +74,35 @@ export const useAdminMarkdownSlashMenu = () => {
* @param {number} line - 줄 번호
* @returns {boolean} 억제 여부
*/
const isLiveSlashLineSuppressed = (line) => liveSlashSuppressedLines.value.has(line)
const isLiveSlashLineSuppressed = (line, lineValue) => {
if (!liveSlashSuppressedLines.value.has(line)) {
return false
}
const suppressedValue = liveSlashSuppressedLineValues.value[String(line)]
if (suppressedValue === undefined || lineValue === undefined) {
return true
}
return suppressedValue === lineValue
}
/**
* 지정 줄에서 슬래시 메뉴가 다시 열리지 않도록 억제한다.
* @param {number} line - 줄 번호
* @param {string} [lineValue] - 억제 당시 줄 내용
* @returns {void}
*/
const suppressLiveSlashLine = (line) => {
const suppressLiveSlashLine = (line, lineValue) => {
liveSlashSuppressedLines.value = addSlashSuppressedLine(liveSlashSuppressedLines.value, line)
if (Number.isFinite(line)) {
liveSlashSuppressedLineValues.value = {
...liveSlashSuppressedLineValues.value,
[String(line)]: String(lineValue ?? '')
}
}
}
/**
@@ -90,6 +112,12 @@ export const useAdminMarkdownSlashMenu = () => {
*/
const releaseLiveSlashLine = (line) => {
liveSlashSuppressedLines.value = removeSlashSuppressedLine(liveSlashSuppressedLines.value, line)
if (Number.isFinite(line)) {
const nextValues = { ...liveSlashSuppressedLineValues.value }
delete nextValues[String(line)]
liveSlashSuppressedLineValues.value = nextValues
}
}
/**
@@ -140,6 +168,7 @@ export const useAdminMarkdownSlashMenu = () => {
liveSlashMenuRef,
liveSlashInsertLine,
liveSlashSuppressedLines,
liveSlashSuppressedLineValues,
liveSlashKeyboardNav,
liveSlashVisible,
liveSlashSuppressedLineList,

View File

@@ -1,3 +1,7 @@
## 2026-07-27 v1.11.109 — 슬래시 메뉴 취소는 같은 입력에만 유지한다
슬래시 메뉴를 ESC로 닫은 뒤 같은 줄에서 다시 열리지 않도록 억제하는 처리는 필요하지만, 줄 번호만 기준으로 억제하면 사용자가 `/`를 지웠다가 다시 입력해도 새 입력으로 인식하지 못한다. 취소는 “현재 입력을 잠시 무시”하는 의미이지 “그 줄의 슬래시 메뉴를 계속 금지”하는 의미가 아니므로, 억제 상태에 취소 당시 줄 내용을 함께 저장하고 줄 내용이 바뀌면 억제를 해제한다.
## 2026-07-27 v1.11.108 — 미디어 업로드 규칙을 에디터 밖에서 검증한다
관리자 Markdown 에디터의 미디어 업로드는 서버 요청 자체보다 파일 목록 정규화, multipart 본문 생성, API 응답 파일 추출, 붙여넣기·드롭 시 이미지 필터링, 단일/갤러리 삽입 대상 선택 같은 작은 규칙이 많다. 이 규칙을 컴포넌트 안에 두면 요청 실행과 사용자 입력 판별이 섞여 회귀 테스트를 쓰기 어렵다. 따라서 업로드 전후 계산은 `markdown-editor-media` helper로 옮기고, 에디터는 `$fetch` 실행과 성공 후 본문 반영·토스트 표시만 맡도록 책임을 더 좁힌다.

View File

@@ -25,7 +25,7 @@
| composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 |
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·억제 줄·키보드 탐색 타이머 관리 |
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·취소 당시 줄 내용 기준 억제·키보드 탐색 타이머 관리 |
| composables/useAdminMarkdownHistory.js | 관리자 Markdown 에디터 undo/redo 스택·기록 그룹화·복원 중 상태 관리 |
| composables/useAdminMarkdownMediaPicker.js | 관리자 Markdown 에디터 미디어 선택 모달 열기·닫기·탭·검색어·페이지·선택 상태·표시 계산 관리 |
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
@@ -142,7 +142,7 @@
| tests/markdown-editor-media.test.js | 관리자 Markdown 에디터 미디어 선택 helper의 종류·선택 가능 여부·미리보기·선택 상태·삽입 계획·업로드 정규화·블록 생성 단위 회귀 테스트 |
| tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·미디어 action·모달 종류·적용 계획·제외 줄 Set 갱신·하이라이트 인덱스 보정·방향키 순환·키보드 동작 분류·포커스 대상·textarea 선택 위치·원문 줄 교체 삽입 단위 회귀 테스트 |
| tests/markdown-slash-menu-scroll.test.js | 관리자 슬래시 메뉴 선택 항목 scrollTop 보정 단위 테스트 |
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태 관리 단위 테스트 |
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태·줄 내용 기준 억제 관리 단위 테스트 |
| tests/useAdminMarkdownHistory.test.js | 관리자 Markdown 에디터 히스토리 composable의 기록·undo/redo·그룹화·보관 개수 단위 테스트 |
| tests/useAdminMarkdownMediaPicker.test.js | 관리자 Markdown 에디터 미디어 선택 모달 composable 상태 관리 단위 테스트 |
| tests/useContentMarkdownBlocks.test.js | 공개 Markdown 블록 계산 composable의 파싱·제목 ID·광고 삽입·하단 삽입 줄 단위 테스트 |

View File

@@ -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·문단 이동(방향키) 시 편집 영역의 `<strong>`·`<em>` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. 인용·콜아웃 멀티라인 편집 영역도 일반 인라인 렌더러를 사용해 색상·굵게·링크 등 인라인 문법을 표시한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하되, 코드 fenced 블록 본문 안에서는 `/`를 일반 텍스트로 둔다. 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **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·문단 이동(방향키) 시 편집 영역의 `<strong>`·`<em>` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. 인용·콜아웃 멀티라인 편집 영역도 일반 인라인 렌더러를 사용해 색상·굵게·링크 등 인라인 문법을 표시한다. **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 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
- 라이브 모드 공통 편집기는 한글 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 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.109
- 관리자 Markdown 에디터에서 `/` 슬래시 메뉴를 ESC로 닫은 뒤 줄 내용을 바꾸고 다시 `/`를 입력해도 메뉴가 다시 열리지 않던 문제 수정.
- 슬래시 메뉴 취소 억제 기준을 줄 번호만이 아니라 취소 당시 줄 내용까지 함께 보도록 보강.
- 슬래시 메뉴 억제 상태 단위 테스트 보강.
## v1.11.108
- 관리자 Markdown 에디터의 미디어 목록 조회 query 생성과 업로드 FormData·응답 파일 해석 규칙을 `markdown-editor-media` helper로 분리.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.108",
"version": "1.11.109",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.108",
"version": "1.11.109",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.11.108",
"version": "1.11.109",
"private": true,
"type": "module",
"imports": {

View File

@@ -23,16 +23,22 @@ test('관리자 Markdown 슬래시 메뉴 상태는 열기와 초기화를 관
test('관리자 Markdown 슬래시 메뉴 억제 줄은 추가와 해제를 분리한다', () => {
const slashMenu = useAdminMarkdownSlashMenu()
slashMenu.suppressLiveSlashLine(3)
slashMenu.suppressLiveSlashLine(3, '/image')
slashMenu.suppressLiveSlashLine(Number.NaN)
assert.equal(slashMenu.isLiveSlashLineSuppressed(3), true)
assert.equal(slashMenu.isLiveSlashLineSuppressed(3, '/image'), true)
assert.equal(slashMenu.isLiveSlashLineSuppressed(3, '/'), false)
assert.deepEqual(slashMenu.liveSlashSuppressedLineList.value, [3])
assert.deepEqual(slashMenu.liveSlashSuppressedLineValues.value, {
3: '/image'
})
slashMenu.releaseLiveSlashLine(3)
assert.equal(slashMenu.isLiveSlashLineSuppressed(3), false)
assert.deepEqual(slashMenu.liveSlashSuppressedLineList.value, [])
assert.deepEqual(slashMenu.liveSlashSuppressedLineValues.value, {})
})
test('관리자 Markdown 슬래시 메뉴 하이라이트는 검색어와 명령 개수에 맞춰 보정한다', () => {