공개 본문 인용 편집 분리
This commit is contained in:
@@ -16,10 +16,8 @@ import {
|
||||
} from '../../lib/markdown-live-edit.js'
|
||||
import { CALLOUT_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
|
||||
import {
|
||||
formatQuoteLine,
|
||||
getQuoteContentStartLine,
|
||||
getQuoteLineEntries,
|
||||
normalizeQuoteEditableValue
|
||||
getQuoteLineEntries
|
||||
} from '../../lib/markdown-quote.js'
|
||||
import { getGalleryRows } from '../../lib/content-gallery-images.js'
|
||||
import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
|
||||
@@ -29,6 +27,7 @@ import { useContentMarkdownLiveSelection } from '../../composables/useContentMar
|
||||
import { useContentMarkdownFencedBlocks } from '../../composables/useContentMarkdownFencedBlocks.js'
|
||||
import { useContentMarkdownParagraphs } from '../../composables/useContentMarkdownParagraphs.js'
|
||||
import { useContentMarkdownLists } from '../../composables/useContentMarkdownLists.js'
|
||||
import { useContentMarkdownQuotes } from '../../composables/useContentMarkdownQuotes.js'
|
||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
import ProseHtml from './ProseHtml.vue'
|
||||
@@ -199,6 +198,19 @@ const {
|
||||
insertBelowBlock: (block, options) => onInsertBelowBlock(block, options)
|
||||
})
|
||||
|
||||
const {
|
||||
onQuoteBlockCommit,
|
||||
onQuoteBlockInsertBelow,
|
||||
onQuoteBlockConvertToParagraph
|
||||
} = useContentMarkdownQuotes({
|
||||
content: computed(() => props.content),
|
||||
pendingFocusLine,
|
||||
pendingFocusPosition,
|
||||
pendingFocusOffset,
|
||||
commitInlineBlockLines: (block, replacementLines) => commitInlineBlockLines(block, replacementLines),
|
||||
insertBelowBlock: (block, options) => onInsertBelowBlock(block, options)
|
||||
})
|
||||
|
||||
/**
|
||||
* 빈 줄 공백 블록 높이를 반환한다.
|
||||
* @param {Object} block - 렌더링 블록
|
||||
@@ -400,16 +412,6 @@ const normalizeInsertBelowPayload = (payload) => {
|
||||
*/
|
||||
const getMarkdownLine = (lineIndex) => String(props.content || '').split('\n')[lineIndex] ?? ''
|
||||
|
||||
/**
|
||||
* 블록에 해당하는 원본 마크다운 줄 목록을 반환한다.
|
||||
* @param {Object} block - 블록
|
||||
* @returns {string[]} 줄 목록
|
||||
*/
|
||||
const getBlockSourceLines = (block) => String(props.content || '').split('\n').slice(
|
||||
block.meta.startLine,
|
||||
(block.meta.endLine ?? block.meta.startLine) + 1
|
||||
)
|
||||
|
||||
/**
|
||||
* 인라인 편집 결과를 마크다운 줄로 반영한다.
|
||||
* @param {Object} block - 블록
|
||||
@@ -680,142 +682,6 @@ const onHeadingInsertBelow = (block, payload) => {
|
||||
commitInlineBlockLines(block, [buildHeadingLine(block.level, value), ''])
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 줄 인라인 편집 반영
|
||||
* @param {Object} block - 인용 블록
|
||||
* @param {number} lineIndex - 줄 인덱스
|
||||
* @param {string|{ value: string, raw?: boolean }} payload - 편집 내용
|
||||
* @returns {void}
|
||||
*/
|
||||
const onQuoteLineInlineCommit = (block, lineIndex, payload) => {
|
||||
const { value, raw } = normalizeCommitPayload(payload)
|
||||
const sourceLines = String(props.content || '').split('\n').slice(
|
||||
block.meta.startLine,
|
||||
(block.meta.endLine ?? block.meta.startLine) + 1
|
||||
)
|
||||
const nextLines = [...sourceLines]
|
||||
|
||||
nextLines[lineIndex] = formatQuoteLine(value, raw)
|
||||
commitInlineBlockLines(block, nextLines)
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 줄 아래에 새 인용 줄을 삽입한다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @param {number} lineIndex - 줄 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const onQuoteLineInsertBelow = (block, lineIndex, payload) => {
|
||||
const { value, raw, before, after, caretAtStart } = normalizeInsertBelowPayload(payload)
|
||||
const nextLines = getBlockSourceLines(block)
|
||||
|
||||
if (caretAtStart && after.length) {
|
||||
nextLines[lineIndex] = formatQuoteLine(after, raw)
|
||||
nextLines.splice(lineIndex, 0, '> ')
|
||||
pendingFocusLine.value = block.meta.startLine + lineIndex
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, nextLines)
|
||||
return
|
||||
}
|
||||
|
||||
if (before.length && after.length) {
|
||||
nextLines[lineIndex] = formatQuoteLine(before, raw)
|
||||
nextLines.splice(lineIndex + 1, 0, formatQuoteLine(after, raw))
|
||||
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, nextLines)
|
||||
return
|
||||
}
|
||||
|
||||
if (lineIndex < nextLines.length) {
|
||||
nextLines[lineIndex] = formatQuoteLine(value, raw)
|
||||
}
|
||||
|
||||
nextLines.splice(lineIndex + 1, 0, '> ')
|
||||
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, nextLines)
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 블록 원본에서 옵션 선언 줄을 반환한다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @returns {string[]} 옵션 선언 줄
|
||||
*/
|
||||
const getQuoteOptionLines = (block) => {
|
||||
const contentStartLine = getQuoteContentStartLine(block)
|
||||
|
||||
if (contentStartLine <= block.meta.startLine) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getBlockSourceLines(block).slice(0, contentStartLine - block.meta.startLine)
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 본문 문자열을 마크다운 줄로 변환한다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||
* @returns {string[]} 인용 마크다운 줄
|
||||
*/
|
||||
const buildQuoteBlockLines = (block, payload) => {
|
||||
const value = typeof payload === 'string'
|
||||
? payload
|
||||
: String(payload?.value ?? '')
|
||||
const bodyLines = String(value ?? '').replace(/\r/g, '').split('\n')
|
||||
const normalizedBodyLines = bodyLines.length ? bodyLines : ['']
|
||||
|
||||
return [
|
||||
...getQuoteOptionLines(block),
|
||||
...normalizedBodyLines.map((line) => formatQuoteLine(line, false))
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 블록 편집 반영
|
||||
* @param {Object} block - 인용 블록
|
||||
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onQuoteBlockCommit = (block, payload) => {
|
||||
const normalizedPayload = typeof payload === 'object' && payload !== null
|
||||
? { ...payload, value: normalizeQuoteEditableValue(payload.value) }
|
||||
: normalizeQuoteEditableValue(payload)
|
||||
|
||||
commitInlineBlockLines(block, buildQuoteBlockLines(block, normalizedPayload))
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 블록 마지막 줄에서 아래로 이탈한다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @param {string|Object} payload - insert-below 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onQuoteBlockInsertBelow = (block, payload) => {
|
||||
const { value } = normalizeInsertBelowPayload(payload)
|
||||
|
||||
onQuoteBlockCommit(block, value)
|
||||
pendingFocusPosition.value = 'start'
|
||||
pendingFocusOffset.value = 0
|
||||
onInsertBelowBlock(block, { lines: [''] })
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 블록을 일반 문단 줄로 되돌린다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @param {string|Object} payload - 편집 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onQuoteBlockConvertToParagraph = (block, payload) => {
|
||||
const { value } = normalizeCommitPayload(payload)
|
||||
const lines = normalizeQuoteEditableValue(value).split('\n')
|
||||
const replacementLines = lines.length ? lines : ['']
|
||||
|
||||
pendingFocusLine.value = block.meta.startLine
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, replacementLines)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 모드에서 현재 줄을 삭제한다.
|
||||
* @param {number} lineIndex - 줄 번호
|
||||
|
||||
158
composables/useContentMarkdownQuotes.js
Normal file
158
composables/useContentMarkdownQuotes.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { unref } from 'vue'
|
||||
import {
|
||||
formatQuoteLine,
|
||||
getQuoteContentStartLine,
|
||||
normalizeQuoteEditableValue
|
||||
} from '../lib/markdown-quote.js'
|
||||
|
||||
/**
|
||||
* 관리자 라이브 Markdown 인용 편집 이벤트 상태를 만든다.
|
||||
* @param {Object} options - 인용 편집 옵션
|
||||
* @param {import('vue').Ref<string>|string} options.content - Markdown 본문
|
||||
* @param {import('vue').Ref<number|null>} options.pendingFocusLine - 다음 포커스 줄
|
||||
* @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} 인용 이벤트 핸들러
|
||||
*/
|
||||
export const useContentMarkdownQuotes = (options) => {
|
||||
/**
|
||||
* commit 이벤트 페이로드를 정규화한다.
|
||||
* @param {string|{ value: string, raw?: boolean }} payload - 페이로드
|
||||
* @returns {{ value: string, raw: boolean }}
|
||||
*/
|
||||
const normalizeCommitPayload = (payload) => {
|
||||
if (typeof payload === 'string') {
|
||||
return { value: payload, raw: false }
|
||||
}
|
||||
|
||||
return {
|
||||
value: String(payload?.value ?? ''),
|
||||
raw: payload?.raw === true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {Object} block - 블록
|
||||
* @returns {string[]} 줄 목록
|
||||
*/
|
||||
const getBlockSourceLines = (block) => String(unref(options.content) || '').split('\n').slice(
|
||||
block.meta.startLine,
|
||||
(block.meta.endLine ?? block.meta.startLine) + 1
|
||||
)
|
||||
|
||||
/**
|
||||
* 인용 블록 원본에서 옵션 선언 줄을 반환한다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @returns {string[]} 옵션 선언 줄
|
||||
*/
|
||||
const getQuoteOptionLines = (block) => {
|
||||
const contentStartLine = getQuoteContentStartLine(block)
|
||||
|
||||
if (contentStartLine <= block.meta.startLine) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getBlockSourceLines(block).slice(0, contentStartLine - block.meta.startLine)
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 본문 문자열을 마크다운 줄로 변환한다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||
* @returns {string[]} 인용 마크다운 줄
|
||||
*/
|
||||
const buildQuoteBlockLines = (block, payload) => {
|
||||
const value = typeof payload === 'string'
|
||||
? payload
|
||||
: String(payload?.value ?? '')
|
||||
const bodyLines = String(value ?? '').replace(/\r/g, '').split('\n')
|
||||
const normalizedBodyLines = bodyLines.length ? bodyLines : ['']
|
||||
|
||||
return [
|
||||
...getQuoteOptionLines(block),
|
||||
...normalizedBodyLines.map((line) => formatQuoteLine(line, false))
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 블록 편집 반영
|
||||
* @param {Object} block - 인용 블록
|
||||
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onQuoteBlockCommit = (block, payload) => {
|
||||
const normalizedPayload = typeof payload === 'object' && payload !== null
|
||||
? { ...payload, value: normalizeQuoteEditableValue(payload.value) }
|
||||
: normalizeQuoteEditableValue(payload)
|
||||
|
||||
options.commitInlineBlockLines(block, buildQuoteBlockLines(block, normalizedPayload))
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 블록 마지막 줄에서 아래로 이탈한다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @param {string|Object} payload - insert-below 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onQuoteBlockInsertBelow = (block, payload) => {
|
||||
const { value } = normalizeInsertBelowPayload(payload)
|
||||
|
||||
onQuoteBlockCommit(block, value)
|
||||
options.pendingFocusPosition.value = 'start'
|
||||
options.pendingFocusOffset.value = 0
|
||||
options.insertBelowBlock(block, { lines: [''] })
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 블록을 일반 문단 줄로 되돌린다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @param {string|Object} payload - 편집 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onQuoteBlockConvertToParagraph = (block, payload) => {
|
||||
const { value } = normalizeCommitPayload(payload)
|
||||
const lines = normalizeQuoteEditableValue(value).split('\n')
|
||||
const replacementLines = lines.length ? lines : ['']
|
||||
|
||||
options.pendingFocusLine.value = block.meta.startLine
|
||||
options.pendingFocusPosition.value = 'start'
|
||||
options.commitInlineBlockLines(block, replacementLines)
|
||||
}
|
||||
|
||||
return {
|
||||
buildQuoteBlockLines,
|
||||
onQuoteBlockCommit,
|
||||
onQuoteBlockInsertBelow,
|
||||
onQuoteBlockConvertToParagraph
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
## 2026-07-27 v1.11.102 — 공개 Markdown 인용 편집 이벤트를 분리한다
|
||||
|
||||
인용 블록 편집은 옵션 선언 줄 보존, 브라우저 자동 보정으로 생기는 `>. ` 접두 정리, 마지막 줄 아래 문단 이탈, Backspace 기반 일반 문단 복귀를 함께 처리한다. 이 흐름을 렌더러 내부에 남기면 공개 본문 표시와 관리자 라이브 편집 규칙이 계속 섞이므로 `useContentMarkdownQuotes`로 분리한다. 이전 줄 단위 인용 편집 함수는 현재 템플릿에서 사용하지 않으므로 제거해 실제 동작 경로만 테스트 대상으로 유지한다.
|
||||
|
||||
## 2026-07-27 v1.11.101 — 공개 Markdown 목록 편집 이벤트를 분리한다
|
||||
|
||||
목록 편집은 항목 내용 저장뿐 아니라 원본 번호 유지, raw 마커 보존, Enter로 항목 분리·목록 탈출·아래 문단 삽입을 함께 다룬다. 이를 `ContentMarkdownRenderer` 안에 계속 두면 템플릿과 목록 문법 직렬화가 섞이므로 `useContentMarkdownLists`로 분리한다. 빈 목록 마커는 `- `처럼 본문이 없는 줄도 정확히 목록으로 판별해야 하므로 공통 목록 마커 유틸을 보정해 라이브 편집의 목록 탈출 동작을 테스트 가능한 단위로 유지한다.
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
| composables/useContentMarkdownFencedBlocks.js | 관리자 라이브 Markdown 코드·콜아웃·토글 fenced 블록 편집 이벤트와 아래 문단 삽입 상태 관리 |
|
||||
| composables/useContentMarkdownParagraphs.js | 관리자 라이브 Markdown 문단·스페이서 편집 이벤트와 문단 Enter 단축 변환 관리 |
|
||||
| composables/useContentMarkdownLists.js | 관리자 라이브 Markdown 목록 번호 계산과 항목 커밋·분리·탈출 이벤트 관리 |
|
||||
| composables/useContentMarkdownQuotes.js | 관리자 라이브 Markdown 인용 블록 커밋·아래 이탈·일반 문단 복귀 이벤트 관리 |
|
||||
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
|
||||
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
|
||||
|
||||
@@ -143,6 +144,7 @@
|
||||
| tests/useContentMarkdownFencedBlocks.test.js | 공개 Markdown fenced 블록 composable의 코드·콜아웃·토글 아래 문단 삽입 단위 테스트 |
|
||||
| tests/useContentMarkdownParagraphs.test.js | 공개 Markdown 문단 composable의 커밋·분리·인용/코드/콜아웃 단축 변환 단위 테스트 |
|
||||
| tests/useContentMarkdownLists.test.js | 공개 Markdown 목록 composable의 번호 유지·항목 커밋·Enter 분기 단위 테스트 |
|
||||
| tests/useContentMarkdownQuotes.test.js | 공개 Markdown 인용 composable의 옵션 줄 보존·접두 정리·아래 이탈·문단 복귀 단위 테스트 |
|
||||
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
|
||||
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
|
||||
| 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`에 생성한다.
|
||||
- 카드 썸네일은 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`에서 관리한다. 코드·콜아웃·토글 fenced 블록의 라이브 편집 이벤트는 `useContentMarkdownFencedBlocks`에서 관리한다. 문단·스페이서 편집과 `>`·코드·콜아웃 문단 단축 변환은 `useContentMarkdownParagraphs`에서 관리한다. 목록 번호 계산과 항목 커밋·분리·탈출 이벤트는 `useContentMarkdownLists`에서 관리한다.
|
||||
- 본문 표시용 이미지는 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`에서 관리한다. 문단·스페이서 편집과 `>`·코드·콜아웃 문단 단축 변환은 `useContentMarkdownParagraphs`에서 관리한다. 목록 번호 계산과 항목 커밋·분리·탈출 이벤트는 `useContentMarkdownLists`에서 관리한다. 인용 블록 커밋·아래 이탈·일반 문단 복귀 이벤트는 `useContentMarkdownQuotes`에서 관리한다.
|
||||
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
|
||||
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
|
||||
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
- 거대 컴포넌트 책임 분리 진행.
|
||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||
- `components/content/ContentMarkdownRenderer.vue`: 남은 인용 편집 이벤트(`onQuote*`)를 도메인별 helper/composable로 추가 분리.
|
||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.102
|
||||
|
||||
- 공개 Markdown 렌더러의 라이브 인용 블록 커밋·아래 이탈·일반 문단 복귀 이벤트를 `useContentMarkdownQuotes` composable로 분리.
|
||||
- 렌더러 안에 남아 있던 미사용 인용 줄 단위 편집 함수와 원본 블록 줄 helper를 제거.
|
||||
- 인용 composable의 옵션 선언 줄 보존, `>. ` 접두 정리, 아래 문단 삽입, 일반 문단 복귀 단위 테스트 추가.
|
||||
- v1.11 내부 개선 todo에서 완료된 공개 Markdown 렌더러 편집 이벤트 분리 항목 제거.
|
||||
|
||||
## v1.11.101
|
||||
|
||||
- 공개 Markdown 렌더러의 라이브 목록 번호 계산·항목 커밋·Enter 분기 이벤트를 `useContentMarkdownLists` composable로 분리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.101",
|
||||
"version": "1.11.102",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.101",
|
||||
"version": "1.11.102",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.101",
|
||||
"version": "1.11.102",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
128
tests/useContentMarkdownQuotes.test.js
Normal file
128
tests/useContentMarkdownQuotes.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { ref } from 'vue'
|
||||
import { useContentMarkdownQuotes } from '../composables/useContentMarkdownQuotes.js'
|
||||
|
||||
/**
|
||||
* 테스트용 인용 편집 상태를 만든다.
|
||||
* @param {string} contentText - Markdown 본문
|
||||
* @returns {{ calls: Object, pendingFocusLine: import('vue').Ref<number|null>, pendingFocusPosition: import('vue').Ref<string>, pendingFocusOffset: import('vue').Ref<number|null>, state: Object }}
|
||||
*/
|
||||
const createQuoteState = (contentText = '') => {
|
||||
const calls = {
|
||||
commits: [],
|
||||
inserts: []
|
||||
}
|
||||
const pendingFocusLine = ref(null)
|
||||
const pendingFocusPosition = ref('auto')
|
||||
const pendingFocusOffset = ref(null)
|
||||
const state = useContentMarkdownQuotes({
|
||||
content: ref(contentText),
|
||||
pendingFocusLine,
|
||||
pendingFocusPosition,
|
||||
pendingFocusOffset,
|
||||
commitInlineBlockLines: (block, replacementLines) => {
|
||||
calls.commits.push({ block, replacementLines })
|
||||
},
|
||||
insertBelowBlock: (block, options) => {
|
||||
calls.inserts.push({ block, options })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
calls,
|
||||
pendingFocusLine,
|
||||
pendingFocusPosition,
|
||||
pendingFocusOffset,
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
test('인용 블록 줄 생성은 옵션 선언 줄을 보존한다', () => {
|
||||
const block = {
|
||||
type: 'quote',
|
||||
meta: {
|
||||
startLine: 1,
|
||||
endLine: 3,
|
||||
quoteContentStartLine: 2
|
||||
}
|
||||
}
|
||||
const { state } = createQuoteState([
|
||||
'제목',
|
||||
'> [!bg=blue]',
|
||||
'> 이전',
|
||||
'> 둘째'
|
||||
].join('\n'))
|
||||
|
||||
assert.deepEqual(state.buildQuoteBlockLines(block, '새 본문\n다음'), [
|
||||
'> [!bg=blue]',
|
||||
'> 새 본문',
|
||||
'> 다음'
|
||||
])
|
||||
})
|
||||
|
||||
test('인용 블록 커밋은 브라우저 보정으로 생긴 >. 접두를 정리한다', () => {
|
||||
const block = {
|
||||
type: 'quote',
|
||||
meta: {
|
||||
startLine: 0,
|
||||
endLine: 0
|
||||
}
|
||||
}
|
||||
const { calls, state } = createQuoteState('> 이전')
|
||||
|
||||
state.onQuoteBlockCommit(block, '>. 발타자르')
|
||||
|
||||
assert.deepEqual(calls.commits[0], {
|
||||
block,
|
||||
replacementLines: ['> 발타자르']
|
||||
})
|
||||
})
|
||||
|
||||
test('인용 블록 아래 이탈은 현재 내용을 저장하고 아래 문단을 삽입한다', () => {
|
||||
const block = {
|
||||
type: 'quote',
|
||||
meta: {
|
||||
startLine: 3,
|
||||
endLine: 4
|
||||
}
|
||||
}
|
||||
const { calls, pendingFocusPosition, pendingFocusOffset, state } = createQuoteState([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'> 이전',
|
||||
'> 둘째'
|
||||
].join('\n'))
|
||||
|
||||
state.onQuoteBlockInsertBelow(block, {
|
||||
value: '저장',
|
||||
before: '저장',
|
||||
after: '',
|
||||
caretAtEnd: true
|
||||
})
|
||||
|
||||
assert.equal(pendingFocusPosition.value, 'start')
|
||||
assert.equal(pendingFocusOffset.value, 0)
|
||||
assert.deepEqual(calls.commits[0].replacementLines, ['> 저장'])
|
||||
assert.deepEqual(calls.inserts[0], { block, options: { lines: [''] } })
|
||||
})
|
||||
|
||||
test('인용 블록 문단 복귀는 인용 마커 없이 원문 줄을 저장한다', () => {
|
||||
const block = {
|
||||
type: 'quote',
|
||||
meta: {
|
||||
startLine: 5,
|
||||
endLine: 6
|
||||
}
|
||||
}
|
||||
const { calls, pendingFocusLine, pendingFocusPosition, state } = createQuoteState('> 이전')
|
||||
|
||||
state.onQuoteBlockConvertToParagraph(block, {
|
||||
value: '첫 줄\n둘째 줄'
|
||||
})
|
||||
|
||||
assert.equal(pendingFocusLine.value, 5)
|
||||
assert.equal(pendingFocusPosition.value, 'start')
|
||||
assert.deepEqual(calls.commits[0].replacementLines, ['첫 줄', '둘째 줄'])
|
||||
})
|
||||
Reference in New Issue
Block a user