에디터 공통 포커스 실행 분리

This commit is contained in:
2026-07-29 12:17:23 +09:00
parent 77271399cc
commit ae9883a588
10 changed files with 319 additions and 113 deletions

View File

@@ -49,6 +49,7 @@ import { useAdminMarkdownSlashMenu } from '../../composables/useAdminMarkdownSla
import { useAdminMarkdownSlashMenuPosition } from '../../composables/useAdminMarkdownSlashMenuPosition.js'
import { useAdminMarkdownSlashCommandActions } from '../../composables/useAdminMarkdownSlashCommandActions.js'
import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHistory.js'
import { useAdminMarkdownEditorFocusActions } from '../../composables/useAdminMarkdownEditorFocusActions.js'
import { useAdminMarkdownMediaPicker } from '../../composables/useAdminMarkdownMediaPicker.js'
import { useAdminMarkdownFocusBridge } from '../../composables/useAdminMarkdownFocusBridge.js'
import { useAdminMarkdownPreviewFocus } from '../../composables/useAdminMarkdownPreviewFocus.js'
@@ -440,44 +441,6 @@ const {
syncWriteSlashState: () => syncWriteSlashState()
})
/**
* 현재 선택 위치에 맞춰 에디터 포커스를 복원한다.
* @param {{ start: number, end: number }|null} selection - 복원할 선택 영역
* @returns {void}
*/
const restoreEditorFocusAfterHistory = (selection = null) => {
const focusLine = selection
? getLineIndexAtOffset(selection.start)
: activeLogicalLineIndex.value
if (activeMode.value === 'write') {
if (selection) {
setTextareaSelection(selection.start, selection.end, { preserveScroll: true })
return
}
focusTextareaAtLine(focusLine)
return
}
nextTick(() => {
const offset = selection
? Math.max(0, selection.start - getLineStartOffset(focusLine))
: 0
focusPreviewEditableAtLine(focusLine, 0, 'auto', offset, 'center')
})
}
const {
recordEditorHistory,
restoreEditorHistory
} = useAdminMarkdownHistory({
value: markdownValue,
lastSelectionState,
restoreFocusAfterHistory: restoreEditorFocusAfterHistory
})
/**
* 라이브 모드에 실제로 존재하는 편집 줄로 포커스 대상을 보정한다.
* @param {number} line - 원본 줄 번호
@@ -596,42 +559,6 @@ const {
focusPreviewEditableAtLine
})
/**
* 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
* @returns {number} 줄 번호(0-based)
*/
const getInitialContentFocusLineIndex = () => {
const lines = (markdownValue.value ?? '').split('\n')
if (!lines.length) {
return 0
}
const firstLine = lines[0]?.trim() ?? ''
if (/^#{1,6}\s+/.test(firstLine)) {
return 1
}
return 0
}
/**
* 본문 작성 시작 줄이 없으면 빈 줄을 추가한다.
* @param {number} lineIndex - 포커스할 줄
* @returns {number} 보정된 줄 번호
*/
const ensureContentFocusLineExists = (lineIndex) => {
const lines = (markdownValue.value ?? '').split('\n')
if (lineIndex < lines.length) {
return lineIndex
}
markdownValue.value = lines.length ? `${markdownValue.value}\n` : ''
return lines.length
}
/**
* 작성 모드와 미리보기 모드를 전환한다.
* @returns {void}
@@ -818,40 +745,6 @@ onMounted(() => {
syncBlockPanelState()
})
/**
* 본문 에디터에 포커스한다. 현재 모드(write/preview)를 유지한다.
* @returns {void}
*/
const focusFirstBlock = () => {
const focusLine = ensureContentFocusLineExists(getInitialContentFocusLineIndex())
if (activeMode.value === 'preview') {
scrollPreviewToTop()
nextTick(() => {
nextTick(() => {
focusPreviewEditableAtLine(focusLine, 0, 'start')
})
})
return
}
nextTick(() => {
const textarea = getWriteTextareaElement()
const lineStart = getLineStartOffset(focusLine)
if (textarea) {
textarea.focus()
textarea.setSelectionRange(lineStart, lineStart)
textarea.scrollTop = 0
}
activeLogicalLineIndex.value = focusLine
refreshCaretLogicalLine()
})
}
/**
* textarea 선택 영역 정보를 반환한다.
* @returns {{ start: number, end: number, value: string }} 선택 정보
@@ -924,6 +817,32 @@ const setTextareaSelection = (start, end = start, options = {}) => {
})
}
const {
focusFirstBlock,
restoreEditorFocusAfterHistory
} = useAdminMarkdownEditorFocusActions({
activeMode,
value: markdownValue,
activeLogicalLineIndex,
getWriteTextareaElement,
getLineIndexAtOffset,
getLineStartOffset,
setTextareaSelection,
focusTextareaAtLine,
refreshCaretLogicalLine,
scrollPreviewToTop,
focusPreviewEditableAtLine
})
const {
recordEditorHistory,
restoreEditorHistory
} = useAdminMarkdownHistory({
value: markdownValue,
lastSelectionState,
restoreFocusAfterHistory: restoreEditorFocusAfterHistory
})
/**
* 선택 영역을 지정 문자열로 교체한다.
* @param {string} replacement - 대체 문자열

View File

@@ -0,0 +1,131 @@
import { nextTick, unref } from 'vue'
/**
* 관리자 Markdown 에디터의 공통 포커스 실행을 관리한다.
* @param {Object} options - 포커스 실행 옵션
* @param {import('vue').Ref<'write'|'preview'|string>} options.activeMode - 현재 에디터 모드
* @param {import('vue').Ref<string>} options.value - Markdown 본문
* @param {import('vue').Ref<number>} options.activeLogicalLineIndex - 현재 논리 줄
* @param {() => HTMLTextAreaElement|null} options.getWriteTextareaElement - 작성 textarea 반환 함수
* @param {(offset: number) => number} options.getLineIndexAtOffset - 오프셋 기준 줄 계산 함수
* @param {(lineIndex: number) => number} options.getLineStartOffset - 줄 시작 오프셋 계산 함수
* @param {(start: number, end?: number, options?: { preserveScroll?: boolean }) => void} options.setTextareaSelection - textarea 선택 복원 함수
* @param {(lineIndex: number) => void} options.focusTextareaAtLine - 지정 줄 textarea 포커스 함수
* @param {() => void} options.refreshCaretLogicalLine - 현재 커서 줄 갱신 함수
* @param {() => void} options.scrollPreviewToTop - 라이브 미리보기 상단 이동 함수
* @param {(line: number, childIndex: number, position: string, offset?: number, scrollAlign?: string) => void} options.focusPreviewEditableAtLine - 라이브 편집 줄 포커스 함수
* @returns {{
* focusFirstBlock: () => void,
* getInitialContentFocusLineIndex: () => number,
* ensureContentFocusLineExists: (lineIndex: number) => number,
* restoreEditorFocusAfterHistory: (selection?: { start: number, end: number }|null) => void
* }} 공통 포커스 액션
*/
export const useAdminMarkdownEditorFocusActions = (options) => {
/**
* 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
* @returns {number} 줄 번호(0-based)
*/
const getInitialContentFocusLineIndex = () => {
const lines = String(unref(options.value) ?? '').split('\n')
if (!lines.length) {
return 0
}
const firstLine = lines[0]?.trim() ?? ''
if (/^#{1,6}\s+/.test(firstLine)) {
return 1
}
return 0
}
/**
* 본문 작성 시작 줄이 없으면 빈 줄을 추가한다.
* @param {number} lineIndex - 포커스할 줄
* @returns {number} 보정된 줄 번호
*/
const ensureContentFocusLineExists = (lineIndex) => {
const value = String(unref(options.value) ?? '')
const lines = value.split('\n')
if (lineIndex < lines.length) {
return lineIndex
}
options.value.value = lines.length ? `${value}\n` : ''
return lines.length
}
/**
* 본문 에디터에 포커스한다. 현재 모드(write/preview)를 유지한다.
* @returns {void}
*/
const focusFirstBlock = () => {
const focusLine = ensureContentFocusLineExists(getInitialContentFocusLineIndex())
if (options.activeMode.value === 'preview') {
options.scrollPreviewToTop()
nextTick(() => {
nextTick(() => {
options.focusPreviewEditableAtLine(focusLine, 0, 'start')
})
})
return
}
nextTick(() => {
const textarea = options.getWriteTextareaElement()
const lineStart = options.getLineStartOffset(focusLine)
if (textarea) {
textarea.focus()
textarea.setSelectionRange(lineStart, lineStart)
textarea.scrollTop = 0
}
options.activeLogicalLineIndex.value = focusLine
options.refreshCaretLogicalLine()
})
}
/**
* 히스토리 복원 후 현재 모드에 맞게 포커스를 복원한다.
* @param {{ start: number, end: number }|null} [selection=null] - 복원할 선택 영역
* @returns {void}
*/
const restoreEditorFocusAfterHistory = (selection = null) => {
const focusLine = selection
? options.getLineIndexAtOffset(selection.start)
: options.activeLogicalLineIndex.value
if (options.activeMode.value === 'write') {
if (selection) {
options.setTextareaSelection(selection.start, selection.end, { preserveScroll: true })
return
}
options.focusTextareaAtLine(focusLine)
return
}
nextTick(() => {
const offset = selection
? Math.max(0, selection.start - options.getLineStartOffset(focusLine))
: 0
options.focusPreviewEditableAtLine(focusLine, 0, 'auto', offset, 'center')
})
}
return {
focusFirstBlock,
getInitialContentFocusLineIndex,
ensureContentFocusLineExists,
restoreEditorFocusAfterHistory
}
}

