공개 본문 fenced 블록 편집 분리
This commit is contained in:
@@ -18,8 +18,7 @@ import {
|
|||||||
parseOrderedListMarker,
|
parseOrderedListMarker,
|
||||||
stripListMarker
|
stripListMarker
|
||||||
} from '../../lib/markdown-live-edit.js'
|
} from '../../lib/markdown-live-edit.js'
|
||||||
import { buildCodeBlockLines, buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
|
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
|
||||||
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
|
|
||||||
import {
|
import {
|
||||||
buildCalloutOpenerLine,
|
buildCalloutOpenerLine,
|
||||||
CALLOUT_BACKGROUND_OPTIONS
|
CALLOUT_BACKGROUND_OPTIONS
|
||||||
@@ -35,6 +34,7 @@ import { useContentMarkdownLightbox } from '../../composables/useContentMarkdown
|
|||||||
import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js'
|
import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js'
|
||||||
import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdownImageDrag.js'
|
import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdownImageDrag.js'
|
||||||
import { useContentMarkdownLiveSelection } from '../../composables/useContentMarkdownLiveSelection.js'
|
import { useContentMarkdownLiveSelection } from '../../composables/useContentMarkdownLiveSelection.js'
|
||||||
|
import { useContentMarkdownFencedBlocks } from '../../composables/useContentMarkdownFencedBlocks.js'
|
||||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||||
import ProseHtml from './ProseHtml.vue'
|
import ProseHtml from './ProseHtml.vue'
|
||||||
@@ -168,6 +168,23 @@ const {
|
|||||||
onContentReplace: (payload) => emit('content-replace', payload)
|
onContentReplace: (payload) => emit('content-replace', payload)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
getCodeLineNumbers,
|
||||||
|
onCodeBlockCommit,
|
||||||
|
onCodeBlockInsertBelow,
|
||||||
|
onCalloutBlockCommit,
|
||||||
|
onCalloutBlockInsertBelow,
|
||||||
|
onToggleBlockCommit,
|
||||||
|
onToggleBlockInsertBelow
|
||||||
|
} = useContentMarkdownFencedBlocks({
|
||||||
|
content: computed(() => props.content),
|
||||||
|
interactive: computed(() => props.interactive),
|
||||||
|
pendingFocusPosition,
|
||||||
|
pendingFocusOffset,
|
||||||
|
commitInlineBlockLines: (block, replacementLines) => commitInlineBlockLines(block, replacementLines),
|
||||||
|
insertBelowBlock: (block, options) => onInsertBelowBlock(block, options)
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 빈 줄 공백 블록 높이를 반환한다.
|
* 빈 줄 공백 블록 높이를 반환한다.
|
||||||
* @param {Object} block - 렌더링 블록
|
* @param {Object} block - 렌더링 블록
|
||||||
@@ -437,18 +454,6 @@ const createEmptyListMarkerLine = (block, itemIndex) => {
|
|||||||
return '- '
|
return '- '
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 코드 블록 줄 번호 목록을 만든다.
|
|
||||||
* @param {Object} block - 코드 블록
|
|
||||||
* @returns {number[]} 줄 번호(1부터)
|
|
||||||
*/
|
|
||||||
const getCodeLineNumbers = (block) => {
|
|
||||||
const text = String(block.text ?? '')
|
|
||||||
const lineCount = text.length ? text.split('\n').length : 1
|
|
||||||
|
|
||||||
return Array.from({ length: lineCount }, (_, index) => index + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인라인 편집 결과를 마크다운 줄로 반영한다.
|
* 인라인 편집 결과를 마크다운 줄로 반영한다.
|
||||||
* @param {Object} block - 블록
|
* @param {Object} block - 블록
|
||||||
@@ -467,33 +472,6 @@ const commitInlineBlockLines = (block, replacementLines) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 코드 블록 편집 반영
|
|
||||||
* @param {Object} block - 코드 블록
|
|
||||||
* @param {string[]} replacementLines - 마크다운 줄
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onCodeBlockCommit = (block, replacementLines) => {
|
|
||||||
commitInlineBlockLines(block, replacementLines)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 코드 블록 마지막 줄에서 아래로 이탈 — 본문 저장 후 다음 문단 삽입
|
|
||||||
* @param {Object} block - 코드 블록
|
|
||||||
* @param {string|Object} payload - insert-below 페이로드
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onCodeBlockInsertBelow = (block, payload) => {
|
|
||||||
const { value } = normalizeInsertBelowPayload(payload)
|
|
||||||
|
|
||||||
onCodeBlockCommit(block, buildCodeBlockLines({
|
|
||||||
language: block.codeLanguage,
|
|
||||||
showLineNumbers: block.codeShowLineNumbers,
|
|
||||||
body: value
|
|
||||||
}))
|
|
||||||
onInsertBelowBlock(block, { lines: [''] })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 블록 위에 기본 문단을 삽입한다.
|
* 블록 위에 기본 문단을 삽입한다.
|
||||||
* @param {Object} block - 기준 블록
|
* @param {Object} block - 기준 블록
|
||||||
@@ -511,65 +489,6 @@ const onInsertAboveBlock = (block) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 콜아웃 편집 반영
|
|
||||||
* @param {Object} block - 콜아웃 블록
|
|
||||||
* @param {string[]} replacementLines - 마크다운 줄
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onCalloutBlockCommit = (block, replacementLines) => {
|
|
||||||
commitInlineBlockLines(block, replacementLines)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 콜아웃 마지막 줄에서 아래로 이탈한다.
|
|
||||||
* @param {Object} block - 콜아웃 블록
|
|
||||||
* @param {string|Object} payload - insert-below 페이로드
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onCalloutBlockInsertBelow = (block, payload) => {
|
|
||||||
const { value } = normalizeInsertBelowPayload(payload)
|
|
||||||
const openingLine = getMarkdownLine(block.meta.startLine)
|
|
||||||
|
|
||||||
onCalloutBlockCommit(block, [
|
|
||||||
openingLine,
|
|
||||||
...String(value ?? '').replace(/\r/g, '').split('\n'),
|
|
||||||
':::'
|
|
||||||
])
|
|
||||||
pendingFocusPosition.value = 'start'
|
|
||||||
pendingFocusOffset.value = 0
|
|
||||||
onInsertBelowBlock(block, { lines: [''] })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 토글 편집 반영
|
|
||||||
* @param {Object} block - 토글 블록
|
|
||||||
* @param {string[]} replacementLines - 마크다운 줄
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onToggleBlockCommit = (block, replacementLines) => {
|
|
||||||
commitInlineBlockLines(block, replacementLines)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 토글 본문 마지막 줄에서 아래로 이탈
|
|
||||||
* @param {Object} block - 토글 블록
|
|
||||||
* @param {string|Object} payload - insert-below 페이로드
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const onToggleBlockInsertBelow = (block, payload) => {
|
|
||||||
const { value } = normalizeInsertBelowPayload(payload)
|
|
||||||
|
|
||||||
onToggleBlockCommit(block, buildToggleBlockLines({
|
|
||||||
title: block.title,
|
|
||||||
defaultOpen: block.defaultOpen,
|
|
||||||
body: value
|
|
||||||
}))
|
|
||||||
pendingFocusPosition.value = 'start'
|
|
||||||
pendingFocusOffset.value = 0
|
|
||||||
onInsertBelowBlock(block, { lines: [''] })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onParagraphInlineCommit = (block, text) => {
|
const onParagraphInlineCommit = (block, text) => {
|
||||||
const value = String(text ?? '').replace(/\r/g, '')
|
const value = String(text ?? '').replace(/\r/g, '')
|
||||||
const lines = value.split('\n').map((line) => line.trimEnd())
|
const lines = value.split('\n').map((line) => line.trimEnd())
|
||||||
|
|||||||
179
composables/useContentMarkdownFencedBlocks.js
Normal file
179
composables/useContentMarkdownFencedBlocks.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { unref } from 'vue'
|
||||||
|
import { buildCodeBlockLines } from '../lib/markdown-code-block.js'
|
||||||
|
import { buildToggleBlockLines } from '../lib/markdown-toggle.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 라이브 Markdown fenced 블록 편집 이벤트 상태를 만든다.
|
||||||
|
* @param {Object} options - fenced 블록 편집 옵션
|
||||||
|
* @param {import('vue').Ref<boolean>|boolean} options.interactive - 라이브 편집 여부
|
||||||
|
* @param {import('vue').Ref<string>|string} options.content - Markdown 본문
|
||||||
|
* @param {import('vue').Ref<'start'|'end'|'auto'>} options.pendingFocusPosition - 다음 포커스 커서 위치
|
||||||
|
* @param {import('vue').Ref<number|null>} options.pendingFocusOffset - 다음 포커스 오프셋
|
||||||
|
* @param {(block: Object, replacementLines: string[]) => void} options.commitInlineBlockLines - 블록 줄 반영 콜백
|
||||||
|
* @param {(block: Object, options?: { lines?: string[] }) => void} options.insertBelowBlock - 블록 아래 삽입 콜백
|
||||||
|
* @returns {Object} fenced 블록 이벤트 핸들러
|
||||||
|
*/
|
||||||
|
export const useContentMarkdownFencedBlocks = (options) => {
|
||||||
|
/**
|
||||||
|
* insert-below 이벤트 페이로드를 정규화한다.
|
||||||
|
* @param {string|Object} payload - 페이로드
|
||||||
|
* @returns {{ value: string, raw: boolean, before: string, after: string, caretAtStart: boolean, caretAtEnd: boolean }}
|
||||||
|
*/
|
||||||
|
const normalizeInsertBelowPayload = (payload) => {
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
return {
|
||||||
|
value: payload,
|
||||||
|
raw: false,
|
||||||
|
before: '',
|
||||||
|
after: '',
|
||||||
|
caretAtStart: false,
|
||||||
|
caretAtEnd: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: String(payload?.value ?? ''),
|
||||||
|
raw: payload?.raw === true,
|
||||||
|
before: String(payload?.before ?? ''),
|
||||||
|
after: String(payload?.after ?? ''),
|
||||||
|
caretAtStart: payload?.caretAtStart === true,
|
||||||
|
caretAtEnd: payload?.caretAtEnd === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원본 마크다운 줄을 반환한다.
|
||||||
|
* @param {number} lineIndex - 줄 번호
|
||||||
|
* @returns {string} 줄 텍스트
|
||||||
|
*/
|
||||||
|
const getMarkdownLine = (lineIndex) => String(unref(options.content) || '').split('\n')[lineIndex] ?? ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인라인 편집 결과를 마크다운 줄로 반영한다.
|
||||||
|
* @param {Object} block - 블록
|
||||||
|
* @param {string[]} replacementLines - 대체 줄
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const commitInlineBlockLines = (block, replacementLines) => {
|
||||||
|
if (!unref(options.interactive)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.commitInlineBlockLines(block, replacementLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다음 외부 문단으로 포커스 위치를 준비한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const prepareNextParagraphFocus = () => {
|
||||||
|
options.pendingFocusPosition.value = 'start'
|
||||||
|
options.pendingFocusOffset.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 블록 줄 번호 목록을 만든다.
|
||||||
|
* @param {Object} block - 코드 블록
|
||||||
|
* @returns {number[]} 줄 번호(1부터)
|
||||||
|
*/
|
||||||
|
const getCodeLineNumbers = (block) => {
|
||||||
|
const text = String(block.text ?? '')
|
||||||
|
const lineCount = text.length ? text.split('\n').length : 1
|
||||||
|
|
||||||
|
return Array.from({ length: lineCount }, (_, index) => index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 블록 편집 반영
|
||||||
|
* @param {Object} block - 코드 블록
|
||||||
|
* @param {string[]} replacementLines - 마크다운 줄
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onCodeBlockCommit = (block, replacementLines) => {
|
||||||
|
commitInlineBlockLines(block, replacementLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 블록 마지막 줄에서 아래로 이탈한다.
|
||||||
|
* @param {Object} block - 코드 블록
|
||||||
|
* @param {string|Object} payload - insert-below 페이로드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onCodeBlockInsertBelow = (block, payload) => {
|
||||||
|
const { value } = normalizeInsertBelowPayload(payload)
|
||||||
|
|
||||||
|
onCodeBlockCommit(block, buildCodeBlockLines({
|
||||||
|
language: block.codeLanguage,
|
||||||
|
showLineNumbers: block.codeShowLineNumbers,
|
||||||
|
body: value
|
||||||
|
}))
|
||||||
|
options.insertBelowBlock(block, { lines: [''] })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 편집 반영
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @param {string[]} replacementLines - 마크다운 줄
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onCalloutBlockCommit = (block, replacementLines) => {
|
||||||
|
commitInlineBlockLines(block, replacementLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 마지막 줄에서 아래로 이탈한다.
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @param {string|Object} payload - insert-below 페이로드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onCalloutBlockInsertBelow = (block, payload) => {
|
||||||
|
const { value } = normalizeInsertBelowPayload(payload)
|
||||||
|
const openingLine = getMarkdownLine(block.meta.startLine)
|
||||||
|
|
||||||
|
onCalloutBlockCommit(block, [
|
||||||
|
openingLine,
|
||||||
|
...String(value ?? '').replace(/\r/g, '').split('\n'),
|
||||||
|
':::'
|
||||||
|
])
|
||||||
|
prepareNextParagraphFocus()
|
||||||
|
options.insertBelowBlock(block, { lines: [''] })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토글 편집 반영
|
||||||
|
* @param {Object} block - 토글 블록
|
||||||
|
* @param {string[]} replacementLines - 마크다운 줄
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onToggleBlockCommit = (block, replacementLines) => {
|
||||||
|
commitInlineBlockLines(block, replacementLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토글 본문 마지막 줄에서 아래로 이탈한다.
|
||||||
|
* @param {Object} block - 토글 블록
|
||||||
|
* @param {string|Object} payload - insert-below 페이로드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onToggleBlockInsertBelow = (block, payload) => {
|
||||||
|
const { value } = normalizeInsertBelowPayload(payload)
|
||||||
|
|
||||||
|
onToggleBlockCommit(block, buildToggleBlockLines({
|
||||||
|
title: block.title,
|
||||||
|
defaultOpen: block.defaultOpen,
|
||||||
|
body: value
|
||||||
|
}))
|
||||||
|
prepareNextParagraphFocus()
|
||||||
|
options.insertBelowBlock(block, { lines: [''] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCodeLineNumbers,
|
||||||
|
onCodeBlockCommit,
|
||||||
|
onCodeBlockInsertBelow,
|
||||||
|
onCalloutBlockCommit,
|
||||||
|
onCalloutBlockInsertBelow,
|
||||||
|
onToggleBlockCommit,
|
||||||
|
onToggleBlockInsertBelow
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
## 2026-07-27 v1.11.99 — 공개 Markdown fenced 블록 편집 이벤트를 분리한다
|
||||||
|
|
||||||
|
코드·콜아웃·토글은 모두 여는 줄과 닫는 줄을 보존하면서 본문을 갱신하고 아래 문단으로 이탈하는 흐름을 공유한다. 이를 `ContentMarkdownRenderer` 안에 각각의 이벤트 함수로 남겨두면 라이브 편집 템플릿과 마크다운 줄 직렬화 책임이 계속 섞인다. `useContentMarkdownFencedBlocks`로 분리해 렌더러는 블록 컴포넌트 이벤트를 연결하고, fenced 블록 문법 보존과 다음 문단 포커스 준비는 composable에서 담당하게 한다.
|
||||||
|
|
||||||
## 2026-07-27 v1.11.98 — 공개 Markdown 라이브 선택과 포커스 상태를 분리한다
|
## 2026-07-27 v1.11.98 — 공개 Markdown 라이브 선택과 포커스 상태를 분리한다
|
||||||
|
|
||||||
라이브 편집 모드의 포커스 복원, Shift 범위 선택, 전체 선택, 선택 삭제, line focus/blur 이벤트는 본문 블록 렌더링보다 편집 세션 상태에 가깝다. 이를 `ContentMarkdownRenderer` 안에 두면 블록 렌더링 이벤트와 DOM 선택 중개가 섞여 파일을 줄여도 todo의 실제 범위가 보이지 않는다. `useContentMarkdownLiveSelection`으로 옮겨 렌더러는 블록별 편집 이벤트 연결에 집중하고, 남은 todo는 실제 블록 편집 도메인 분리로 좁힌다.
|
라이브 편집 모드의 포커스 복원, Shift 범위 선택, 전체 선택, 선택 삭제, line focus/blur 이벤트는 본문 블록 렌더링보다 편집 세션 상태에 가깝다. 이를 `ContentMarkdownRenderer` 안에 두면 블록 렌더링 이벤트와 DOM 선택 중개가 섞여 파일을 줄여도 todo의 실제 범위가 보이지 않는다. `useContentMarkdownLiveSelection`으로 옮겨 렌더러는 블록별 편집 이벤트 연결에 집중하고, 남은 todo는 실제 블록 편집 도메인 분리로 좁힌다.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
|
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
|
||||||
| composables/useContentMarkdownImageDrag.js | 관리자 라이브 Markdown 본문의 단일 이미지·갤러리 드래그 상태, drop target 표시, 병합·삽입·분리·정렬 이벤트 중개 |
|
| composables/useContentMarkdownImageDrag.js | 관리자 라이브 Markdown 본문의 단일 이미지·갤러리 드래그 상태, drop target 표시, 병합·삽입·분리·정렬 이벤트 중개 |
|
||||||
| composables/useContentMarkdownLiveSelection.js | 관리자 라이브 Markdown 본문의 Selection Bridge, 포커스 복원, line focus/blur, 원문 편집 표시 상태 관리 |
|
| composables/useContentMarkdownLiveSelection.js | 관리자 라이브 Markdown 본문의 Selection Bridge, 포커스 복원, line focus/blur, 원문 편집 표시 상태 관리 |
|
||||||
|
| composables/useContentMarkdownFencedBlocks.js | 관리자 라이브 Markdown 코드·콜아웃·토글 fenced 블록 편집 이벤트와 아래 문단 삽입 상태 관리 |
|
||||||
| 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`)와 기본 설정값 통일 |
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@
|
|||||||
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
|
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
|
||||||
| tests/useContentMarkdownImageDrag.test.js | 공개 Markdown 라이브 이미지·갤러리 드래그 composable의 병합·삽입·분리·정렬 단위 테스트 |
|
| tests/useContentMarkdownImageDrag.test.js | 공개 Markdown 라이브 이미지·갤러리 드래그 composable의 병합·삽입·분리·정렬 단위 테스트 |
|
||||||
| tests/useContentMarkdownLiveSelection.test.js | 공개 Markdown 라이브 선택 composable의 포커스 요청·예약 포커스 초기화·line focus/blur 단위 테스트 |
|
| tests/useContentMarkdownLiveSelection.test.js | 공개 Markdown 라이브 선택 composable의 포커스 요청·예약 포커스 초기화·line focus/blur 단위 테스트 |
|
||||||
|
| tests/useContentMarkdownFencedBlocks.test.js | 공개 Markdown fenced 블록 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 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
|
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
|
||||||
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
|
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
|
||||||
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록은 `useContentMarkdownBlocks`에서 `lib/content-gallery-images.js` 기준으로 계산하고, 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다. 관리자 라이브 모드의 단일 이미지·갤러리 드래그 상태와 병합·삽입·분리·정렬 이벤트 중개는 `useContentMarkdownImageDrag`에서 관리한다. 라이브 모드의 선택 확장·전체 선택·선택 삭제·포커스 복원·line focus/blur 중개는 `useContentMarkdownLiveSelection`에서 관리한다.
|
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록은 `useContentMarkdownBlocks`에서 `lib/content-gallery-images.js` 기준으로 계산하고, 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다. 관리자 라이브 모드의 단일 이미지·갤러리 드래그 상태와 병합·삽입·분리·정렬 이벤트 중개는 `useContentMarkdownImageDrag`에서 관리한다. 라이브 모드의 선택 확장·전체 선택·선택 삭제·포커스 복원·line focus/blur 중개는 `useContentMarkdownLiveSelection`에서 관리한다. 코드·콜아웃·토글 fenced 블록의 라이브 편집 이벤트는 `useContentMarkdownFencedBlocks`에서 관리한다.
|
||||||
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
|
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
|
||||||
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
|
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
|
||||||
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
|
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- 거대 컴포넌트 책임 분리 진행.
|
- 거대 컴포넌트 책임 분리 진행.
|
||||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||||
- `components/content/ContentMarkdownRenderer.vue`: 남은 블록별 편집 이벤트(`onParagraph*`, `onList*`, `onQuote*`, `onCallout*`, `onToggle*`, `onCode*`)를 도메인별 helper/composable로 추가 분리.
|
- `components/content/ContentMarkdownRenderer.vue`: 남은 문단·목록·인용 편집 이벤트(`onParagraph*`, `onList*`, `onQuote*`)를 도메인별 helper/composable로 추가 분리.
|
||||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||||
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.99
|
||||||
|
|
||||||
|
- 공개 Markdown 렌더러의 코드·콜아웃·토글 fenced 블록 편집 이벤트를 `useContentMarkdownFencedBlocks` composable로 분리.
|
||||||
|
- fenced 블록 composable의 코드·콜아웃·토글 아래 문단 삽입 단위 테스트 추가.
|
||||||
|
- v1.11 내부 개선 todo에서 완료된 fenced 블록 편집 이벤트 분리 범위를 제외하고 남은 블록 이벤트 범위를 재정리.
|
||||||
|
|
||||||
## v1.11.98
|
## v1.11.98
|
||||||
|
|
||||||
- 공개 Markdown 렌더러의 라이브 선택·포커스 복원·line focus/blur·원문 편집 표시 상태를 `useContentMarkdownLiveSelection` composable로 분리.
|
- 공개 Markdown 렌더러의 라이브 선택·포커스 복원·line focus/blur·원문 편집 표시 상태를 `useContentMarkdownLiveSelection` composable로 분리.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.98",
|
"version": "1.11.99",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.98",
|
"version": "1.11.99",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.98",
|
"version": "1.11.99",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
104
tests/useContentMarkdownFencedBlocks.test.js
Normal file
104
tests/useContentMarkdownFencedBlocks.test.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useContentMarkdownFencedBlocks } from '../composables/useContentMarkdownFencedBlocks.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트용 fenced 블록 편집 상태를 만든다.
|
||||||
|
* @param {string} contentText - Markdown 본문
|
||||||
|
* @returns {{ calls: Object, pendingFocusPosition: import('vue').Ref<string>, pendingFocusOffset: import('vue').Ref<number|null>, state: Object }}
|
||||||
|
*/
|
||||||
|
const createFencedState = (contentText = '') => {
|
||||||
|
const calls = {
|
||||||
|
commits: [],
|
||||||
|
inserts: []
|
||||||
|
}
|
||||||
|
const pendingFocusPosition = ref('auto')
|
||||||
|
const pendingFocusOffset = ref(null)
|
||||||
|
const state = useContentMarkdownFencedBlocks({
|
||||||
|
content: ref(contentText),
|
||||||
|
interactive: ref(true),
|
||||||
|
pendingFocusPosition,
|
||||||
|
pendingFocusOffset,
|
||||||
|
commitInlineBlockLines: (block, replacementLines) => {
|
||||||
|
calls.commits.push({ block, replacementLines })
|
||||||
|
},
|
||||||
|
insertBelowBlock: (block, options) => {
|
||||||
|
calls.inserts.push({ block, options })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
pendingFocusPosition,
|
||||||
|
pendingFocusOffset,
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('fenced 코드 블록은 본문을 코드 펜스 줄로 저장하고 아래 문단을 삽입한다', () => {
|
||||||
|
const block = {
|
||||||
|
type: 'code',
|
||||||
|
codeLanguage: 'javascript',
|
||||||
|
codeShowLineNumbers: true,
|
||||||
|
text: 'a\nb',
|
||||||
|
meta: { startLine: 1, endLine: 4 }
|
||||||
|
}
|
||||||
|
const { calls, state } = createFencedState()
|
||||||
|
|
||||||
|
assert.deepEqual(state.getCodeLineNumbers(block), [1, 2])
|
||||||
|
|
||||||
|
state.onCodeBlockInsertBelow(block, { value: 'console.log(1)' })
|
||||||
|
|
||||||
|
assert.deepEqual(calls.commits[0].replacementLines, [
|
||||||
|
'```javascript line-numbers',
|
||||||
|
'console.log(1)',
|
||||||
|
'```'
|
||||||
|
])
|
||||||
|
assert.deepEqual(calls.inserts[0], { block, options: { lines: [''] } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fenced 콜아웃 블록은 기존 여는 줄을 유지하고 다음 문단 포커스를 준비한다', () => {
|
||||||
|
const block = {
|
||||||
|
type: 'callout',
|
||||||
|
meta: { startLine: 0, endLine: 3 }
|
||||||
|
}
|
||||||
|
const { calls, pendingFocusOffset, pendingFocusPosition, state } = createFencedState([
|
||||||
|
':::callout bg=blue title=안내',
|
||||||
|
'old',
|
||||||
|
':::'
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
state.onCalloutBlockInsertBelow(block, { value: '새 본문\n둘째 줄' })
|
||||||
|
|
||||||
|
assert.deepEqual(calls.commits[0].replacementLines, [
|
||||||
|
':::callout bg=blue title=안내',
|
||||||
|
'새 본문',
|
||||||
|
'둘째 줄',
|
||||||
|
':::'
|
||||||
|
])
|
||||||
|
assert.equal(pendingFocusPosition.value, 'start')
|
||||||
|
assert.equal(pendingFocusOffset.value, 0)
|
||||||
|
assert.deepEqual(calls.inserts[0], { block, options: { lines: [''] } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fenced 토글 블록은 제목과 기본 펼침 상태를 유지하고 다음 문단을 삽입한다', () => {
|
||||||
|
const block = {
|
||||||
|
type: 'toggle',
|
||||||
|
title: '자세히',
|
||||||
|
defaultOpen: true,
|
||||||
|
meta: { startLine: 0, endLine: 3 }
|
||||||
|
}
|
||||||
|
const { calls, pendingFocusOffset, pendingFocusPosition, state } = createFencedState()
|
||||||
|
|
||||||
|
state.onToggleBlockInsertBelow(block, '토글 본문')
|
||||||
|
|
||||||
|
assert.deepEqual(calls.commits[0].replacementLines, [
|
||||||
|
':::toggle open 자세히',
|
||||||
|
'토글 본문',
|
||||||
|
':::'
|
||||||
|
])
|
||||||
|
assert.equal(pendingFocusPosition.value, 'start')
|
||||||
|
assert.equal(pendingFocusOffset.value, 0)
|
||||||
|
assert.deepEqual(calls.inserts[0], { block, options: { lines: [''] } })
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user