에디터 슬래시 메뉴 컴포넌트 분리

This commit is contained in:
2026-07-27 09:54:26 +09:00
parent 84cb3ad9e7
commit f4a2f1c034
6 changed files with 109 additions and 40 deletions

View File

@@ -63,7 +63,7 @@ import {
normalizeUploadMaxFileCount,
normalizeUploadMaxTotalSize
} from '../../lib/upload-size-limit.js'
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
import AdminMarkdownSlashMenu from './AdminMarkdownSlashMenu.vue'
const { toast, showToast } = useAdminToast()
const runtimeConfig = useRuntimeConfig()
@@ -2325,6 +2325,24 @@ const clampSlashMenuPosition = (top, left, menuHeight, menuWidth, anchorTop) =>
}
}
/**
* 슬래시 메뉴 컴포넌트가 노출한 실제 DOM 엘리먼트를 반환한다.
* @returns {HTMLElement|null} 메뉴 엘리먼트
*/
const getLiveSlashMenuElement = () => {
const menu = liveSlashMenuRef.value
if (!menu) {
return null
}
if (typeof menu.getRootElement === 'function') {
return menu.getRootElement()
}
return menu.rootRef ?? menu
}
/**
* 미리보기 모드 슬래시 메뉴 위치를 갱신한다.
* @returns {void}
@@ -2337,7 +2355,7 @@ const updatePreviewSlashMenuPosition = () => {
}
const rect = element.getBoundingClientRect()
const menuEl = liveSlashMenuRef.value
const menuEl = getLiveSlashMenuElement()
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
const menuWidth = menuEl?.offsetWidth || 288
const gap = 4
@@ -2367,7 +2385,7 @@ const updateWriteSlashMenuPosition = () => {
const lineStart = value.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0)
const coords = getTextareaCaretCoordinates(textarea, lineStart)
const rect = textarea.getBoundingClientRect()
const menuEl = liveSlashMenuRef.value
const menuEl = getLiveSlashMenuElement()
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
const menuWidth = menuEl?.offsetWidth || 288
const gap = 4
@@ -2413,7 +2431,7 @@ const scrollLiveSlashHighlightedIntoView = () => {
}
nextTick(() => {
const menu = liveSlashMenuRef.value
const menu = getLiveSlashMenuElement()
if (!menu) {
return
@@ -3589,41 +3607,15 @@ const handleKeydown = (event) => {
</div>
<Teleport to="body">
<div
<AdminMarkdownSlashMenu
v-if="liveSlashVisible"
ref="liveSlashMenuRef"
class="admin-markdown-editor__slash-menu fixed z-[60] w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded-lg border border-[#e3e6e8] bg-white p-1 shadow-lg"
:style="{ top: `${liveSlashMenuPosition.top}px`, left: `${liveSlashMenuPosition.left}px` }"
role="listbox"
aria-label="슬래시 명령"
>
<button
v-for="(command, commandIndex) in visibleLiveSlashCommands"
:key="`live-slash-${command.id}`"
class="admin-markdown-editor__slash-item flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left transition-colors"
:class="commandIndex === liveSlashHighlightedIndex ? 'admin-markdown-editor__slash-item--active bg-[#f4f5f6]' : ''"
type="button"
role="option"
:aria-selected="commandIndex === liveSlashHighlightedIndex"
@mouseenter="onSlashMenuItemMouseEnter(commandIndex)"
@mousedown.prevent="applyLiveSlashCommand(command)"
>
<span class="admin-markdown-editor__slash-leading flex min-w-0 items-center gap-2.5">
<AdminSlashCommandIcon
:command-id="command.id"
class="admin-markdown-editor__slash-icon shrink-0 text-[#394047]"
/>
<span class="admin-markdown-editor__slash-label truncate text-sm text-[#15171a]">{{ command.label }}</span>
</span>
<span
class="admin-markdown-editor__slash-slug shrink-0 text-xs text-[#8b959e] transition-opacity"
:class="commandIndex === liveSlashHighlightedIndex ? 'opacity-100' : 'opacity-0'"
>/{{ command.id }}</span>
</button>
<p v-if="!visibleLiveSlashCommands.length" class="admin-markdown-editor__slash-empty px-4 py-3 text-sm text-[#6b7280]">
일치하는 명령이 없습니다.
</p>
</div>
:commands="visibleLiveSlashCommands"
:highlighted-index="liveSlashHighlightedIndex"
:position="liveSlashMenuPosition"
@highlight="onSlashMenuItemMouseEnter"
@select="applyLiveSlashCommand"
/>
</Teleport>
<AdminMarkdownMediaPickerModal

View File

@@ -0,0 +1,71 @@
<script setup>
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
defineProps({
/** @type {import('vue').PropType<Array<{ id: string, label: string }>>} */
commands: {
type: Array,
default: () => []
},
highlightedIndex: {
type: Number,
default: 0
},
/** @type {import('vue').PropType<{ top: number, left: number }>} */
position: {
type: Object,
default: () => ({ top: 0, left: 0 })
}
})
const emit = defineEmits(['highlight', 'select'])
const rootRef = ref(null)
/**
* 부모 컴포넌트의 위치 보정·스크롤 계산에 사용할 메뉴 루트 엘리먼트를 반환한다.
* @returns {HTMLElement|null} 메뉴 루트 엘리먼트
*/
const getRootElement = () => rootRef.value
defineExpose({
getRootElement,
rootRef
})
</script>
<template>
<div
ref="rootRef"
class="admin-markdown-editor__slash-menu fixed z-[60] w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded-lg border border-[#e3e6e8] bg-white p-1 shadow-lg"
:style="{ top: `${position.top}px`, left: `${position.left}px` }"
role="listbox"
aria-label="슬래시 명령"
>
<button
v-for="(command, commandIndex) in commands"
:key="`live-slash-${command.id}`"
class="admin-markdown-editor__slash-item flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left transition-colors"
:class="commandIndex === highlightedIndex ? 'admin-markdown-editor__slash-item--active bg-[#f4f5f6]' : ''"
type="button"
role="option"
:aria-selected="commandIndex === highlightedIndex"
@mouseenter="emit('highlight', commandIndex)"
@mousedown.prevent="emit('select', command)"
>
<span class="admin-markdown-editor__slash-leading flex min-w-0 items-center gap-2.5">
<AdminSlashCommandIcon
:command-id="command.id"
class="admin-markdown-editor__slash-icon shrink-0 text-[#394047]"
/>
<span class="admin-markdown-editor__slash-label truncate text-sm text-[#15171a]">{{ command.label }}</span>
</span>
<span
class="admin-markdown-editor__slash-slug shrink-0 text-xs text-[#8b959e] transition-opacity"
:class="commandIndex === highlightedIndex ? 'opacity-100' : 'opacity-0'"
>/{{ command.id }}</span>
</button>
<p v-if="!commands.length" class="admin-markdown-editor__slash-empty px-4 py-3 text-sm text-[#6b7280]">
일치하는 명령이 없습니다.
</p>
</div>
</template>

View File

@@ -219,6 +219,7 @@
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·자동 저장 실패/진행 중 이탈 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 설정 패널 상태와 이벤트 연결, 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminMarkdownMediaPickerModal.vue | 관리자 Markdown 에디터 미디어 선택 모달 UI(업로드·라이브러리 탭, 검색 입력, 미디어 그리드, 업로드 영역, 페이지네이션, 적용/취소 버튼) |
| components/admin/AdminMarkdownSlashMenu.vue | 관리자 Markdown 에디터 슬래시 명령 메뉴 UI(명령 아이콘·라벨·슬러그 표시, 키보드 하이라이트 상태 표시, 루트 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 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.11.79
- 관리자 Markdown 에디터의 슬래시 명령 메뉴 UI를 `AdminMarkdownSlashMenu` 컴포넌트로 분리.
- 슬래시 메뉴 위치 보정·키보드 선택 스크롤을 유지하도록 메뉴 DOM 참조 연결 정리.
## v1.11.78
- 관리자 Markdown 에디터의 미디어 선택 모달 UI를 `AdminMarkdownMediaPickerModal` 컴포넌트로 분리.

4
package-lock.json generated
View File

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

View File

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