View File

@@ -1,3 +1,7 @@
## 2026-07-29 v1.11.124 — 공통 포커스 실행은 에디터 본문 밖으로 둔다
관리자 Markdown 에디터에는 작성 모드 textarea와 라이브 미리보기 편집 줄 사이에서 첫 포커스를 잡고, 히스토리 복원 후 선택 위치나 줄 오프셋을 되돌리는 실행 코드가 남아 있었다. 이 코드는 본문 편집 규칙보다 모드별 포커스 적용에 가까우므로 에디터 본체에 두면 선택 편집·미디어 삽입 로직과 다시 섞인다. 따라서 `useAdminMarkdownEditorFocusActions`가 첫 줄 보정, 첫 블록 포커스, 히스토리 복원 포커스를 맡고 에디터는 DOM 실행 콜백만 제공한다.
## 2026-07-29 v1.11.123 — 슬래시 명령 실행은 전용 composable로 연결한다
슬래시 명령의 후보 계산과 부수 효과 계획은 이미 helper로 분리되어 있었지만, 실제 적용 단계인 원문 줄 제거, 미디어 모달 열기, textarea 선택 복원, 라이브 미리보기 포커스 예약은 에디터 컴포넌트에 남아 있었다. 이 실행 연결부가 컴포넌트에 남으면 슬래시 메뉴 상태와 DOM 포커스 복원이 계속 한 파일에서 커진다. 따라서 `useAdminMarkdownSlashCommandActions`가 명령 적용 실행을 맡고, 에디터는 줄 교체·모달·포커스 콜백만 주입한다.

