에디터 슬래시 상태와 단축키 판별 분리

This commit is contained in:
2026-07-27 12:09:47 +09:00
parent 358199e595
commit bf48af7bd3
10 changed files with 361 additions and 69 deletions

View File

@@ -21,6 +21,11 @@ import {
getEditorHistoryEntrySelection, getEditorHistoryEntrySelection,
getEditorHistoryEntryValue getEditorHistoryEntryValue
} from '../../lib/markdown-editor-history.js' } from '../../lib/markdown-editor-history.js'
import {
getEditorHistoryKeyboardAction,
isEditorModeToggleShortcut,
isMediaPickerCloseShortcut
} from '../../lib/markdown-editor-keyboard.js'
import { import {
buildMediaPickerApplyState, buildMediaPickerApplyState,
buildMediaPickerEmptyMessage, buildMediaPickerEmptyMessage,
@@ -44,9 +49,6 @@ import {
toggleMediaSelectionState toggleMediaSelectionState
} from '../../lib/markdown-editor-media.js' } from '../../lib/markdown-editor-media.js'
import { import {
addSlashSuppressedLine,
clampSlashHighlightedIndex,
filterSlashCommands,
getMediaPickerKindFromSlashAction, getMediaPickerKindFromSlashAction,
getNextSlashHighlightedIndex, getNextSlashHighlightedIndex,
getSlashCommandFocusTarget, getSlashCommandFocusTarget,
@@ -55,7 +57,6 @@ import {
insertMarkdownLinesAtSlashTarget, insertMarkdownLinesAtSlashTarget,
isMediaSlashAction, isMediaSlashAction,
parseSlashInput, parseSlashInput,
removeSlashSuppressedLine,
resolveSlashCommand, resolveSlashCommand,
shouldIgnorePendingMediaSlashCommit shouldIgnorePendingMediaSlashCommit
} from '../../lib/markdown-slash-commands.js' } from '../../lib/markdown-slash-commands.js'
@@ -69,6 +70,7 @@ import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OP
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js' import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js' import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js' import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import { useAdminMarkdownSlashMenu } from '../../composables/useAdminMarkdownSlashMenu.js'
import { import {
buildDefaultUploadSizeLimits, buildDefaultUploadSizeLimits,
DEFAULT_UPLOAD_MAX_FILE_COUNT, DEFAULT_UPLOAD_MAX_FILE_COUNT,
@@ -147,44 +149,32 @@ const lastSelectionState = ref({
start: 0, start: 0,
end: 0 end: 0
}) })
/** @type {import('vue').Ref<number|null>} 슬래시 명령이 입력된 줄 */
const liveSlashSourceLine = ref(null)
/** @type {import('vue').Ref<string>} 슬래시 검색어 */
const liveSlashQuery = ref('')
/** @type {import('vue').Ref<number>} 슬래시 메뉴 하이라이트 인덱스 */
const liveSlashHighlightedIndex = ref(0)
/** @type {import('vue').Ref<{ top: number, left: number }>} 슬래시 메뉴 위치 */
const liveSlashMenuPosition = ref({ top: 0, left: 0 })
/** @type {import('vue').Ref<HTMLElement|null>} 슬래시 메뉴 DOM */
const liveSlashMenuRef = ref(null)
/** @type {import('vue').Ref<number|null>} 슬래시 후 미디어 삽입 줄 */ const {
const liveSlashInsertLine = ref(null) liveSlashSourceLine,
/** @type {import('vue').Ref<Set<number>>} ESC로 슬래시 메뉴만 닫은 줄(0-based) */ liveSlashQuery,
const liveSlashSuppressedLines = ref(new Set()) liveSlashHighlightedIndex,
/** 키보드로 슬래시 메뉴를 탐색 중일 때 mouseenter 하이라이트 무시 */ liveSlashMenuPosition,
const liveSlashKeyboardNav = ref(false) liveSlashMenuRef,
let liveSlashKeyboardNavTimer = null liveSlashInsertLine,
liveSlashKeyboardNav,
liveSlashVisible,
liveSlashSuppressedLineList,
visibleLiveSlashCommands,
clearLiveSlashState,
openLiveSlashMenu,
isLiveSlashLineSuppressed,
suppressLiveSlashLine,
releaseLiveSlashLine,
resetLiveSlashHighlightedIndex,
clampLiveSlashHighlightedIndex,
startLiveSlashKeyboardNavigation,
stopLiveSlashKeyboardNavigation
} = useAdminMarkdownSlashMenu()
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 미리보기 스크롤 영역 */ /** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 미리보기 스크롤 영역 */
let liveSlashPreviewScrollElement = null let liveSlashPreviewScrollElement = null
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 작성 textarea */ /** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 작성 textarea */
let liveSlashTextareaScrollElement = null let liveSlashTextareaScrollElement = null
const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null)
const liveSlashSuppressedLineList = computed(() => [...liveSlashSuppressedLines.value])
const visibleLiveSlashCommands = computed(() => filterSlashCommands(liveSlashQuery.value))
/**
* 슬래시 메뉴 입력 상태를 초기화한다.
* @returns {void}
*/
const clearLiveSlashState = () => {
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
}
/** 되돌리기 히스토리 최대 보관 개수 */ /** 되돌리기 히스토리 최대 보관 개수 */
const EDITOR_UNDO_LIMIT = 100 const EDITOR_UNDO_LIMIT = 100
/** @type {import('vue').Ref<Array<string|{ value: string, selectionStart: number, selectionEnd: number }>>} 본문 되돌리기 스택 */ /** @type {import('vue').Ref<Array<string|{ value: string, selectionStart: number, selectionEnd: number }>>} 본문 되돌리기 스택 */
@@ -1054,7 +1044,7 @@ const syncWriteSlashState = () => {
const line = activeLogicalLineIndex.value const line = activeLogicalLineIndex.value
if (liveSlashSuppressedLines.value.has(line)) { if (isLiveSlashLineSuppressed(line)) {
if (liveSlashSourceLine.value !== null) { if (liveSlashSourceLine.value !== null) {
clearLiveSlashState() clearLiveSlashState()
} }
@@ -1076,8 +1066,7 @@ const syncWriteSlashState = () => {
return return
} }
liveSlashSourceLine.value = line openLiveSlashMenu(line, parsed.query)
liveSlashQuery.value = parsed.query
nextTick(updateLiveSlashMenuPosition) nextTick(updateLiveSlashMenuPosition)
} }
@@ -1423,7 +1412,7 @@ onMounted(() => {
* @returns {void} * @returns {void}
*/ */
const onDocumentKeydown = (event) => { const onDocumentKeydown = (event) => {
if (event.key === 'Escape' && isMediaPickerOpen.value) { if (isMediaPickerCloseShortcut(event) && isMediaPickerOpen.value) {
event.preventDefault() event.preventDefault()
closeMediaPicker() closeMediaPicker()
return return
@@ -1435,19 +1424,12 @@ onMounted(() => {
return return
} }
const hasCommandModifier = event.metaKey || event.ctrlKey const historyKeyboardAction = getEditorHistoryKeyboardAction(event)
const isUndoShortcut = hasCommandModifier
&& !event.altKey
&& !event.shiftKey
&& event.key.toLowerCase() === 'z'
const isRedoShortcut = hasCommandModifier
&& !event.altKey
&& (event.shiftKey && event.key.toLowerCase() === 'z' || event.key.toLowerCase() === 'y')
if (isUndoShortcut || isRedoShortcut) { if (historyKeyboardAction) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
restoreEditorHistory(isUndoShortcut ? 'undo' : 'redo') restoreEditorHistory(historyKeyboardAction)
return return
} }
@@ -1459,11 +1441,7 @@ onMounted(() => {
const count = visibleLiveSlashCommands.value.length const count = visibleLiveSlashCommands.value.length
if (count) { if (count) {
liveSlashKeyboardNav.value = true startLiveSlashKeyboardNavigation()
window.clearTimeout(liveSlashKeyboardNavTimer)
liveSlashKeyboardNavTimer = window.setTimeout(() => {
liveSlashKeyboardNav.value = false
}, 200)
liveSlashHighlightedIndex.value = getNextSlashHighlightedIndex({ liveSlashHighlightedIndex.value = getNextSlashHighlightedIndex({
currentIndex: liveSlashHighlightedIndex.value, currentIndex: liveSlashHighlightedIndex.value,
@@ -1493,7 +1471,7 @@ onMounted(() => {
} }
} }
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'e') { if (!isEditorModeToggleShortcut(event)) {
return return
} }
@@ -1533,7 +1511,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.clearTimeout(blockPanelFocusTimer) window.clearTimeout(blockPanelFocusTimer)
window.clearTimeout(liveSlashKeyboardNavTimer) stopLiveSlashKeyboardNavigation()
removeLiveSlashRepositionListeners() removeLiveSlashRepositionListeners()
document.removeEventListener('selectionchange', onSelectionChange) document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('keydown', onDocumentKeydown, true) document.removeEventListener('keydown', onDocumentKeydown, true)
@@ -2547,7 +2525,7 @@ const onLiveSlashUpdate = (payload) => {
return return
} }
if (liveSlashSuppressedLines.value.has(payload.sourceLine)) { if (isLiveSlashLineSuppressed(payload.sourceLine)) {
if (liveSlashSourceLine.value !== null) { if (liveSlashSourceLine.value !== null) {
clearLiveSlashState() clearLiveSlashState()
} }
@@ -2562,17 +2540,16 @@ const onLiveSlashUpdate = (payload) => {
return return
} }
liveSlashSourceLine.value = payload.sourceLine openLiveSlashMenu(payload.sourceLine, payload.query)
liveSlashQuery.value = String(payload.query ?? '')
nextTick(updateLiveSlashMenuPosition) nextTick(updateLiveSlashMenuPosition)
} }
watch(liveSlashQuery, () => { watch(liveSlashQuery, () => {
liveSlashHighlightedIndex.value = 0 resetLiveSlashHighlightedIndex()
}) })
watch(visibleLiveSlashCommands, (commands) => { watch(visibleLiveSlashCommands, (commands) => {
liveSlashHighlightedIndex.value = clampSlashHighlightedIndex(liveSlashHighlightedIndex.value, commands.length) clampLiveSlashHighlightedIndex(commands.length)
updateLiveSlashMenuPosition() updateLiveSlashMenuPosition()
}) })
@@ -2602,7 +2579,7 @@ watch(liveSlashVisible, (visible) => {
*/ */
const onLiveSlashEnd = (payload) => { const onLiveSlashEnd = (payload) => {
if (typeof payload?.sourceLine === 'number') { if (typeof payload?.sourceLine === 'number') {
liveSlashSuppressedLines.value = removeSlashSuppressedLine(liveSlashSuppressedLines.value, payload.sourceLine) releaseLiveSlashLine(payload.sourceLine)
} }
clearLiveSlashState() clearLiveSlashState()
@@ -2616,7 +2593,7 @@ const cancelLiveSlashCommand = () => {
const line = liveSlashSourceLine.value const line = liveSlashSourceLine.value
if (line !== null) { if (line !== null) {
liveSlashSuppressedLines.value = addSlashSuppressedLine(liveSlashSuppressedLines.value, line) suppressLiveSlashLine(line)
} }
clearLiveSlashState() clearLiveSlashState()
@@ -2689,7 +2666,7 @@ const applyLiveSlashCommand = (command) => {
if (command.action === 'lines' && Array.isArray(command.lines)) { if (command.action === 'lines' && Array.isArray(command.lines)) {
const replacementLines = command.lines const replacementLines = command.lines
liveSlashSuppressedLines.value = removeSlashSuppressedLine(liveSlashSuppressedLines.value, line) releaseLiveSlashLine(line)
replaceLineRange(line, line, replacementLines, false) replaceLineRange(line, line, replacementLines, false)
clearLiveSlashState() clearLiveSlashState()

View File

@@ -0,0 +1,157 @@
import { computed, ref } from 'vue'
import {
addSlashSuppressedLine,
clampSlashHighlightedIndex,
filterSlashCommands,
removeSlashSuppressedLine
} from '../lib/markdown-slash-commands.js'
/**
* 관리자 Markdown 에디터의 슬래시 메뉴 상태를 관리한다.
* @returns {{
* liveSlashSourceLine: import('vue').Ref<number|null>,
* liveSlashQuery: import('vue').Ref<string>,
* liveSlashHighlightedIndex: import('vue').Ref<number>,
* liveSlashMenuPosition: import('vue').Ref<{ top: number, left: number }>,
* liveSlashMenuRef: import('vue').Ref<HTMLElement|null>,
* liveSlashInsertLine: import('vue').Ref<number|null>,
* liveSlashSuppressedLines: import('vue').Ref<Set<number>>,
* 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,
* releaseLiveSlashLine: (line: number) => void,
* resetLiveSlashHighlightedIndex: () => void,
* clampLiveSlashHighlightedIndex: (commandCount: number) => void,
* startLiveSlashKeyboardNavigation: (durationMs?: number) => void,
* stopLiveSlashKeyboardNavigation: () => void
* }} 슬래시 메뉴 상태와 액션
*/
export const useAdminMarkdownSlashMenu = () => {
const liveSlashSourceLine = ref(null)
const liveSlashQuery = ref('')
const liveSlashHighlightedIndex = ref(0)
const liveSlashMenuPosition = ref({ top: 0, left: 0 })
const liveSlashMenuRef = ref(null)
const liveSlashInsertLine = ref(null)
const liveSlashSuppressedLines = ref(new Set())
const liveSlashKeyboardNav = ref(false)
let liveSlashKeyboardNavTimer = null
const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null)
const liveSlashSuppressedLineList = computed(() => [...liveSlashSuppressedLines.value])
const visibleLiveSlashCommands = computed(() => filterSlashCommands(liveSlashQuery.value))
/**
* 슬래시 메뉴 입력 상태를 초기화한다.
* @returns {void}
*/
const clearLiveSlashState = () => {
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
}
/**
* 슬래시 메뉴를 지정 줄과 검색어로 연다.
* @param {number} sourceLine - 슬래시 명령이 입력된 줄
* @param {string} query - 검색어
* @returns {void}
*/
const openLiveSlashMenu = (sourceLine, query) => {
liveSlashSourceLine.value = sourceLine
liveSlashQuery.value = String(query ?? '')
}
/**
* 슬래시 메뉴가 억제된 줄인지 확인한다.
* @param {number} line - 줄 번호
* @returns {boolean} 억제 여부
*/
const isLiveSlashLineSuppressed = (line) => liveSlashSuppressedLines.value.has(line)
/**
* 지정 줄에서 슬래시 메뉴가 다시 열리지 않도록 억제한다.
* @param {number} line - 줄 번호
* @returns {void}
*/
const suppressLiveSlashLine = (line) => {
liveSlashSuppressedLines.value = addSlashSuppressedLine(liveSlashSuppressedLines.value, line)
}
/**
* 지정 줄의 슬래시 메뉴 억제를 해제한다.
* @param {number} line - 줄 번호
* @returns {void}
*/
const releaseLiveSlashLine = (line) => {
liveSlashSuppressedLines.value = removeSlashSuppressedLine(liveSlashSuppressedLines.value, line)
}
/**
* 슬래시 메뉴 하이라이트를 첫 항목으로 되돌린다.
* @returns {void}
*/
const resetLiveSlashHighlightedIndex = () => {
liveSlashHighlightedIndex.value = 0
}
/**
* 슬래시 메뉴 하이라이트를 명령 개수 안으로 보정한다.
* @param {number} commandCount - 표시 가능한 명령 개수
* @returns {void}
*/
const clampLiveSlashHighlightedIndex = (commandCount) => {
liveSlashHighlightedIndex.value = clampSlashHighlightedIndex(liveSlashHighlightedIndex.value, commandCount)
}
/**
* 키보드 탐색 중 mouseenter 하이라이트를 잠시 무시한다.
* @param {number} [durationMs=200] - 유지 시간(ms)
* @returns {void}
*/
const startLiveSlashKeyboardNavigation = (durationMs = 200) => {
liveSlashKeyboardNav.value = true
globalThis.clearTimeout(liveSlashKeyboardNavTimer)
liveSlashKeyboardNavTimer = globalThis.setTimeout(() => {
liveSlashKeyboardNav.value = false
}, durationMs)
}
/**
* 슬래시 메뉴 키보드 탐색 타이머를 정리한다.
* @returns {void}
*/
const stopLiveSlashKeyboardNavigation = () => {
globalThis.clearTimeout(liveSlashKeyboardNavTimer)
liveSlashKeyboardNavTimer = null
liveSlashKeyboardNav.value = false
}
return {
liveSlashSourceLine,
liveSlashQuery,
liveSlashHighlightedIndex,
liveSlashMenuPosition,
liveSlashMenuRef,
liveSlashInsertLine,
liveSlashSuppressedLines,
liveSlashKeyboardNav,
liveSlashVisible,
liveSlashSuppressedLineList,
visibleLiveSlashCommands,
clearLiveSlashState,
openLiveSlashMenu,
isLiveSlashLineSuppressed,
suppressLiveSlashLine,
releaseLiveSlashLine,
resetLiveSlashHighlightedIndex,
clampLiveSlashHighlightedIndex,
startLiveSlashKeyboardNavigation,
stopLiveSlashKeyboardNavigation
}
}

View File

@@ -1,3 +1,7 @@
## 2026-07-27 v1.11.94 — Markdown 에디터의 입력 상태와 단축키 판별을 분리한다
관리자 Markdown 에디터는 미디어 선택, 슬래시 명령, 히스토리, 블록 패널이 한 파일에서 맞물려 동작하므로, 작은 조건문을 계속 파일 안에 남기면 회귀 원인을 찾기 어려워진다. 전역 단축키 판별은 순수 helper로, 슬래시 메뉴 refs·computed·억제 줄·키보드 탐색 타이머는 composable로 분리해 에디터 컴포넌트가 실제 편집 흐름 연결에 더 집중하도록 정리한다.
## 2026-07-23 v1.11.18 — 게시물 Import는 작업 단위로 상태를 남긴다 ## 2026-07-23 v1.11.18 — 게시물 Import는 작업 단위로 상태를 남긴다
대량 ZIP Import를 단일 HTTP 요청 안에서 끝내면 브라우저 종료, 네트워크 단절, 서버 재시작 시 사용자가 어느 게시물까지 처리됐는지 확인하기 어렵다. 게시물 생성은 자산 저장 이후에만 수행하되, 참조 자산이 누락된 게시물은 불완전한 글을 만들지 않고 게시물별 오류로 남긴다. Import도 Export처럼 PostgreSQL job/item 테이블과 advisory lock 기반 워커를 사용해 진행 상태를 남기고, 같은 slug는 재시도 시에도 스킵해 중복 게시물 생성을 막는다. 대량 ZIP Import를 단일 HTTP 요청 안에서 끝내면 브라우저 종료, 네트워크 단절, 서버 재시작 시 사용자가 어느 게시물까지 처리됐는지 확인하기 어렵다. 게시물 생성은 자산 저장 이후에만 수행하되, 참조 자산이 누락된 게시물은 불완전한 글을 만들지 않고 게시물별 오류로 남긴다. Import도 Export처럼 PostgreSQL job/item 테이블과 advisory lock 기반 워커를 사용해 진행 상태를 남기고, 같은 slug는 재시도 시에도 스킵해 중복 게시물 생성을 막는다.

View File

@@ -25,6 +25,7 @@
| composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 | | composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 |
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 | | composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 | | composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·억제 줄·키보드 탐색 타이머 관리 |
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 | | composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 | | composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
@@ -48,6 +49,7 @@
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` | | lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 | | lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
| lib/markdown-editor-history.js | 관리자 Markdown-first 에디터 undo/redo 히스토리 항목 생성, 레거시 문자열 항목 본문 추출, 선택 범위 보정 | | lib/markdown-editor-history.js | 관리자 Markdown-first 에디터 undo/redo 히스토리 항목 생성, 레거시 문자열 항목 본문 추출, 선택 범위 보정 |
| lib/markdown-editor-keyboard.js | 관리자 Markdown-first 에디터 전역 단축키 판별(Cmd/Ctrl 조합, undo/redo, 작성/미리보기 전환, 미디어 선택 닫기) |
| lib/markdown-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, 선택 대상별 제목·accept·API kind·업로드 안내, 라이브러리 빈 상태 문구, 적용 버튼 상태, 선택 순서·토글·항목 해석, 업로드 오류·한도 검증, 페이지 상태·범위·페이지 번호 보정·페이지 번호 계산, fenced 미디어 블록 생성 | | lib/markdown-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, 선택 대상별 제목·accept·API kind·업로드 안내, 라이브러리 빈 상태 문구, 적용 버튼 상태, 선택 순서·토글·항목 해석, 업로드 오류·한도 검증, 페이지 상태·범위·페이지 번호 보정·페이지 번호 계산, fenced 미디어 블록 생성 |
| lib/markdown-block-parser.js | 공개 Markdown 본문 문자열을 제목·문단·이미지·갤러리·fenced 블록·인용·목록·코드·표 렌더링 블록으로 변환 | | lib/markdown-block-parser.js | 공개 Markdown 본문 문자열을 제목·문단·이미지·갤러리·fenced 블록·인용·목록·코드·표 렌더링 블록으로 변환 |
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 인용 옵션 공통 파서 연결, 코드 fenced 블록 여닫는 펜스 쌍 판별 | | lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 인용 옵션 공통 파서 연결, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
@@ -122,9 +124,11 @@
| tests/content-image-urls.test.js | 공개 본문 이미지 파생 URL·srcset·갤러리 sizes 단위 회귀 테스트 | | tests/content-image-urls.test.js | 공개 본문 이미지 파생 URL·srcset·갤러리 sizes 단위 회귀 테스트 |
| tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 | | tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 |
| tests/markdown-editor-history.test.js | 관리자 Markdown 에디터 undo/redo 히스토리 항목과 선택 범위 보정 단위 회귀 테스트 | | tests/markdown-editor-history.test.js | 관리자 Markdown 에디터 undo/redo 히스토리 항목과 선택 범위 보정 단위 회귀 테스트 |
| tests/markdown-editor-keyboard.test.js | 관리자 Markdown 에디터 전역 단축키 판별 단위 회귀 테스트 |
| tests/markdown-editor-media.test.js | 관리자 Markdown 에디터 미디어 선택 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/markdown-slash-menu-scroll.test.js | 관리자 슬래시 메뉴 선택 항목 scrollTop 보정 단위 테스트 |
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태 관리 단위 테스트 |
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 | | tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 | | tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 | | tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |

View File

@@ -1,5 +1,11 @@
# 업데이트 이력 # 업데이트 이력
## v1.11.94
- 관리자 Markdown 에디터의 슬래시 메뉴 상태·억제 줄·키보드 탐색 타이머 관리를 `useAdminMarkdownSlashMenu` composable로 분리.
- 관리자 Markdown 에디터의 전역 키보드 단축키 판별을 `markdown-editor-keyboard` helper로 분리.
- 슬래시 메뉴 상태 관리와 에디터 Cmd/Ctrl 조합, undo/redo, 작성/미리보기 전환, 미디어 선택 닫기 단축키 단위 테스트 추가.
## v1.11.92 ## v1.11.92
- 관리자 Markdown 에디터의 슬래시 메뉴 키보드 입력 동작 판별을 `markdown-slash-commands` helper로 분리. - 관리자 Markdown 에디터의 슬래시 메뉴 키보드 입력 동작 판별을 `markdown-slash-commands` helper로 분리.

View File

@@ -0,0 +1,44 @@
/**
* 키보드 이벤트가 Cmd/Ctrl 조합인지 확인한다.
* @param {KeyboardEvent|Object} event - 키보드 이벤트
* @returns {boolean} Cmd/Ctrl 조합 여부
*/
export const hasEditorCommandModifier = (event) => Boolean(event?.metaKey || event?.ctrlKey)
/**
* 에디터 히스토리 단축키 동작을 계산한다.
* @param {KeyboardEvent|Object} event - 키보드 이벤트
* @returns {'undo'|'redo'|''} 히스토리 동작
*/
export const getEditorHistoryKeyboardAction = (event) => {
const key = String(event?.key ?? '').toLowerCase()
if (!hasEditorCommandModifier(event) || event?.altKey) {
return ''
}
if (!event?.shiftKey && key === 'z') {
return 'undo'
}
if (event?.shiftKey && key === 'z' || key === 'y') {
return 'redo'
}
return ''
}
/**
* 에디터 작성/미리보기 모드 전환 단축키인지 확인한다.
* @param {KeyboardEvent|Object} event - 키보드 이벤트
* @returns {boolean} 모드 전환 단축키 여부
*/
export const isEditorModeToggleShortcut = (event) => hasEditorCommandModifier(event)
&& String(event?.key ?? '').toLowerCase() === 'e'
/**
* 미디어 선택 모달을 닫는 키 입력인지 확인한다.
* @param {KeyboardEvent|Object} event - 키보드 이벤트
* @returns {boolean} 닫기 키 여부
*/
export const isMediaPickerCloseShortcut = (event) => event?.key === 'Escape'

4
package-lock.json generated
View File

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

View File

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

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getEditorHistoryKeyboardAction,
hasEditorCommandModifier,
isEditorModeToggleShortcut,
isMediaPickerCloseShortcut
} from '../lib/markdown-editor-keyboard.js'
test('에디터 Cmd/Ctrl 조합 여부를 판별한다', () => {
assert.equal(hasEditorCommandModifier({ metaKey: true }), true)
assert.equal(hasEditorCommandModifier({ ctrlKey: true }), true)
assert.equal(hasEditorCommandModifier({ metaKey: false, ctrlKey: false }), false)
assert.equal(hasEditorCommandModifier(null), false)
})
test('에디터 히스토리 단축키는 undo와 redo를 구분한다', () => {
assert.equal(getEditorHistoryKeyboardAction({ metaKey: true, key: 'z' }), 'undo')
assert.equal(getEditorHistoryKeyboardAction({ ctrlKey: true, key: 'Z' }), 'undo')
assert.equal(getEditorHistoryKeyboardAction({ metaKey: true, shiftKey: true, key: 'z' }), 'redo')
assert.equal(getEditorHistoryKeyboardAction({ ctrlKey: true, key: 'y' }), 'redo')
assert.equal(getEditorHistoryKeyboardAction({ metaKey: true, altKey: true, key: 'z' }), '')
assert.equal(getEditorHistoryKeyboardAction({ key: 'z' }), '')
})
test('에디터 모드 전환과 미디어 선택 닫기 단축키를 판별한다', () => {
assert.equal(isEditorModeToggleShortcut({ metaKey: true, key: 'e' }), true)
assert.equal(isEditorModeToggleShortcut({ ctrlKey: true, key: 'E' }), true)
assert.equal(isEditorModeToggleShortcut({ key: 'e' }), false)
assert.equal(isMediaPickerCloseShortcut({ key: 'Escape' }), true)
assert.equal(isMediaPickerCloseShortcut({ key: 'Enter' }), false)
})

View File

@@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useAdminMarkdownSlashMenu } from '../composables/useAdminMarkdownSlashMenu.js'
test('관리자 Markdown 슬래시 메뉴 상태는 열기와 초기화를 관리한다', () => {
const slashMenu = useAdminMarkdownSlashMenu()
slashMenu.openLiveSlashMenu(2, 'image')
assert.equal(slashMenu.liveSlashVisible.value, true)
assert.equal(slashMenu.liveSlashSourceLine.value, 2)
assert.equal(slashMenu.liveSlashQuery.value, 'image')
assert.equal(slashMenu.visibleLiveSlashCommands.value[0]?.id, 'image')
slashMenu.clearLiveSlashState()
assert.equal(slashMenu.liveSlashVisible.value, false)
assert.equal(slashMenu.liveSlashSourceLine.value, null)
assert.equal(slashMenu.liveSlashQuery.value, '')
assert.equal(slashMenu.liveSlashHighlightedIndex.value, 0)
})
test('관리자 Markdown 슬래시 메뉴 억제 줄은 추가와 해제를 분리한다', () => {
const slashMenu = useAdminMarkdownSlashMenu()
slashMenu.suppressLiveSlashLine(3)
slashMenu.suppressLiveSlashLine(Number.NaN)
assert.equal(slashMenu.isLiveSlashLineSuppressed(3), true)
assert.deepEqual(slashMenu.liveSlashSuppressedLineList.value, [3])
slashMenu.releaseLiveSlashLine(3)
assert.equal(slashMenu.isLiveSlashLineSuppressed(3), false)
assert.deepEqual(slashMenu.liveSlashSuppressedLineList.value, [])
})
test('관리자 Markdown 슬래시 메뉴 하이라이트는 검색어와 명령 개수에 맞춰 보정한다', () => {
const slashMenu = useAdminMarkdownSlashMenu()
slashMenu.liveSlashHighlightedIndex.value = 9
slashMenu.clampLiveSlashHighlightedIndex(3)
assert.equal(slashMenu.liveSlashHighlightedIndex.value, 2)
slashMenu.resetLiveSlashHighlightedIndex()
assert.equal(slashMenu.liveSlashHighlightedIndex.value, 0)
})
test('관리자 Markdown 슬래시 메뉴 키보드 탐색 상태는 타이머로 정리한다', async () => {
const slashMenu = useAdminMarkdownSlashMenu()
slashMenu.startLiveSlashKeyboardNavigation(10)
assert.equal(slashMenu.liveSlashKeyboardNav.value, true)
await new Promise((resolve) => {
globalThis.setTimeout(resolve, 20)
})
assert.equal(slashMenu.liveSlashKeyboardNav.value, false)
slashMenu.startLiveSlashKeyboardNavigation(100)
slashMenu.stopLiveSlashKeyboardNavigation()
assert.equal(slashMenu.liveSlashKeyboardNav.value, false)
})