본문 문단과 줄바꿈 처리 정리
This commit is contained in:
@@ -24,6 +24,11 @@ const isUploading = ref(false)
|
|||||||
const mediaPickerTarget = ref('image')
|
const mediaPickerTarget = ref('image')
|
||||||
const selectedMediaUrls = ref([])
|
const selectedMediaUrls = ref([])
|
||||||
const selectedImageWidth = ref('regular')
|
const selectedImageWidth = ref('regular')
|
||||||
|
const lastSelectionState = ref({
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
scrollTop: 0
|
||||||
|
})
|
||||||
|
|
||||||
const imageWidthOptions = [
|
const imageWidthOptions = [
|
||||||
{ value: 'regular', label: '기본' },
|
{ value: 'regular', label: '기본' },
|
||||||
@@ -154,26 +159,79 @@ const refreshCaretLogicalLine = () => {
|
|||||||
const pos = Math.min(textarea.selectionStart, value.length)
|
const pos = Math.min(textarea.selectionStart, value.length)
|
||||||
const lineIndex = value.slice(0, pos).split('\n').length - 1
|
const lineIndex = value.slice(0, pos).split('\n').length - 1
|
||||||
|
|
||||||
|
lastSelectionState.value = {
|
||||||
|
start: Math.min(textarea.selectionStart, value.length),
|
||||||
|
end: Math.min(textarea.selectionEnd, value.length),
|
||||||
|
scrollTop: textarea.scrollTop
|
||||||
|
}
|
||||||
activeLogicalLineIndex.value = Math.max(0, lineIndex)
|
activeLogicalLineIndex.value = Math.max(0, lineIndex)
|
||||||
syncGutterScroll()
|
syncGutterScroll()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* textarea 스크롤 시 거터만 동기화한다.
|
* textarea 스크롤 시 선택 위치를 기억하고 거터를 동기화한다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const onTextareaScroll = () => {
|
const onTextareaScroll = () => {
|
||||||
|
rememberTextareaSelection()
|
||||||
syncGutterScroll()
|
syncGutterScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* textarea의 선택 영역과 스크롤 위치를 기억한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const rememberTextareaSelection = () => {
|
||||||
|
const textarea = textareaRef.value
|
||||||
|
const value = markdownValue.value ?? ''
|
||||||
|
|
||||||
|
if (!textarea) {
|
||||||
|
lastSelectionState.value = {
|
||||||
|
start: value.length,
|
||||||
|
end: value.length,
|
||||||
|
scrollTop: 0
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSelectionState.value = {
|
||||||
|
start: Math.min(textarea.selectionStart, value.length),
|
||||||
|
end: Math.min(textarea.selectionEnd, value.length),
|
||||||
|
scrollTop: textarea.scrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기억한 선택 영역과 스크롤 위치로 작성 textarea 포커스를 복원한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const restoreTextareaFocus = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
const textarea = textareaRef.value
|
||||||
|
const value = markdownValue.value ?? ''
|
||||||
|
|
||||||
|
if (!textarea) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.min(lastSelectionState.value.start, value.length)
|
||||||
|
const end = Math.min(lastSelectionState.value.end, value.length)
|
||||||
|
|
||||||
|
textarea.focus()
|
||||||
|
textarea.setSelectionRange(start, end)
|
||||||
|
textarea.scrollTop = lastSelectionState.value.scrollTop
|
||||||
|
refreshCaretLogicalLine()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => props.modelValue, () => {
|
watch(() => props.modelValue, () => {
|
||||||
refreshCaretLogicalLine()
|
refreshCaretLogicalLine()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(activeMode, (mode) => {
|
watch(activeMode, (mode) => {
|
||||||
if (mode === 'write') {
|
if (mode === 'write') {
|
||||||
refreshCaretLogicalLine()
|
restoreTextareaFocus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,9 +245,30 @@ watch(activeMode, (mode) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const toggleEditorMode = () => {
|
const toggleEditorMode = () => {
|
||||||
|
if (activeMode.value === 'write') {
|
||||||
|
rememberTextareaSelection()
|
||||||
|
}
|
||||||
|
|
||||||
activeMode.value = activeMode.value === 'write' ? 'preview' : 'write'
|
activeMode.value = activeMode.value === 'write' ? 'preview' : 'write'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성/미리보기 모드를 지정한다.
|
||||||
|
* @param {'write'|'preview'} mode - 전환할 모드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const setEditorMode = (mode) => {
|
||||||
|
if (activeMode.value === mode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMode.value === 'write') {
|
||||||
|
rememberTextareaSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
activeMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
/**
|
/**
|
||||||
* document selectionchange에서 작성 textarea가 포커스일 때만 활성 줄을 갱신한다.
|
* document selectionchange에서 작성 textarea가 포커스일 때만 활성 줄을 갱신한다.
|
||||||
@@ -311,6 +390,21 @@ const replaceSelection = (replacement, cursorOffset = replacement.length, select
|
|||||||
setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0))
|
setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter 입력을 문단 분리 규칙에 맞게 처리한다.
|
||||||
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
|
* @returns {boolean} 직접 처리했는지 여부
|
||||||
|
*/
|
||||||
|
const handleParagraphEnter = (event) => {
|
||||||
|
if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.isComposing) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
replaceSelection('\n\n')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 블록형 마크다운 조각을 커서 위치에 삽입한다.
|
* 블록형 마크다운 조각을 커서 위치에 삽입한다.
|
||||||
* @param {string} snippet - 삽입할 마크다운
|
* @param {string} snippet - 삽입할 마크다운
|
||||||
@@ -882,6 +976,10 @@ const handleDrop = async (event) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const handleKeydown = (event) => {
|
const handleKeydown = (event) => {
|
||||||
|
if (handleParagraphEnter(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!(event.metaKey || event.ctrlKey)) {
|
if (!(event.metaKey || event.ctrlKey)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -956,7 +1054,7 @@ const handleKeydown = (event) => {
|
|||||||
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold"
|
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold"
|
||||||
:class="activeMode === 'write' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
|
:class="activeMode === 'write' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
|
||||||
type="button"
|
type="button"
|
||||||
@click="activeMode = 'write'"
|
@click="setEditorMode('write')"
|
||||||
>
|
>
|
||||||
작성
|
작성
|
||||||
</button>
|
</button>
|
||||||
@@ -964,7 +1062,7 @@ const handleKeydown = (event) => {
|
|||||||
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold"
|
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold"
|
||||||
:class="activeMode === 'preview' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
|
:class="activeMode === 'preview' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
|
||||||
type="button"
|
type="button"
|
||||||
@click="activeMode = 'preview'"
|
@click="setEditorMode('preview')"
|
||||||
>
|
>
|
||||||
미리보기
|
미리보기
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -99,6 +99,31 @@ const parseImageLine = (line) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
|
||||||
|
* @param {string} line - 마크다운 행
|
||||||
|
* @returns {boolean} 블록 시작 여부
|
||||||
|
*/
|
||||||
|
const isMarkdownBlockStart = (line) => {
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
|
||||||
|
return trimmedLine === BLANK_PARAGRAPH_MARKER ||
|
||||||
|
trimmedLine === '>>>' ||
|
||||||
|
trimmedLine === ':::bookmark' ||
|
||||||
|
trimmedLine === ':::signup' ||
|
||||||
|
trimmedLine === ':::gallery' ||
|
||||||
|
trimmedLine === ':::embed' ||
|
||||||
|
trimmedLine.startsWith(':::callout') ||
|
||||||
|
trimmedLine.startsWith(':::toggle') ||
|
||||||
|
trimmedLine.startsWith('```') ||
|
||||||
|
trimmedLine === '---' ||
|
||||||
|
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
|
||||||
|
trimmedLine.startsWith('> ') ||
|
||||||
|
/^- /.test(trimmedLine) ||
|
||||||
|
/^\d+\.\s+/.test(trimmedLine) ||
|
||||||
|
Boolean(parseImageLine(trimmedLine))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 닫힘 표식까지의 행 목록을 반환
|
* 닫힘 표식까지의 행 목록을 반환
|
||||||
* @param {Array<string>} lines - 전체 마크다운 행
|
* @param {Array<string>} lines - 전체 마크다운 행
|
||||||
@@ -224,14 +249,7 @@ const parseMarkdownBlocks = (markdown) => {
|
|||||||
const line = lines[index]
|
const line = lines[index]
|
||||||
const trimmedLine = line.trim()
|
const trimmedLine = line.trim()
|
||||||
|
|
||||||
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
|
if (trimmedLine === BLANK_PARAGRAPH_MARKER || !trimmedLine) {
|
||||||
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
|
|
||||||
index += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!trimmedLine) {
|
|
||||||
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
|
|
||||||
index += 1
|
index += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -380,8 +398,22 @@ const parseMarkdownBlocks = (markdown) => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
blocks.push(createBlock('paragraph', trimmedLine, null, `block-${blocks.length}`))
|
const paragraphLines = [trimmedLine]
|
||||||
index += 1
|
index += 1
|
||||||
|
|
||||||
|
while (index < lines.length) {
|
||||||
|
const nextLine = lines[index]
|
||||||
|
const nextTrimmedLine = nextLine.trim()
|
||||||
|
|
||||||
|
if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraphLines.push(nextTrimmedLine)
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push(createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
return blocks
|
return blocks
|
||||||
@@ -447,6 +479,15 @@ const parseInlineSegments = (value) => {
|
|||||||
return segments.length ? segments : [{ type: 'text', text: source }]
|
return segments.length ? segments : [{ type: 'text', text: source }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
|
||||||
|
* @param {string} value - 원본 문자열
|
||||||
|
* @returns {Array<Array<{ type: string, text: string, href?: string }>>} 줄별 인라인 세그먼트
|
||||||
|
*/
|
||||||
|
const parseInlineSegmentLines = (value) => {
|
||||||
|
return String(value || '').split('\n').map(parseInlineSegments)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 라이트박스를 연다
|
* 라이트박스를 연다
|
||||||
* @param {Array<Object>} images - 이미지 목록
|
* @param {Array<Object>} images - 이미지 목록
|
||||||
@@ -489,8 +530,7 @@ const showNextImage = () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="content-markdown-renderer">
|
<div class="content-markdown-renderer">
|
||||||
<template v-for="block in blocks" :key="block.id">
|
<template v-for="block in blocks" :key="block.id">
|
||||||
<div v-if="block.type === 'spacer'" class="content-markdown-renderer__spacer h-6" aria-hidden="true" />
|
<ProseHeading v-if="block.type === 'heading'" :level="block.level">
|
||||||
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
|
|
||||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
||||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||||
@@ -576,13 +616,16 @@ const showNextImage = () => {
|
|||||||
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
|
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
|
||||||
><code>{{ block.text }}</code></pre>
|
><code>{{ block.text }}</code></pre>
|
||||||
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
|
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
|
||||||
<p v-else class="content-markdown-renderer__paragraph my-5 text-[15px] leading-8 text-[var(--site-text)]">
|
<p v-else class="content-markdown-renderer__paragraph mb-6 text-[15px] leading-8 text-[var(--site-text)] last:mb-0">
|
||||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-paragraph-${segmentIndex}`">
|
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
|
||||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
<br v-if="lineIndex > 0">
|
||||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
||||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||||
<template v-else>{{ segment.text }}</template>
|
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||||
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||||
|
<template v-else>{{ segment.text }}</template>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.0.16
|
||||||
|
|
||||||
|
- 글쓰기에서 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 정리.
|
||||||
|
- 미리보기 전환 후 작성 모드로 돌아오면 기존 커서 위치에서 계속 입력할 수 있도록 개선.
|
||||||
|
- 공개 본문과 관리자 미리보기의 문단 간격을 24px 기준으로 통일.
|
||||||
|
|
||||||
## v1.0.15
|
## v1.0.15
|
||||||
|
|
||||||
- 본문 중간의 빈 줄이 공개 화면과 관리자 미리보기에서 사라지지 않도록 간격 보존을 보강.
|
- 본문 중간의 빈 줄이 공개 화면과 관리자 미리보기에서 사라지지 않도록 간격 보존을 보강.
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-14 v1.0.16
|
||||||
|
|
||||||
|
### 문단과 줄바꿈 의미 분리
|
||||||
|
|
||||||
|
Markdown-first 에디터에서 빈 줄을 그대로 spacer로 보존하면 세로 간격은 조절할 수 있지만, 글쓰기 경험이 옵시디언·고스트처럼 “문단”과 “줄바꿈”으로 나뉘지 않는다. 운영 글쓰기에서는 일반 Enter를 새 문단, Shift+Enter를 같은 문단 안 줄바꿈으로 보는 Ghost형 규칙이 더 예측 가능하다. 따라서 에디터는 일반 Enter 입력 시 `\\n\\n`을 넣고, Shift+Enter는 브라우저 기본 단일 줄바꿈을 유지한다. 렌더러는 빈 줄을 별도 spacer가 아니라 문단 경계로만 쓰며, 연속 텍스트 줄은 한 문단으로 묶어 단일 줄바꿈을 `<br>`로 표시한다. 문단 간 구분감은 개별 spacer가 아니라 문단 하단 24px 간격으로 통일한다.
|
||||||
|
|
||||||
|
### 미리보기 전환 후 커서 복원
|
||||||
|
|
||||||
|
`Cmd/Ctrl+E`로 미리보기를 확인한 뒤 작성 모드로 돌아왔을 때 포커스가 사라지면 긴 글을 이어 쓰는 흐름이 끊긴다. 작성 textarea의 선택 시작·끝 위치와 스크롤 위치를 기억하고, 미리보기에서 돌아올 때 같은 위치로 포커스와 선택 영역을 복원하도록 했다.
|
||||||
|
|
||||||
## 2026-05-14 v1.0.15
|
## 2026-05-14 v1.0.15
|
||||||
|
|
||||||
### 마크다운 빈 줄 간격 보존
|
### 마크다운 빈 줄 간격 보존
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, 작성 모드 왼쪽 논리 줄 번호 거터·현재 줄 강조·거터 스크롤 동기화, 작성/미리보기 전환(`Cmd/Ctrl+E`), 툴바 마크다운 삽입, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 |
|
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, Enter 새 문단·Shift+Enter 줄바꿈 입력, 작성 모드 왼쪽 논리 줄 번호 거터·현재 줄 강조·거터 스크롤 동기화, 작성/미리보기 전환(`Cmd/Ctrl+E`)과 커서 복원, 툴바 마크다운 삽입, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 |
|
||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||||
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 빈 줄 spacer 보존, 확장 블록 파싱 |
|
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 빈 줄 문단 경계·단일 줄바꿈 `<br>` 처리, 확장 블록 파싱 |
|
||||||
| components/content/ProseHeading.vue | h1~h6 제목 |
|
| components/content/ProseHeading.vue | h1~h6 제목 |
|
||||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||||
| components/content/ProseList.vue | 목록 |
|
| components/content/ProseList.vue | 목록 |
|
||||||
|
|||||||
12
docs/spec.md
12
docs/spec.md
@@ -180,9 +180,12 @@ components/content/
|
|||||||
- 이미지 갤러리
|
- 이미지 갤러리
|
||||||
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
||||||
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스)
|
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스)
|
||||||
- 빈 줄
|
- 문단과 줄바꿈
|
||||||
- 본문 중간의 내용 없는 줄은 줄 수만큼 spacer 블록으로 렌더링해 작성자가 의도한 세로 간격을 보존
|
- 관리자 Markdown-first 에디터에서 일반 Enter는 새 문단을 만들기 위해 빈 줄 포함 `\\n\\n`을 삽입한다.
|
||||||
- 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)도 같은 spacer로 렌더링
|
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 단일 `\\n`을 삽입한다.
|
||||||
|
- 공개 본문 렌더러는 연속된 텍스트 줄을 하나의 문단으로 묶고 단일 줄바꿈은 `<br>`로 표시한다.
|
||||||
|
- 빈 줄과 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)는 별도 간격 블록이 아니라 문단 경계로만 사용한다.
|
||||||
|
- 문단 하단 간격은 24px 기준으로 유지한다.
|
||||||
- 카드류
|
- 카드류
|
||||||
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
|
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
|
||||||
- Toggle: `:::toggle 제목` ~ `:::`
|
- Toggle: `:::toggle 제목` ~ `:::`
|
||||||
@@ -448,11 +451,12 @@ components/content/
|
|||||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
|
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
|
||||||
- 관리자 게시물/페이지 저장 API는 레거시 블록 배열·객체 본문 값이 들어와도 마크다운 문자열로 정규화한 뒤 저장한다.
|
- 관리자 게시물/페이지 저장 API는 레거시 블록 배열·객체 본문 값이 들어와도 마크다운 문자열로 정규화한 뒤 저장한다.
|
||||||
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
|
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
|
||||||
|
- 본문 작성 모드에서 일반 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 처리한다.
|
||||||
- 클립보드에 `text/html`이 있으면 제목, 문단, 목록, 인용, 코드, 링크, 굵게, 기울임, 이미지를 기본 마크다운 조각으로 변환해 삽입한다.
|
- 클립보드에 `text/html`이 있으면 제목, 문단, 목록, 인용, 코드, 링크, 굵게, 기울임, 이미지를 기본 마크다운 조각으로 변환해 삽입한다.
|
||||||
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용한다.
|
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용한다.
|
||||||
- 툴바는 제목 1/2/3, 굵게, 기울임, 인라인 코드, 인용, 목록, 코드 블록, 구분선 삽입을 제공한다.
|
- 툴바는 제목 1/2/3, 굵게, 기울임, 인라인 코드, 인용, 목록, 코드 블록, 구분선 삽입을 제공한다.
|
||||||
- `Cmd/Ctrl+B`, `Cmd/Ctrl+I`는 현재 선택 텍스트에 각각 굵게, 기울임 마크다운을 적용한다.
|
- `Cmd/Ctrl+B`, `Cmd/Ctrl+I`는 현재 선택 텍스트에 각각 굵게, 기울임 마크다운을 적용한다.
|
||||||
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환한다.
|
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
|
||||||
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
|
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
|
||||||
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `{width=...}` 형식으로 삽입한다.
|
- 미디어 라이브러리에서 단일 이미지를 선택하면 `{width=...}` 형식으로 삽입한다.
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.0.16
|
||||||
|
|
||||||
|
- 관리자 `AdminMarkdownEditor`에서 일반 Enter는 새 문단(`\\n\\n`), Shift+Enter는 같은 문단 안 줄바꿈(`\\n`)으로 입력되도록 수정.
|
||||||
|
- `Cmd/Ctrl+E`로 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 스크롤을 복원하도록 보강.
|
||||||
|
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 연속 텍스트 줄을 한 문단으로 묶고, 단일 줄바꿈은 `<br>`, 빈 줄은 문단 경계로 렌더링하도록 정리.
|
||||||
|
- 본문 문단 하단 간격을 24px 기준으로 조정.
|
||||||
|
- 패키지 버전 `1.0.16`으로 갱신.
|
||||||
|
|
||||||
## v1.0.15
|
## v1.0.15
|
||||||
|
|
||||||
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 빈 줄을 버리지 않고 spacer 블록으로 렌더링하도록 수정.
|
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 빈 줄을 버리지 않고 spacer 블록으로 렌더링하도록 수정.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.0.15",
|
"version": "1.0.16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.0.15",
|
"version": "1.0.16",
|
||||||
"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.0.15",
|
"version": "1.0.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
Reference in New Issue
Block a user