View File

@@ -28,6 +28,7 @@
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·취소 당시 줄 내용 기준 억제·키보드 탐색 타이머 관리 |
| composables/useAdminMarkdownSlashMenuPosition.js | 관리자 Markdown 에디터 슬래시 메뉴 미리보기 줄·textarea caret 기준 위치 계산, scroll·resize 리스너 수명, 선택 항목 스크롤 후처리 관리 |
| composables/useAdminMarkdownSlashCommandActions.js | 관리자 Markdown 에디터 슬래시 명령 적용 실행, 미디어 모달 연결, textarea·미리보기 포커스 복원 관리 |
| composables/useAdminMarkdownEditorFocusActions.js | 관리자 Markdown 에디터 첫 본문 포커스, 제목 다음 줄 보정, 히스토리 복원 후 작성·라이브 모드 포커스 적용 관리 |
| composables/useAdminMarkdownHistory.js | 관리자 Markdown 에디터 undo/redo 스택·기록 그룹화·복원 중 상태 관리 |
| composables/useAdminMarkdownFocusBridge.js | 관리자 Markdown 에디터 작성/라이브 모드 전환의 pending 포커스 상태, 위치 기억, 전환 후 textarea·라이브 편집 줄 복원 콜백 실행 순서 관리 |
| composables/useAdminMarkdownPreviewFocus.js | 관리자 Markdown 에디터 라이브 미리보기 표면 DOM 접근, 현재 source line 계산, 상단 스크롤, 전체 선택, 줄 포커스 실행 관리 |
@@ -162,6 +163,7 @@
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태·줄 내용 기준 억제 관리 단위 테스트 |
| tests/useAdminMarkdownSlashMenuPosition.test.js | 관리자 Markdown 에디터 슬래시 메뉴 위치 composable의 좌표 갱신·리스너 수명·하이라이트 스크롤 단위 테스트 |
| tests/useAdminMarkdownSlashCommandActions.test.js | 관리자 Markdown 에디터 슬래시 명령 실행 composable의 미디어 명령·줄 삽입·포커스 복원·취소 처리 단위 테스트 |
| tests/useAdminMarkdownEditorFocusActions.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·광고 삽입·하단 삽입 줄 단위 테스트 |
@@ -276,7 +278,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 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달 선택 결과 본문 삽입(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `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 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 슬래시 메뉴 위치·슬래시 명령 실행·블록 패널 옵션 적용·라이브 미리보기 이벤트 적용 composable 연결, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달 선택 결과 본문 삽입(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `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 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 슬래시 메뉴 위치·슬래시 명령 실행·공통 포커스·블록 패널 옵션 적용·라이브 미리보기 이벤트 적용 composable 연결, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, 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 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |

View File

@@ -780,6 +780,7 @@ components/content/
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 `<strong>`·`<em>` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. 인용·콜아웃 멀티라인 편집 영역도 일반 인라인 렌더러를 사용해 색상·굵게·링크 등 인라인 문법을 표시한다. **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 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
- 관리자 Markdown 슬래시 메뉴 위치는 미리보기 모드에서 현재 source line DOM, 소스 모드에서 textarea caret 좌표를 기준으로 계산한다. 뷰포트 밖으로 넘치는 경우 위쪽·좌우를 보정하고, 메뉴 노출 중 scroll·resize 리스너와 선택 항목 스크롤은 `useAdminMarkdownSlashMenuPosition`에서 관리한다.
- 관리자 Markdown 슬래시 명령 적용은 `useAdminMarkdownSlashCommandActions`에서 실행한다. 미디어 명령은 슬래시 원문 줄을 제거하고 미디어 모달을 열며, 일반 줄 삽입 명령은 작성 모드에서 textarea 커서, 라이브 모드에서 미리보기 편집 줄 포커스를 복원한다.
- 관리자 Markdown 첫 본문 포커스와 히스토리 복원 후 모드별 포커스 적용은 `useAdminMarkdownEditorFocusActions`에서 관리한다. 첫 줄이 제목이면 본문 시작 줄을 우선 포커스하고, 작성 모드에서는 textarea 선택 위치, 라이브 모드에서는 source line과 오프셋을 복원한다.
- 라이브 모드 공통 편집기는 한글 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

@@ -3,7 +3,7 @@
## v1.11 내부 개선
- 거대 컴포넌트 책임 분리 진행.
- `components/admin/AdminMarkdownEditor.vue`: 소스/라이브 공통 포커스 후처리를 단계적으로 composable 또는 하위 컴포넌트로 분리.
- `components/admin/AdminMarkdownEditor.vue`: 소스 모드 선택 편집 실행부와 미디어 삽입 적용 연결부를 단계적으로 composable 또는 하위 컴포넌트로 분리.
- 미디어 카탈로그 전환 코드 수명 정리.
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.124
- 관리자 Markdown 에디터의 첫 본문 포커스와 히스토리 복원 후 포커스 실행을 `useAdminMarkdownEditorFocusActions` composable로 분리.
- 에디터 컴포넌트는 textarea·미리보기 포커스 실행 콜백만 주입하고, 모드별 포커스 대상 계산과 첫 줄 보정은 composable에 위임하도록 정리.
- 첫 포커스 줄 계산, 작성/라이브 첫 블록 포커스, 히스토리 복원 선택·오프셋 복원 단위 테스트 추가.
## v1.11.123
- 관리자 Markdown 에디터의 슬래시 명령 적용 실행을 `useAdminMarkdownSlashCommandActions` composable로 분리.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -0,0 +1,143 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { nextTick, ref } from 'vue'
import { useAdminMarkdownEditorFocusActions } from '../composables/useAdminMarkdownEditorFocusActions.js'
/**
* 테스트용 에디터 포커스 액션 상태를 만든다.
* @param {Object} [options] - 테스트 옵션
* @returns {Object} 테스트 상태
*/
const createEditorFocusActionsState = (options = {}) => {
const activeMode = ref(options.activeMode ?? 'write')
const value = ref(options.value ?? '첫 줄\n둘째 줄')
const activeLogicalLineIndex = ref(options.activeLogicalLineIndex ?? 0)
const calls = {
focusPreview: [],
focusTextareaAtLine: [],
refreshCaret: 0,
scrollPreviewToTop: 0,
setTextareaSelection: []
}
const textarea = options.textarea ?? {
focused: false,
scrollTop: 48,
selection: null,
focus() {
this.focused = true
},
setSelectionRange(start, end) {
this.selection = { start, end }
}
}
const actions = useAdminMarkdownEditorFocusActions({
activeMode,
value,
activeLogicalLineIndex,
getWriteTextareaElement: () => textarea,
getLineIndexAtOffset: (offset) => String(value.value).slice(0, offset).split('\n').length - 1,
getLineStartOffset: (lineIndex) => {
const lines = String(value.value).split('\n')
return lines.slice(0, lineIndex).join('\n').length + (lineIndex > 0 ? 1 : 0)
},
setTextareaSelection: (start, end, selectionOptions) => {
calls.setTextareaSelection.push({ start, end, options: selectionOptions })
},
focusTextareaAtLine: (lineIndex) => calls.focusTextareaAtLine.push(lineIndex),
refreshCaretLogicalLine: () => {
calls.refreshCaret += 1
},
scrollPreviewToTop: () => {
calls.scrollPreviewToTop += 1
},
focusPreviewEditableAtLine: (...args) => calls.focusPreview.push(args)
})
return {
actions,
activeLogicalLineIndex,
activeMode,
calls,
textarea,
value
}
}
test('관리자 Markdown 첫 포커스 줄은 첫 줄 제목 다음 줄을 선택한다', () => {
const state = createEditorFocusActionsState({
value: '# 제목'
})
assert.equal(state.actions.getInitialContentFocusLineIndex(), 1)
assert.equal(state.actions.ensureContentFocusLineExists(1), 1)
assert.equal(state.value.value, '# 제목\n')
})
test('관리자 Markdown 첫 블록 포커스는 작성 모드 textarea를 문서 상단으로 맞춘다', async () => {
const state = createEditorFocusActionsState({
value: '# 제목',
activeMode: 'write'
})
state.actions.focusFirstBlock()
await nextTick()
assert.equal(state.value.value, '# 제목\n')
assert.deepEqual(state.textarea.selection, { start: 5, end: 5 })
assert.equal(state.textarea.scrollTop, 0)
assert.equal(state.textarea.focused, true)
assert.equal(state.activeLogicalLineIndex.value, 1)
assert.equal(state.calls.refreshCaret, 1)
})
test('관리자 Markdown 첫 블록 포커스는 라이브 모드에서 상단 이동 후 편집 줄을 선택한다', async () => {
const state = createEditorFocusActionsState({
value: '# 제목\n본문',
activeMode: 'preview'
})
state.actions.focusFirstBlock()
await nextTick()
await nextTick()
assert.equal(state.calls.scrollPreviewToTop, 1)
assert.deepEqual(state.calls.focusPreview, [[1, 0, 'start']])
})
test('관리자 Markdown 히스토리 복원 포커스는 작성 모드 선택 범위를 보존 스크롤로 복원한다', () => {
const state = createEditorFocusActionsState({
activeMode: 'write'
})
state.actions.restoreEditorFocusAfterHistory({ start: 3, end: 5 })
assert.deepEqual(state.calls.setTextareaSelection, [{
start: 3,
end: 5,
options: { preserveScroll: true }
}])
assert.deepEqual(state.calls.focusTextareaAtLine, [])
})
test('관리자 Markdown 히스토리 복원 포커스는 라이브 모드 줄과 오프셋을 복원한다', async () => {
const state = createEditorFocusActionsState({
value: '첫 줄\n둘째 줄',
activeMode: 'preview'
})
state.actions.restoreEditorFocusAfterHistory({ start: 6, end: 6 })
await nextTick()
assert.deepEqual(state.calls.focusPreview, [[1, 0, 'auto', 2, 'center']])
})
test('관리자 Markdown 히스토리 복원 선택이 없으면 현재 논리 줄로 작성 포커스를 보낸다', () => {
const state = createEditorFocusActionsState({
activeMode: 'write',
activeLogicalLineIndex: 2
})
state.actions.restoreEditorFocusAfterHistory()
assert.deepEqual(state.calls.focusTextareaAtLine, [2])
})