v1.2.9: 라이브 에디터·홈 피드·메인 커버 개선

라이브 모드 코드/콜아웃/토글 편집, 슬래시 명령, 홈 Latest List·Compact·Cards 보기,
사이트 설정 메인 화면 커버(720px) 및 HomeHero 반영.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-18 16:57:30 +09:00
parent 666bd304fc
commit 3fb8a40031
34 changed files with 3823 additions and 443 deletions

View File

@@ -3,17 +3,20 @@ import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.
import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js'
import { getImageDefaultAltLabel, serializeImageMarkdown } from '../../lib/markdown-image.js'
import { convertHtmlToMarkdown } from '../../lib/markdown-inline.js'
import {
filterSlashCommands,
getSlashCommandFocusTarget,
parseSlashInput,
resolveSlashCommand
} from '../../lib/markdown-slash-commands.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
const props = defineProps({
modelValue: {
type: [String, Array, Object],
default: ''
},
/** 헤더 아래 고정 툴바 슬롯(AdminPostForm `#admin-post-form-editor-toolbar-host`) */
toolbarTeleportTo: {
type: String,
default: '#admin-post-form-editor-toolbar-host'
},
/** 작성/미리보기 토글 버튼 Teleport 대상(AdminPostForm 헤더) */
modeToggleTeleportTo: {
type: String,
@@ -30,6 +33,7 @@ const activeMode = defineModel('editorMode', {
const editorRootRef = ref(null)
const textareaRef = ref(null)
const previewRef = ref(null)
const previewRendererRef = ref(null)
const gutterRef = ref(null)
/** 커서가 있는 논리 줄(0-based, `\\n` 기준) */
const activeLogicalLineIndex = ref(0)
@@ -45,6 +49,32 @@ const lastSelectionState = ref({
start: 0,
end: 0
})
/** @type {import('vue').Ref<number|null>} 슬래시 명령이 입력된 줄 */
const liveSlashSourceLine = ref(null)
/** @type {import('vue').Ref<string>} 슬래시 검색어 */
const liveSlashQuery = ref('')
/** @type {import('vue').Ref<number>} 슬래시 메뉴 하이라이트 인덱스 */
const liveSlashHighlightedIndex = ref(0)
/** @type {import('vue').Ref<{ top: number, left: number }>} 슬래시 메뉴 위치 */
const liveSlashMenuPosition = ref({ top: 0, left: 0 })
/** @type {import('vue').Ref<HTMLElement|null>} 슬래시 메뉴 DOM */
const liveSlashMenuRef = ref(null)
/** 슬래시 메뉴 예상 높이(px) — 렌더 전 위치 계산용 */
const LIVE_SLASH_MENU_ESTIMATED_HEIGHT = 280
/** @type {import('vue').Ref<number|null>} 슬래시 후 미디어 삽입 줄 */
const liveSlashInsertLine = ref(null)
/** @type {import('vue').Ref<Set<number>>} ESC로 슬래시 메뉴만 닫은 줄(0-based) */
const liveSlashSuppressedLines = ref(new Set())
/** 키보드로 슬래시 메뉴를 탐색 중일 때 mouseenter 하이라이트 무시 */
const liveSlashKeyboardNav = ref(false)
let liveSlashKeyboardNavTimer = null
const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null)
const liveSlashSuppressedLineList = computed(() => [...liveSlashSuppressedLines.value])
const visibleLiveSlashCommands = computed(() => filterSlashCommands(liveSlashQuery.value))
/** 작성 textarea 최소 높이(px) */
const MIN_TEXTAREA_HEIGHT_PX = 620
@@ -215,9 +245,47 @@ const refreshCaretLogicalLine = () => {
activeLogicalLineIndex.value = Math.max(0, lineIndex)
syncTextareaHeight()
syncBlockPanelState()
if (activeMode.value === 'write' && document.activeElement === textarea) {
syncWriteSlashState()
}
})
}
/**
* 소스(작성) 모드 textarea 현재 줄의 슬래시 입력 상태를 갱신한다.
* @returns {void}
*/
const syncWriteSlashState = () => {
if (activeMode.value !== 'write') {
return
}
const line = activeLogicalLineIndex.value
if (liveSlashSuppressedLines.value.has(line)) {
if (liveSlashSourceLine.value !== null) {
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
}
return
}
const lines = (markdownValue.value ?? '').split('\n')
const parsed = parseSlashInput(lines[line] ?? '')
if (!parsed) {
onLiveSlashEnd({ sourceLine: line })
return
}
liveSlashSourceLine.value = line
liveSlashQuery.value = parsed.query
nextTick(updateLiveSlashMenuPosition)
}
/**
* textarea의 선택 영역과 스크롤 위치를 기억한다.
* @returns {void}
@@ -341,12 +409,58 @@ onMounted(() => {
* @returns {void}
*/
const onDocumentKeydown = (event) => {
if (event.key === 'Escape' && isMediaPickerOpen.value) {
event.preventDefault()
closeMediaPicker()
return
}
const root = editorRootRef.value
if (!root || !root.contains(document.activeElement)) {
return
}
if (liveSlashVisible.value) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
const count = visibleLiveSlashCommands.value.length
if (count) {
liveSlashKeyboardNav.value = true
window.clearTimeout(liveSlashKeyboardNavTimer)
liveSlashKeyboardNavTimer = window.setTimeout(() => {
liveSlashKeyboardNav.value = false
}, 200)
if (event.key === 'ArrowDown') {
liveSlashHighlightedIndex.value = (liveSlashHighlightedIndex.value + 1) % count
} else {
liveSlashHighlightedIndex.value = (liveSlashHighlightedIndex.value - 1 + count) % count
}
}
return
}
if (event.key === 'Enter') {
event.preventDefault()
const command = visibleLiveSlashCommands.value[liveSlashHighlightedIndex.value]
if (command) {
applyLiveSlashCommand(command)
}
return
}
if (event.key === 'Escape') {
event.preventDefault()
cancelLiveSlashCommand()
return
}
}
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'e') {
return
}
@@ -360,6 +474,7 @@ onMounted(() => {
onBeforeUnmount(() => {
window.clearTimeout(blockPanelFocusTimer)
window.clearTimeout(liveSlashKeyboardNavTimer)
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('keydown', onDocumentKeydown, true)
})
@@ -439,25 +554,6 @@ const replaceSelection = (replacement, cursorOffset = replacement.length, select
setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0))
}
/**
* Enter 입력을 문단/줄바꿈 규칙에 맞게 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {boolean} 직접 처리했는지 여부
*/
const handleParagraphEnter = (event) => {
if (event.key !== 'Enter' || event.metaKey || event.ctrlKey || event.altKey || event.isComposing) {
return false
}
if (!event.shiftKey) {
return false
}
event.preventDefault()
replaceSelection('\\\n')
return true
}
/**
* 블록형 마크다운 조각을 커서 위치에 삽입한다.
* @param {string} snippet - 삽입할 마크다운
@@ -737,6 +833,392 @@ const onPreviewDeleteLine = (lineIndex) => {
markdownValue.value = nextLines.length ? nextLines.join('\n') : ''
}
/**
* 라이브 모드에서 현재 줄을 이전 줄 끝에 병합한다.
* @param {{ lineIndex: number, mergedLine: string }} payload - 병합 위치·결과 줄
* @returns {void}
*/
/**
* 슬래시 메뉴 좌표를 뷰포트 안으로 맞춘다.
* @param {number} top - 후보 top(px)
* @param {number} left - 후보 left(px)
* @param {number} menuHeight - 메뉴 높이
* @param {number} menuWidth - 메뉴 너비
* @param {number} anchorTop - 기준 요소 top(위로 넘칠 때)
* @returns {{ top: number, left: number }}
*/
const clampSlashMenuPosition = (top, left, menuHeight, menuWidth, anchorTop) => {
const gap = 4
const padding = 8
const viewportBottom = window.innerHeight - padding
const viewportRight = window.innerWidth - padding
let nextTop = top
if (nextTop + menuHeight > viewportBottom) {
const aboveTop = anchorTop - menuHeight - gap
if (aboveTop >= padding) {
nextTop = aboveTop
} else {
nextTop = Math.max(padding, viewportBottom - menuHeight)
}
}
let nextLeft = left
if (nextLeft + menuWidth > viewportRight) {
nextLeft = viewportRight - menuWidth
}
return {
top: nextTop,
left: Math.max(padding, nextLeft)
}
}
/**
* 미리보기 모드 슬래시 메뉴 위치를 갱신한다.
* @returns {void}
*/
const updatePreviewSlashMenuPosition = () => {
const element = previewRef.value?.querySelector(`[data-source-line="${liveSlashSourceLine.value}"]`)
if (!element) {
return
}
const rect = element.getBoundingClientRect()
const menuEl = liveSlashMenuRef.value
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
const menuWidth = menuEl?.offsetWidth || 288
const gap = 4
liveSlashMenuPosition.value = clampSlashMenuPosition(
rect.bottom + gap,
rect.left,
menuHeight,
menuWidth,
rect.top
)
}
/**
* 소스 모드 textarea 슬래시 메뉴 위치를 갱신한다.
* @returns {void}
*/
const updateWriteSlashMenuPosition = () => {
const textarea = textareaRef.value
const line = liveSlashSourceLine.value
if (!textarea || line === null) {
return
}
const value = markdownValue.value ?? ''
const lineStart = value.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0)
const coords = getTextareaCaretCoordinates(textarea, lineStart)
const rect = textarea.getBoundingClientRect()
const menuEl = liveSlashMenuRef.value
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
const menuWidth = menuEl?.offsetWidth || 288
const gap = 4
const anchorTop = rect.top + coords.top - textarea.scrollTop
liveSlashMenuPosition.value = clampSlashMenuPosition(
anchorTop + coords.height + gap,
rect.left + coords.left - textarea.scrollLeft,
menuHeight,
menuWidth,
anchorTop
)
}
/**
* 슬래시 메뉴 위치를 갱신한다(뷰포트 아래로 넘치면 위쪽·좌우 클램프).
* @returns {void}
*/
const updateLiveSlashMenuPosition = () => {
if (!import.meta.client || liveSlashSourceLine.value === null) {
return
}
nextTick(() => {
nextTick(() => {
if (activeMode.value === 'write') {
updateWriteSlashMenuPosition()
return
}
updatePreviewSlashMenuPosition()
})
})
}
/**
* 키보드로 선택한 슬래시 메뉴 항목이 보이도록 스크롤한다.
* @returns {void}
*/
const scrollLiveSlashHighlightedIntoView = () => {
if (!import.meta.client || !liveSlashVisible.value) {
return
}
nextTick(() => {
const menu = liveSlashMenuRef.value
if (!menu) {
return
}
const highlightedItem = menu.querySelector('.admin-markdown-editor__slash-item--active')
if (!highlightedItem) {
return
}
const itemTop = highlightedItem.offsetTop
const itemBottom = itemTop + highlightedItem.offsetHeight
const viewTop = menu.scrollTop
const viewBottom = viewTop + menu.clientHeight
if (itemTop < viewTop) {
menu.scrollTop = itemTop
} else if (itemBottom > viewBottom) {
menu.scrollTop = itemBottom - menu.clientHeight
}
})
}
/**
* 슬래시 메뉴 항목 mouseenter 시 하이라이트(키보드 탐색 중에는 무시).
* @param {number} commandIndex - 명령 인덱스
* @returns {void}
*/
const onSlashMenuItemMouseEnter = (commandIndex) => {
if (liveSlashKeyboardNav.value) {
return
}
liveSlashHighlightedIndex.value = commandIndex
}
/**
* 슬래시 명령 입력을 갱신한다.
* @param {{ sourceLine: number, query: string }} payload - 줄·검색어
* @returns {void}
*/
const onLiveSlashUpdate = (payload) => {
if (typeof payload?.sourceLine !== 'number') {
return
}
if (liveSlashSuppressedLines.value.has(payload.sourceLine)) {
if (liveSlashSourceLine.value !== null) {
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
}
return
}
liveSlashSourceLine.value = payload.sourceLine
liveSlashQuery.value = String(payload.query ?? '')
nextTick(updateLiveSlashMenuPosition)
}
watch(liveSlashQuery, () => {
liveSlashHighlightedIndex.value = 0
})
watch(visibleLiveSlashCommands, (commands) => {
if (liveSlashHighlightedIndex.value >= commands.length) {
liveSlashHighlightedIndex.value = Math.max(0, commands.length - 1)
}
updateLiveSlashMenuPosition()
})
watch(liveSlashHighlightedIndex, () => {
scrollLiveSlashHighlightedIntoView()
})
watch(liveSlashVisible, (visible) => {
if (!import.meta.client) {
return
}
const onReposition = () => updateLiveSlashMenuPosition()
if (visible) {
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
previewRef.value?.addEventListener('scroll', onReposition)
textareaRef.value?.addEventListener('scroll', onReposition)
updateLiveSlashMenuPosition()
scrollLiveSlashHighlightedIntoView()
return
}
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
previewRef.value?.removeEventListener('scroll', onReposition)
textareaRef.value?.removeEventListener('scroll', onReposition)
})
/**
* 슬래시 명령 입력을 종료한다.
* @returns {void}
*/
const onLiveSlashEnd = (payload) => {
if (typeof payload?.sourceLine === 'number') {
const next = new Set(liveSlashSuppressedLines.value)
next.delete(payload.sourceLine)
liveSlashSuppressedLines.value = next
}
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
}
/**
* 슬래시 명령 입력을 취소한다(줄 내용은 유지, 메뉴만 닫음).
* @returns {void}
*/
const cancelLiveSlashCommand = () => {
const line = liveSlashSourceLine.value
if (line !== null) {
liveSlashSuppressedLines.value = new Set([...liveSlashSuppressedLines.value, line])
}
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
}
/**
* 슬래시 명령 적용 후 미리보기 인라인 편집에 포커스를 복원한다.
* @param {number} line - 줄 번호(0-based)
* @param {string[]} replacementLines - 삽입된 줄
* @returns {void}
*/
const focusPreviewAfterSlashCommand = (line, replacementLines) => {
if (!import.meta.client) {
return
}
const { line: focusLine, offset } = getSlashCommandFocusTarget(line, replacementLines)
nextTick(() => {
nextTick(() => {
previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'end', offset)
})
})
}
/**
* 소스 모드 textarea에 슬래시 명령 적용 후 커서를 복원한다.
* @param {number} line - 줄 번호(0-based)
* @param {string[]} replacementLines - 삽입된 줄
* @returns {void}
*/
const focusTextareaAfterSlashCommand = (line, replacementLines) => {
const value = markdownValue.value ?? ''
const lineStart = value.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0)
const { offset } = getSlashCommandFocusTarget(line, replacementLines)
setTextareaSelection(lineStart + offset, lineStart + offset)
}
/**
* 슬래시 명령을 적용한다.
* @param {import('../../lib/markdown-slash-commands.js').MarkdownSlashCommand} command - 명령
* @returns {void}
*/
const applyLiveSlashCommand = (command) => {
if (!command || liveSlashSourceLine.value === null) {
return
}
const line = liveSlashSourceLine.value
if (command.action === 'media-image' || command.action === 'media-gallery') {
liveSlashInsertLine.value = line
replaceLineRange(line, line, [], false)
onLiveSlashEnd()
openMediaPicker(command.action === 'media-image' ? 'image' : 'gallery')
return
}
if (command.action === 'lines' && Array.isArray(command.lines)) {
const replacementLines = command.lines
const nextSuppressed = new Set(liveSlashSuppressedLines.value)
nextSuppressed.delete(line)
liveSlashSuppressedLines.value = nextSuppressed
replaceLineRange(line, line, replacementLines, false)
liveSlashSourceLine.value = null
liveSlashQuery.value = ''
liveSlashHighlightedIndex.value = 0
if (activeMode.value === 'write') {
focusTextareaAfterSlashCommand(line, replacementLines)
} else {
focusPreviewAfterSlashCommand(line, replacementLines)
}
}
}
/**
* 슬래시 명령 Enter 적용
* @param {{ sourceLine: number, value: string }} payload - 줄·입력 값
* @returns {void}
*/
const onLiveSlashApply = (payload) => {
const parsed = parseSlashInput(payload?.value)
const command = resolveSlashCommand(parsed?.query ?? liveSlashQuery.value)
if (!command) {
return
}
applyLiveSlashCommand(command)
}
/**
* 지정 줄에 마크다운 줄을 삽입한다.
* @param {number} lineIndex - 삽입 위치(0-based)
* @param {string[]} insertLines - 삽입 줄
* @returns {void}
*/
const insertMarkdownAtLine = (lineIndex, insertLines) => {
const sourceLines = (markdownValue.value ?? '').split('\n')
markdownValue.value = [
...sourceLines.slice(0, lineIndex),
...insertLines,
...sourceLines.slice(lineIndex)
].join('\n')
}
const onPreviewMergeWithPreviousLine = ({ lineIndex, mergedLine }) => {
if (typeof lineIndex !== 'number' || lineIndex <= 0) {
return
}
const sourceLines = (markdownValue.value ?? '').split('\n')
if (lineIndex >= sourceLines.length) {
return
}
markdownValue.value = [
...sourceLines.slice(0, lineIndex - 1),
String(mergedLine ?? ''),
...sourceLines.slice(lineIndex + 1)
].join('\n')
}
/**
* 현재 갤러리 이미지 순서를 바꾼다.
* @param {number} imageIndex - 이동할 이미지 인덱스
@@ -894,6 +1376,7 @@ const closeMediaPicker = () => {
selectedMediaUrls.value = []
activeMediaPickerTab.value = 'library'
mediaSearchQuery.value = ''
liveSlashInsertLine.value = null
}
/**
@@ -950,6 +1433,36 @@ const applyMediaSelection = () => {
.map((url) => mediaItems.value.find((item) => item.url === url))
.filter(Boolean)
if (liveSlashInsertLine.value !== null) {
const line = liveSlashInsertLine.value
if (mediaPickerTarget.value === 'image') {
const [item] = selectedItems
if (item) {
insertMarkdownAtLine(line, [createImageMarkdown({
url: item.url,
useAlt: false,
caption: ''
})])
}
} else if (selectedItems.length) {
insertMarkdownAtLine(line, [
':::gallery',
...selectedItems.map((item) => createImageMarkdown({
url: item.url,
useAlt: false,
caption: ''
})),
':::'
])
}
liveSlashInsertLine.value = null
closeMediaPicker()
return
}
if (mediaPickerTarget.value === 'image') {
const [item] = selectedItems
if (item) {
@@ -1141,10 +1654,6 @@ const handleDrop = async (event) => {
* @returns {void}
*/
const handleKeydown = (event) => {
if (handleParagraphEnter(event)) {
return
}
if (!(event.metaKey || event.ctrlKey)) {
return
}
@@ -1163,49 +1672,6 @@ const handleKeydown = (event) => {
<template>
<div ref="editorRootRef" class="admin-markdown-editor grid gap-3">
<Teleport v-if="activeMode === 'write'" :to="toolbarTeleportTo">
<div class="admin-markdown-editor__toolbar flex w-full min-h-[40px] items-center gap-1.5 border-b border-[#e3e6e8] bg-white px-8 py-1.5">
<div class="admin-markdown-editor__toolbar-tools flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(1)">
H1
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(2)">
H2
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(3)">
H3
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-bold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('**', '**', '굵은 글씨')">
B
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm italic text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('*', '*', '기울임')">
I
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 font-mono text-sm text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('`', '`', 'code')">
Code
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyQuote">
인용
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyList">
목록
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="insertCodeBlock">
코드블록
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="insertBlockSnippet('---')">
구분선
</button>
<span class="admin-markdown-editor__divider mx-1 h-6 w-px bg-[#e3e6e8]" aria-hidden="true" />
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('image')">
이미지
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('gallery')">
갤러리
</button>
</div>
</div>
</Teleport>
<Teleport v-if="modeToggleTeleportTo" :to="modeToggleTeleportTo">
<button
@@ -1299,16 +1765,61 @@ const handleKeydown = (event) => {
tabindex="0"
>
<ContentMarkdownRenderer
ref="previewRendererRef"
:content="markdownValue"
interactive
:slash-menu-active="liveSlashVisible"
:slash-suppressed-lines="liveSlashSuppressedLineList"
@gallery-reorder="onPreviewGalleryReorder"
@block-content-change="onPreviewBlockContentChange"
@append-paragraph="onPreviewAppendParagraph"
@insert-after-line="onPreviewInsertAfterLine"
@delete-line="onPreviewDeleteLine"
@merge-with-previous-line="onPreviewMergeWithPreviousLine"
@slash-update="onLiveSlashUpdate"
@slash-end="onLiveSlashEnd"
@slash-apply="onLiveSlashApply"
/>
</div>
<Teleport to="body">
<div
v-if="liveSlashVisible"
ref="liveSlashMenuRef"
class="admin-markdown-editor__slash-menu fixed z-[60] w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded-lg border border-[#e3e6e8] bg-white p-1 shadow-lg"
:style="{ top: `${liveSlashMenuPosition.top}px`, left: `${liveSlashMenuPosition.left}px` }"
role="listbox"
aria-label="슬래시 명령"
>
<button
v-for="(command, commandIndex) in visibleLiveSlashCommands"
:key="`live-slash-${command.id}`"
class="admin-markdown-editor__slash-item flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left transition-colors"
:class="commandIndex === liveSlashHighlightedIndex ? 'admin-markdown-editor__slash-item--active bg-[#f4f5f6]' : ''"
type="button"
role="option"
:aria-selected="commandIndex === liveSlashHighlightedIndex"
@mouseenter="onSlashMenuItemMouseEnter(commandIndex)"
@mousedown.prevent="applyLiveSlashCommand(command)"
>
<span class="admin-markdown-editor__slash-leading flex min-w-0 items-center gap-2.5">
<AdminSlashCommandIcon
:command-id="command.id"
class="admin-markdown-editor__slash-icon shrink-0 text-[#394047]"
/>
<span class="admin-markdown-editor__slash-label truncate text-sm text-[#15171a]">{{ command.label }}</span>
</span>
<span
class="admin-markdown-editor__slash-slug shrink-0 text-xs text-[#8b959e] transition-opacity"
:class="commandIndex === liveSlashHighlightedIndex ? 'opacity-100' : 'opacity-0'"
>/{{ command.id }}</span>
</button>
<p v-if="!visibleLiveSlashCommands.length" class="admin-markdown-editor__slash-empty px-4 py-3 text-sm text-[#6b7280]">
일치하는 명령이 없습니다.
</p>
</div>
</Teleport>
<div v-if="isMediaPickerOpen" class="admin-markdown-editor__media-modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="closeMediaPicker">
<div class="admin-markdown-editor__media-panel flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
<header class="admin-markdown-editor__media-header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">

View File

@@ -1338,12 +1338,6 @@ defineExpose({
</div>
</header>
<div
v-show="editorMode === 'write'"
id="admin-post-form-editor-toolbar-host"
class="admin-post-form__editor-toolbar-host min-h-[40px] shrink-0 bg-white"
/>
<main class="admin-post-form__editor-scroll min-h-0 flex-1 overflow-y-auto">
<section class="admin-post-form__content mx-auto w-full max-w-[804px] px-8 pb-16 pt-16">
<div class="admin-post-form__feature-block mb-9">

View File

@@ -0,0 +1,135 @@
<script setup>
/**
* 슬래시 명령 메뉴 아이콘 (Ghost 스타일 라인 아이콘)
*/
const props = defineProps({
/** @type {import('vue').PropType<string>} */
commandId: {
type: String,
required: true
}
})
</script>
<template>
<svg
class="admin-slash-command-icon"
:class="`admin-slash-command-icon--${commandId}`"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
width="20"
height="20"
aria-hidden="true"
>
<!-- image -->
<template v-if="commandId === 'image'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="m19.642 16.276-3.85-7a.517.517 0 0 0-.181-.189.585.585 0 0 0-.749.115l-4.533 5.494-2.307-2.516a.548.548 0 0 0-.206-.14.598.598 0 0 0-.499.031.529.529 0 0 0-.183.164l-2.75 4a.468.468 0 0 0-.015.507.526.526 0 0 0 .202.189c.084.045.18.069.28.069H19.15a.594.594 0 0 0 .268-.063.532.532 0 0 0 .2-.174.462.462 0 0 0 .024-.487ZM9.25 9c.911 0 1.65-.672 1.65-1.5S10.161 6 9.25 6c-.91 0-1.65.672-1.65 1.5S8.34 9 9.25 9Z"></path>
</template>
<!-- gallery -->
<template v-else-if="commandId === 'gallery'">
<g clip-path="url(#a)"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 5H3.2C1.985 5 1 5.806 1 6.8v14.4c0 .994.985 1.8 2.2 1.8h17.6c1.215 0 2.2-.806 2.2-1.8V6.8c0-.994-.985-1.8-2.2-1.8ZM6 1h12"></path><path fill="currentColor" d="M15.142 10.264a.75.75 0 0 1 .529.4l4 8A.75.75 0 0 1 19 19.75H6a.75.75 0 0 1-.498-1.31l9-8a.75.75 0 0 1 .64-.176ZM7 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"></path></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h24v24H0z"></path></clipPath></defs>
</template>
<!-- h1 -->
<template v-else-if="commandId === 'h1'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M17.926 6.578v10.898c0 .602.33.963.862.963.541 0 .862-.351.862-.963V5.726c0-.682-.451-1.153-1.093-1.153-.39 0-.742.15-1.373.622l-2.026 1.504c-.4.29-.591.561-.591.852 0 .38.3.692.672.692.22 0 .43-.08.721-.291l1.885-1.374zM4.42 4.903a.77.77 0 0 1 .77.77v5.35h6.168v-5.35a.77.77 0 1 1 1.54 0v12.242a.77.77 0 0 1-1.54 0v-5.351H5.19v5.351a.77.77 0 1 1-1.54 0V5.673a.77.77 0 0 1 .77-.77"
clip-rule="evenodd"
/>
</template>
<!-- h2 -->
<template v-else-if="commandId === 'h2'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M14.159 16c-.562.638-.725.905-.725 1.248 0 .553.439.886 1.135.886h6.289c.524 0 .829-.276.829-.724 0-.457-.324-.734-.83-.734H15.57v-.114l3.526-4.031c1.715-1.953 2.201-2.859 2.201-4.098 0-2.096-1.648-3.583-3.993-3.583-2.515 0-4.088 1.697-4.088 3.317 0 .514.305.867.772.867.39 0 .658-.258.791-.763.286-1.238 1.191-1.972 2.42-1.972 1.449 0 2.411.896 2.411 2.24 0 .895-.41 1.695-1.486 2.925zM3.419 5.364c.404 0 .731.327.731.731v5.087h5.863V6.095a.732.732 0 1 1 1.464 0v11.637a.732.732 0 0 1-1.464 0v-5.087H4.15v5.087a.732.732 0 1 1-1.463 0V6.095c0-.404.327-.731.732-.731"
clip-rule="evenodd"
/>
</template>
<!-- h3 -->
<template v-else-if="commandId === 'h3'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M4.05 5.856a.75.75 0 0 0-1.5 0v11.921a.75.75 0 1 0 1.5 0v-5.21h6.006v5.21a.75.75 0 0 0 1.5 0V5.856a.75.75 0 0 0-1.5 0v5.21H4.05zm9.479 9.234c-.418 0-.713.304-.713.732 0 1.454 1.796 2.936 4.248 2.936 2.642 0 4.486-1.558 4.486-3.782 0-1.635-1.226-3.041-2.832-3.222v-.095c1.321-.228 2.395-1.596 2.395-3.031 0-1.977-1.692-3.393-4.068-3.393-2.338 0-3.925 1.425-3.925 2.898 0 .476.285.79.723.79.37 0 .608-.2.798-.723.38-.979 1.235-1.54 2.366-1.54 1.454 0 2.433.875 2.433 2.177s-1.007 2.242-2.395 2.242h-1.121c-.456 0-.76.295-.76.713 0 .409.323.723.76.723h1.188c1.654 0 2.765.978 2.765 2.432s-1.083 2.386-2.784 2.386c-1.293 0-2.281-.57-2.775-1.587-.247-.485-.456-.656-.789-.656"
clip-rule="evenodd"
/>
</template>
<!-- h4 -->
<template v-else-if="commandId === 'h4'">
<path fill="currentColor" d="M140-290v-380h60v160h180v-160h60v380h-60v-160H200v160h-60Zm580 0v-120H520v-260h60v200h140v-200h60v200h80v60h-80v120h-60Z"/>
</template>
<!-- quote -->
<template v-else-if="commandId === 'quote'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M5 10.966v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024H6.704A3.35 3.35 0 0 1 9.845 7.75v-1.5A4.845 4.845 0 0 0 5 10.966m8 0v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024h-3.415a3.35 3.35 0 0 1 3.141-2.192v-1.5A4.845 4.845 0 0 0 13 10.966"
clip-rule="evenodd"
/>
</template>
<!-- list -->
<template v-else-if="commandId === 'list'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M4.3 7.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2m4.033-1.75a.75.75 0 0 0 0 1.5l11.917.001a.75.75 0 0 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 1 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 0 0 0-1.5zM5.3 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1 6.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2"
clip-rule="evenodd"
/>
</template>
<!-- code -->
<template v-else-if="commandId === 'code'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M2.25 6A2.75 2.75 0 0 1 5 3.25h14A2.75 2.75 0 0 1 21.75 6v12A2.75 2.75 0 0 1 19 20.75H5A2.75 2.75 0 0 1 2.25 18zM5 4.75c-.69 0-1.25.56-1.25 1.25v12c0 .69.56 1.25 1.25 1.25h14c.69 0 1.25-.56 1.25-1.25V6c0-.69-.56-1.25-1.25-1.25zm5.53 4.62a.75.75 0 0 1 0 1.06l-1.59 1.591 1.59 1.591a.75.75 0 0 1-1.06 1.06l-2.122-2.12a.75.75 0 0 1 0-1.061L9.47 9.37a.75.75 0 0 1 1.06 0m2.94 1.06a.75.75 0 1 1 1.06-1.06l2.122 2.12a.75.75 0 0 1 0 1.062l-2.122 2.12a.75.75 0 1 1-1.06-1.06l1.59-1.59z"
clip-rule="evenodd"
/>
</template>
<!-- divider -->
<template v-else-if="commandId === 'divider'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M4 4.25a.75.75 0 0 1 .75.75v1c0 .69.56 1.25 1.25 1.25h12c.69 0 1.25-.56 1.25-1.25V5a.75.75 0 0 1 1.5 0v1A2.75 2.75 0 0 1 18 8.75H6A2.75 2.75 0 0 1 3.25 6V5A.75.75 0 0 1 4 4.25m0 15.5a.75.75 0 0 0 .75-.75v-1c0-.69.56-1.25 1.25-1.25h12c.69 0 1.25.56 1.25 1.25v1a.75.75 0 0 0 1.5 0v-1A2.75 2.75 0 0 0 18 15.25H6A2.75 2.75 0 0 0 3.25 18v1c0 .414.336.75.75.75m-1-8.5a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5H3m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5H7.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5h-1.2m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5h-1.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5H21a.75.75 0 0 0 0-1.5h-1.2"
clip-rule="evenodd"
/>
</template>
<!-- callout -->
<template v-else-if="commandId === 'callout'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="M12 18a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.5v6"></path>
</template>
<!-- toggle -->
<template v-else-if="commandId === 'toggle'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 11 12 15l-4.5-4"></path>
</template>
<!-- embed -->
<template v-else-if="commandId === 'embed'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm.5 16c0 .3-.2.5-.5.5H5c-.3 0-.5-.2-.5-.5V9.8l4.7-5.3H19c.3 0 .5.2.5.5v14zm-6-9.5L16 12l-2.5 2.8 1.1 1L18 12l-3.5-3.5-1 1zm-3 0l-1-1L6 12l3.5 3.8 1.1-1L8 12l2.5-2.5z"></path>
</template>
<!-- fallback -->
<template v-else>
<path
fill="currentColor"
fill-rule="evenodd"
d="M12 4.5c.46 0 .833.373.833.833v5.834h5.834a.833.833 0 0 1 0 1.666h-5.834v5.834a.833.833 0 0 1-1.666 0v-5.834H5.333a.833.833 0 0 1 0-1.666h5.834V5.333c0-.46.373-.833.833-.833"
clip-rule="evenodd"
/>
</template>
</svg>
</template>

View File

@@ -0,0 +1,257 @@
<script setup>
import {
buildCalloutOpenerLine,
CALLOUT_BACKGROUND_OPTIONS,
CALLOUT_EMOJI_OPTIONS
} from '../../lib/markdown-callout.js'
import ProseCallout from './ProseCallout.vue'
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
const props = defineProps({
/** 콜아웃 본문 */
modelValue: {
type: String,
default: ''
},
calloutEmojiEnabled: {
type: Boolean,
default: true
},
calloutEmoji: {
type: String,
default: '💡'
},
calloutBackground: {
type: String,
default: 'blue'
},
/** 본문 첫 줄 source-line(0-based) */
bodySourceLine: {
type: Number,
required: true
}
})
const emit = defineEmits(['commit'])
const settingsOpen = ref(false)
const emojiDraft = ref(props.calloutEmoji)
const emojiEnabled = ref(props.calloutEmojiEnabled)
const background = ref(props.calloutBackground)
/** @type {import('vue').ComputedRef<Array<{ value: string, label: string }>>} */
const backgroundOptions = computed(() => CALLOUT_BACKGROUND_OPTIONS.map((value) => ({
value,
label: value
})))
/** @type {import('vue').ComputedRef<string[]>} */
const emojiOptions = computed(() => CALLOUT_EMOJI_OPTIONS)
watch(() => props.calloutEmoji, (value) => {
emojiDraft.value = value
})
watch(() => props.calloutEmojiEnabled, (value) => {
emojiEnabled.value = value
})
watch(() => props.calloutBackground, (value) => {
background.value = value
})
/**
* 콜아웃 마크다운 줄을 반영한다.
* @param {string} body - 본문
* @returns {void}
*/
const commitCallout = (body) => {
const contentLines = String(body ?? '').replace(/\r/g, '').split('\n')
emit('commit', [
buildCalloutOpenerLine({
calloutEmojiEnabled: emojiEnabled.value,
calloutEmoji: emojiDraft.value,
calloutBackground: background.value
}),
...contentLines,
':::'
])
}
/**
* 본문 편집 반영
* @param {string} body - 본문
* @returns {void}
*/
const onBodyCommit = (body) => {
commitCallout(body)
}
/**
* 설정 모달을 연다.
* @returns {void}
*/
const openSettings = () => {
settingsOpen.value = true
}
/**
* 설정 모달을 닫는다.
* @returns {void}
*/
const closeSettings = () => {
settingsOpen.value = false
}
/**
* 이모지 표시를 토글한다.
* @returns {void}
*/
const toggleEmoji = () => {
emojiEnabled.value = !emojiEnabled.value
commitCallout(props.modelValue)
}
/**
* 배경색을 변경한다.
* @param {string} value - 배경 키
* @returns {void}
*/
const setBackground = (value) => {
background.value = value
commitCallout(props.modelValue)
}
/**
* 프리셋 이모지를 선택한다.
* @param {string} value - 이모지
* @returns {void}
*/
const setEmoji = (value) => {
emojiDraft.value = value
emojiEnabled.value = true
commitCallout(props.modelValue)
}
</script>
<template>
<div class="content-markdown-callout-editor relative">
<ProseCallout
:emoji-enabled="false"
:background="background"
>
<div class="content-markdown-callout-editor__inner flex items-start gap-2">
<button
class="content-markdown-callout-editor__emoji-btn inline-flex size-9 shrink-0 items-center justify-center rounded-md text-xl text-[var(--site-text)] transition-colors hover:bg-black/10"
type="button"
aria-label="콜아웃 아이콘·색상 설정"
@mousedown.prevent
@click.stop="openSettings"
>
<span v-if="emojiEnabled">{{ emojiDraft || '💡' }}</span>
<span v-else class="text-base text-[#8e9cac]">+</span>
</button>
<ContentMarkdownEditableInline
block-class="content-markdown-callout-editor__body min-w-0 flex-1 text-[15px] leading-8 text-[var(--site-text)]"
enter-mode="multiline"
:source-line="bodySourceLine"
:model-value="modelValue"
@commit="onBodyCommit"
/>
</div>
</ProseCallout>
<Teleport to="body">
<div
v-if="settingsOpen"
class="content-markdown-callout-editor__modal fixed inset-0 z-[80] grid place-items-center bg-black/35 px-4"
@click.self="closeSettings"
>
<section
class="content-markdown-callout-editor__panel w-full max-w-sm rounded-lg bg-white p-6 shadow-xl"
role="dialog"
aria-labelledby="callout-settings-title"
@click.stop
>
<header class="content-markdown-callout-editor__panel-header mb-4 flex items-center justify-between gap-3">
<h2 id="callout-settings-title" class="text-base font-semibold text-[#15171a]">
콜아웃 설정
</h2>
<button
class="content-markdown-callout-editor__close rounded px-2 py-1 text-sm text-[#6b7280] hover:bg-[#f1f3f4]"
type="button"
@click="closeSettings"
>
닫기
</button>
</header>
<div class="content-markdown-callout-editor__icon-toggle mb-4 flex items-center justify-between gap-3">
<span class="text-sm font-medium text-[#15171a]">아이콘</span>
<button
class="content-markdown-callout-editor__switch relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8e9cac]"
:class="emojiEnabled ? 'bg-[#15171a]' : 'bg-[#d0d7de]'"
type="button"
role="switch"
:aria-checked="emojiEnabled"
@click="toggleEmoji"
>
<span
class="content-markdown-callout-editor__switch-knob pointer-events-none inline-block size-5 rounded-full bg-white shadow transition-transform duration-200 ease-out"
:class="emojiEnabled ? 'translate-x-[22px]' : 'translate-x-0.5'"
/>
<span class="sr-only">{{ emojiEnabled ? '아이콘 표시' : '아이콘 숨김' }}</span>
</button>
</div>
<div
v-if="emojiEnabled"
class="content-markdown-callout-editor__emoji-presets mb-4"
>
<ul class="flex flex-wrap gap-2">
<li
v-for="emoji in emojiOptions"
:key="`callout-emoji-${emoji}`"
>
<button
class="content-markdown-callout-editor__emoji-swatch inline-flex size-9 items-center justify-center rounded-full border-2 text-xl transition-transform"
:class="emojiDraft === emoji ? 'border-[#22c55e] scale-110 bg-[#f6f7f8]' : 'border-transparent hover:bg-[#f1f3f4]'"
type="button"
:aria-label="`이모지 ${emoji}`"
@click="setEmoji(emoji)"
>
{{ emoji }}
</button>
</li>
</ul>
</div>
<div class="content-markdown-callout-editor__colors">
<p class="mb-2 text-sm font-medium text-[#15171a]">배경색</p>
<ul class="flex flex-wrap gap-2">
<li v-for="option in backgroundOptions" :key="`callout-bg-${option.value}`">
<button
class="content-markdown-callout-editor__color-swatch size-8 rounded-full border-2 transition-transform"
:class="background === option.value ? 'border-[#22c55e] scale-110' : 'border-transparent'"
type="button"
:aria-label="option.label"
:style="{
background: option.value === 'gray' ? 'rgba(100,116,139,0.28)'
: option.value === 'blue' ? 'rgba(59,130,246,0.3)'
: option.value === 'green' ? 'rgba(34,197,94,0.3)'
: option.value === 'yellow' ? 'rgba(245,158,11,0.34)'
: option.value === 'red' ? 'rgba(239,68,68,0.3)'
: option.value === 'purple' ? 'rgba(168,85,247,0.3)'
: 'rgba(236,72,153,0.3)'
}"
@click="setBackground(option.value)"
/>
</li>
</ul>
</div>
</section>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,185 @@
<script setup>
import { buildCodeBlockLines } from '../../lib/markdown-code-block.js'
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
const props = defineProps({
/** 코드 본문 */
modelValue: {
type: String,
default: ''
},
/** 언어(slug) */
language: {
type: String,
default: ''
},
/** 줄 번호 표시 */
showLineNumbers: {
type: Boolean,
default: true
},
/** 본문 첫 줄 source-line(0-based) */
bodySourceLine: {
type: Number,
required: true
}
})
const emit = defineEmits(['commit', 'insert-below'])
const languageDraft = ref(props.language)
const lineNumbersEnabled = ref(props.showLineNumbers)
const liveBody = ref(props.modelValue)
watch(() => props.language, (value) => {
languageDraft.value = value
})
watch(() => props.showLineNumbers, (value) => {
lineNumbersEnabled.value = value
})
watch(() => props.modelValue, (value) => {
liveBody.value = value
})
/** @type {import('vue').ComputedRef<string[]>} */
const bodyLines = computed(() => {
const text = String(liveBody.value ?? '')
if (!text.length) {
return ['']
}
return text.split('\n')
})
/** @type {import('vue').ComputedRef<number[]>} */
const gutterLines = computed(() => bodyLines.value.map((_, index) => index + 1))
/**
* 마크다운에 코드 블록을 반영한다.
* @param {string} body - 본문
* @returns {void}
*/
const commitCodeBlock = (body) => {
emit('commit', buildCodeBlockLines({
language: languageDraft.value,
showLineNumbers: lineNumbersEnabled.value,
body
}))
}
/**
* 본문 편집 반영
* @param {string} body - 본문
* @returns {void}
*/
const onBodyCommit = (body) => {
liveBody.value = body
commitCodeBlock(body)
}
/**
* 입력 중 줄 번호 갱신용 본문 동기화
* @param {string} body - 본문
* @returns {void}
*/
const onBodyInput = (body) => {
liveBody.value = body
}
/**
* 코드 블록 아래로 이탈(다음 문단 생성)
* @param {Object} payload - insert-below 페이로드
* @returns {void}
*/
const onExitBelow = (payload) => {
emit('insert-below', payload)
}
/**
* 언어 입력 반영
* @returns {void}
*/
const onLanguageCommit = () => {
commitCodeBlock(props.modelValue)
}
/**
* 줄 번호 표시를 토글한다.
* @returns {void}
*/
const toggleLineNumbers = () => {
lineNumbersEnabled.value = !lineNumbersEnabled.value
commitCodeBlock(props.modelValue)
}
</script>
<template>
<ProseCodeBlock
class="content-markdown-code-block-editor"
:show-line-numbers="lineNumbersEnabled"
:line-numbers="gutterLines"
>
<template #header-tools>
<div
class="content-markdown-code-block-editor__toolbar pointer-events-none flex items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
<button
class="content-markdown-code-block-editor__line-numbers pointer-events-auto rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/15 hover:text-white"
type="button"
:aria-pressed="lineNumbersEnabled"
:title="lineNumbersEnabled ? '줄 번호 숨기기' : '줄 번호 표시'"
@mousedown.prevent
@click="toggleLineNumbers"
>
{{ lineNumbersEnabled ? '줄번호' : '줄번호 끔' }}
</button>
<input
v-model="languageDraft"
class="content-markdown-code-block-editor__language pointer-events-auto w-[7.5rem] rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs text-white outline-none transition-colors placeholder:text-white/35 focus:border-white/30 focus:bg-white/15"
type="text"
placeholder="Language..."
spellcheck="false"
@mousedown.stop
@keydown.stop
@blur="onLanguageCommit"
@keydown.enter.prevent="onLanguageCommit"
>
</div>
</template>
<ContentMarkdownEditableInline
tag="pre"
block-class="content-markdown-code-block-editor__editor m-0 min-w-0 border-0 bg-transparent p-0 font-mono text-sm leading-6 text-white outline-none"
enter-mode="multiline"
plain-text
:source-line="bodySourceLine"
:model-value="modelValue"
@input="onBodyInput"
@commit="onBodyCommit"
@insert-below="onExitBelow"
/>
</ProseCodeBlock>
</template>
<style scoped>
.content-markdown-code-block-editor :deep(.prose-code-block__content) {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.content-markdown-code-block-editor :deep(.prose-code-block__header) {
pointer-events: none;
}
.content-markdown-code-block-editor__toolbar {
pointer-events: none;
}
.content-markdown-code-block-editor__toolbar > * {
pointer-events: auto;
}
</style>

View File

@@ -1,10 +1,12 @@
<script setup>
import {
convertEditableHtmlToMarkdown,
getRangeInnerHtml,
escapeHtml,
getEditableCaretOffset,
markdownInlineToHtml,
markdownMultilineInlineToHtml
readEditableTextFromElement,
setEditableCaretOffset
} from '../../lib/markdown-inline.js'
import { parseSlashInput } from '../../lib/markdown-slash-commands.js'
const props = defineProps({
/** 인라인 마크다운 원문 */
@@ -12,31 +14,32 @@ const props = defineProps({
type: String,
default: ''
},
/** 여러 줄(Shift+Enter 줄바꿈) 허용 */
multiline: {
type: Boolean,
default: false
},
/**
* Enter 동작
* - split-paragraph: 문단 분리
* - insert-below: 블록 아래 새 줄 삽입
* - none: 기본(제목 등 단일 줄 blur)
* - focus-next: Enter 시 blur 없이 enter-advance 발생
* - multiline: Enter 기본 동작(코드·콜아웃 본문 등)
*/
enterMode: {
type: String,
default: 'none'
},
/**
* ↑↓ 이동 범위
* - document: 렌더러 전체 블록
* - parent: 가장 가까운 data-editable-scope 컨테이너 안
*/
navigationScope: {
type: String,
default: 'document'
},
/** @deprecated enterMode 사용 */
splitOnEnter: {
type: Boolean,
default: false
},
/** Shift+Enter 줄바꿈 허용 */
allowHardBreak: {
type: Boolean,
default: false
},
/** 원본 마크다운 줄 번호(0-based) */
sourceLine: {
type: Number,
@@ -61,10 +64,38 @@ const props = defineProps({
allowRawToggle: {
type: Boolean,
default: false
},
/** 슬래시 메뉴가 열려 키보드 탐색 중인지 */
slashMenuActive: {
type: Boolean,
default: false
},
/** ESC 등으로 이 줄의 슬래시 메뉴를 닫은 상태(문자 `/`는 유지) */
slashCommandSuppressed: {
type: Boolean,
default: false
},
/** true면 인라인 마크다운 변환 없이 줄바꿈 유지(코드 블록 등) */
plainText: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['commit', 'split', 'insert-below', 'delete-line', 'raw-mode'])
const emit = defineEmits([
'commit',
'input',
'split',
'insert-below',
'delete-line',
'merge-with-previous',
'raw-mode',
'slash-update',
'slash-end',
'slash-apply',
'enter-advance',
'leave-block'
])
const rootRef = ref(null)
const isFocused = ref(false)
@@ -87,9 +118,14 @@ const resolvedEnterMode = computed(() => {
* modelValue를 편집용 HTML로 변환한다.
* @returns {string} HTML
*/
const toEditorHtml = () => (props.multiline
? markdownMultilineInlineToHtml(props.modelValue)
: markdownInlineToHtml(props.modelValue))
const toEditorHtml = () => markdownInlineToHtml(props.modelValue.replace(/\n/g, ' '))
/**
* plainText 모드용 편집 HTML을 만든다.
* @param {string} value - 본문
* @returns {string} HTML
*/
const plainTextToEditorHtml = (value) => escapeHtml(String(value ?? '')).replace(/\n/g, '<br>')
/**
* 편집 영역 HTML을 동기화한다.
@@ -105,15 +141,38 @@ const syncEditorHtml = () => {
return
}
if (props.plainText) {
rootRef.value.innerHTML = plainTextToEditorHtml(props.modelValue)
return
}
rootRef.value.innerHTML = toEditorHtml()
}
watch(() => [props.modelValue, props.rawLine], () => {
if (!isFocused.value) {
showingRaw.value = false
syncEditorHtml()
return
}
syncEditorHtml()
if (showingRaw.value) {
if (props.rawLine && rootRef.value && rootRef.value.textContent !== props.rawLine) {
rootRef.value.textContent = props.rawLine
}
return
}
const current = readEditorValue()
if (current !== props.modelValue) {
if (props.plainText) {
rootRef.value.innerHTML = plainTextToEditorHtml(props.modelValue)
} else {
rootRef.value.innerHTML = toEditorHtml()
}
}
})
onMounted(() => {
@@ -126,6 +185,43 @@ onMounted(() => {
*/
const onFocus = () => {
isFocused.value = true
syncSlashState()
}
/**
* 슬래시 명령 입력 상태를 부모에 알린다.
* @returns {void}
*/
const syncSlashState = () => {
if (props.sourceLine === null || showingRaw.value) {
emit('slash-end')
return
}
if (props.slashCommandSuppressed) {
return
}
const parsed = parseSlashInput(readEditorValue())
if (!parsed) {
emit('slash-end', { sourceLine: props.sourceLine })
return
}
emit('slash-update', {
sourceLine: props.sourceLine,
query: parsed.query,
value: parsed.raw
})
}
/**
* 입력 시 슬래시 상태를 갱신한다.
* @returns {void}
*/
const onEditorInput = () => {
syncSlashState()
}
/**
@@ -145,7 +241,7 @@ const readEditorValue = () => {
return rootRef.value.textContent ?? ''
}
return convertEditableHtmlToMarkdown(rootRef.value.innerHTML)
return readEditableTextFromElement(rootRef.value)
}
const onBlur = () => {
@@ -265,6 +361,30 @@ const getRawPrefixLength = () => {
return match ? match[1].length : 0
}
/**
* 편집 영역에 접힙지 않은 텍스트 선택이 있는지 확인한다.
* @returns {boolean}
*/
const hasNonCollapsedSelection = () => {
if (!import.meta.client || !rootRef.value) {
return false
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return false
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return false
}
return !range.collapsed
}
/**
* 커서가 줄의 논리적 맨 앞인지 확인한다(원문 접두사 직후 포함).
* @returns {boolean}
@@ -290,25 +410,109 @@ const isCaretAtLogicalStart = () => {
const isCaretAtLogicalEnd = () => isCaretAtEdge('end')
/**
* 이전·다음 편집 줄로 포커스를 옮긴다.
* @param {number} direction - -1 이전 줄, 1 다음 줄
* @param {boolean} cursorAtStart - 커서를 줄 앞에 둘지
* 커서가 위치한 시각 줄 정보를 반환한다.
* @returns {{ column: number, lineIndex: number, lines: string[], isFirstLine: boolean, isLastLine: boolean }}
*/
const getCaretLineContext = () => {
const fallback = {
column: 0,
lineIndex: 0,
lines: [''],
isFirstLine: true,
isLastLine: true
}
if (!import.meta.client || !rootRef.value) {
return fallback
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return fallback
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return fallback
}
const full = readEditorValue()
const offset = getEditableCaretOffset(rootRef.value, range)
const lines = full.length ? full.split('\n') : ['']
const before = full.slice(0, offset)
const lineIndex = before.split('\n').length - 1
const lineStart = before.lastIndexOf('\n') + 1
const column = offset - lineStart
return {
column,
lineIndex,
lines,
isFirstLine: lineIndex === 0,
isLastLine: lineIndex >= lines.length - 1
}
}
/**
* 텍스트 줄 배열에서 지정 줄·열의 오프셋을 계산한다.
* @param {string[]} lines - 줄 목록
* @param {number} lineIndex - 줄 인덱스
* @param {number} column - 열
* @returns {number} 오프셋
*/
const getOffsetForLineColumn = (lines, lineIndex, column) => {
let offset = 0
for (let index = 0; index < lineIndex; index += 1) {
offset += (lines[index] ?? '').length + 1
}
const line = lines[lineIndex] ?? ''
return offset + Math.min(Math.max(column, 0), line.length)
}
/**
* 인접 편집 블록으로 포커스를 옮긴다.
* @param {number} direction - -1 이전, 1 다음
* @param {number} column - 유지할 열(column 모드)
* @param {'column'|'block-start'|'block-end'} [caretMode='column'] - 커서 배치
* @returns {void}
*/
const navigateToAdjacentLine = (direction, cursorAtStart) => {
/**
* ↑↓ 이동 대상 편집 요소 목록을 반환한다.
* @returns {HTMLElement[]}
*/
const getNavigationElements = () => {
if (!import.meta.client || !rootRef.value) {
return []
}
const root = props.navigationScope === 'parent'
? rootRef.value.closest('[data-editable-scope]')
: rootRef.value.closest('.content-markdown-renderer')
if (!root) {
return []
}
return [...root.querySelectorAll('[data-source-line]')]
.sort((left, right) => Number(left.dataset.sourceLine) - Number(right.dataset.sourceLine))
}
const navigateToAdjacentBlock = (direction, column, caretMode = 'column') => {
if (!import.meta.client || props.sourceLine === null) {
return
}
const container = rootRef.value?.closest('.content-markdown-renderer')
const elements = getNavigationElements()
if (!container) {
if (!elements.length) {
return
}
const elements = [...container.querySelectorAll('[data-source-line]')]
.sort((left, right) => Number(left.dataset.sourceLine) - Number(right.dataset.sourceLine))
const currentIndex = elements.findIndex(
(element) => Number(element.dataset.sourceLine) === props.sourceLine
)
@@ -320,16 +524,113 @@ const navigateToAdjacentLine = (direction, cursorAtStart) => {
const target = elements[currentIndex + direction]
if (!target) {
if (resolvedEnterMode.value === 'multiline' && direction === 1) {
emit('insert-below', buildInsertBelowPayload())
}
return
}
target.focus()
const range = document.createRange()
range.selectNodeContents(target)
range.collapse(cursorAtStart)
if (props.navigationScope === 'parent') {
emit('leave-block', { direction })
}
target.focus({ preventScroll: true })
const scopedCaretMode = props.navigationScope === 'parent'
? (direction === 1 ? 'block-start' : 'block-end')
: caretMode
const targetText = readEditableTextFromElement(target)
const targetLines = targetText.length ? targetText.split('\n') : ['']
let targetLineIndex = direction === -1 ? targetLines.length - 1 : 0
let targetColumn = column
if (scopedCaretMode === 'block-start') {
targetLineIndex = 0
targetColumn = 0
} else if (scopedCaretMode === 'block-end') {
targetLineIndex = targetLines.length - 1
targetColumn = (targetLines[targetLineIndex] ?? '').length
} else {
targetColumn = Math.min(column, (targetLines[targetLineIndex] ?? '').length)
}
const targetOffset = getOffsetForLineColumn(targetLines, targetLineIndex, targetColumn)
setEditableCaretOffset(target, targetOffset)
}
/**
* 현재 블록의 맨 앞·맨 끝으로 커서를 옮긴다.
* @param {'start'|'end'} edge - 위치
* @returns {void}
*/
const setCaretAtBlockEdge = (edge) => {
if (!rootRef.value) {
return
}
if (edge === 'start') {
if (showingRaw.value) {
placeCursorAfterPrefix()
return
}
setEditableCaretOffset(rootRef.value, 0)
return
}
const full = readEditorValue()
setEditableCaretOffset(rootRef.value, full.length)
}
/**
* 커서 기준 앞·뒤 텍스트를 읽는다.
* @returns {{ before: string, after: string }}
*/
const readCaretSplit = () => {
if (!import.meta.client || !rootRef.value) {
return { before: props.modelValue, after: '' }
}
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
if (!selection || selection.rangeCount === 0) {
return { before: props.modelValue, after: '' }
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return { before: props.modelValue, after: '' }
}
const full = readEditorValue()
const offset = getEditableCaretOffset(rootRef.value, range)
return {
before: full.slice(0, offset),
after: full.slice(offset)
}
}
/**
* insert-below 이벤트 페이로드를 만든다.
* @returns {{ value: string, raw: boolean, before: string, after: string, caretAtStart: boolean, caretAtEnd: boolean }}
*/
const buildInsertBelowPayload = () => {
const { before, after } = readCaretSplit()
return {
value: readEditorValue(),
raw: showingRaw.value,
before,
after,
caretAtStart: isCaretAtLogicalStart(),
caretAtEnd: isCaretAtLogicalEnd()
}
}
/**
@@ -337,40 +638,7 @@ const navigateToAdjacentLine = (direction, cursorAtStart) => {
* @returns {void}
*/
const splitAtCaret = () => {
if (!import.meta.client || !rootRef.value) {
return
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
emit('split', { before: props.modelValue, after: '' })
return
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return
}
const beforeRange = document.createRange()
beforeRange.selectNodeContents(rootRef.value)
beforeRange.setEnd(range.startContainer, range.startOffset)
const afterRange = document.createRange()
afterRange.setStart(range.endContainer, range.endOffset)
if (rootRef.value.lastChild) {
afterRange.setEndAfter(rootRef.value.lastChild)
} else {
afterRange.setEnd(rootRef.value, 0)
}
const before = convertEditableHtmlToMarkdown(getRangeInnerHtml(beforeRange))
const after = convertEditableHtmlToMarkdown(getRangeInnerHtml(afterRange))
emit('split', { before, after })
emit('split', readCaretSplit())
}
/**
@@ -389,15 +657,14 @@ const scheduleEnterAction = (action) => {
if (action === 'split') {
splitAtCaret()
} else {
const nextValue = readEditorValue()
emit('insert-below', showingRaw.value ? { value: nextValue, raw: true } : nextValue)
emit('insert-below', buildInsertBelowPayload())
}
nextTick(() => {
splitLock.value = false
window.setTimeout(() => {
suppressBlurCommit.value = false
}, 50)
}, 180)
})
}
@@ -477,7 +744,22 @@ const onKeydown = (event) => {
if (
event.key === 'Backspace'
&& isCaretAtEdge('start')
&& !hasNonCollapsedSelection()
&& isCaretAtLogicalStart()
&& !showingRaw.value
&& props.sourceLine !== null
&& readEditorValue().trim()
) {
event.preventDefault()
event.stopPropagation()
emit('merge-with-previous', buildInsertBelowPayload())
return
}
if (
event.key === 'Backspace'
&& !hasNonCollapsedSelection()
&& isCaretAtLogicalStart()
&& !showingRaw.value
&& props.sourceLine !== null
&& !readEditorValue().trim()
@@ -490,6 +772,7 @@ const onKeydown = (event) => {
if (
event.key === 'Backspace'
&& !hasNonCollapsedSelection()
&& props.allowRawToggle
&& props.rawLine
&& !showingRaw.value
@@ -510,37 +793,80 @@ const onKeydown = (event) => {
return
}
if (event.key === 'ArrowUp' && isCaretAtLogicalStart()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(-1, false)
const hasCommandModifier = event.metaKey || event.ctrlKey
if (hasCommandModifier && !event.shiftKey && !event.altKey) {
if (event.key === 'ArrowLeft') {
event.preventDefault()
event.stopPropagation()
setCaretAtBlockEdge('start')
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
event.stopPropagation()
setCaretAtBlockEdge('end')
return
}
}
if (props.slashMenuActive) {
return
}
if (event.key === 'ArrowDown' && isCaretAtLogicalEnd()) {
if (!hasCommandModifier && !event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
if (event.isComposing || event.keyCode === 229) {
return
}
const lineContext = getCaretLineContext()
const direction = event.key === 'ArrowUp' ? -1 : 1
const isMultilineEditor = resolvedEnterMode.value === 'multiline'
if (isMultilineEditor) {
if (direction === -1 && !lineContext.isFirstLine) {
return
}
if (direction === 1 && !lineContext.isLastLine) {
return
}
}
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(1, true)
navigateToAdjacentBlock(direction, lineContext.column)
return
}
if (event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
if (!hasCommandModifier && !event.altKey && event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(-1, false)
navigateToAdjacentBlock(-1, 0, 'block-end')
return
}
if (event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
if (!hasCommandModifier && !event.altKey && event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(1, true)
navigateToAdjacentBlock(1, 0, 'block-start')
return
}
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
if (event.key === 'Enter' && !event.shiftKey && (enterMode === 'split-paragraph' || enterMode === 'insert-below')) {
if (event.key === 'Enter' && !event.shiftKey && parseSlashInput(readEditorValue())) {
event.preventDefault()
event.stopPropagation()
emit('slash-apply', {
sourceLine: props.sourceLine,
value: readEditorValue()
})
return
}
if (event.key === 'Enter' && (enterMode === 'split-paragraph' || enterMode === 'insert-below')) {
event.preventDefault()
event.stopPropagation()
@@ -554,11 +880,19 @@ const onKeydown = (event) => {
return
}
if (event.key === 'Enter' && event.shiftKey && props.allowHardBreak) {
if (event.key === 'Enter' && enterMode === 'focus-next') {
event.preventDefault()
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) {
return
}
emit('enter-advance')
return
}
if (event.key === 'Enter' && !props.multiline && enterMode === 'none') {
if (event.key === 'Enter' && enterMode === 'none') {
event.preventDefault()
event.stopPropagation()
rootRef.value?.blur()
@@ -596,16 +930,13 @@ const focusEditor = (position = 'end') => {
return
}
rootRef.value.focus()
const range = document.createRange()
range.selectNodeContents(rootRef.value)
range.collapse(position === 'start')
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
rootRef.value.focus({ preventScroll: true })
const textLength = readEditorValue().length
const offset = position === 'start' ? 0 : textLength
setEditableCaretOffset(rootRef.value, offset)
}
defineExpose({ focusEditor })
defineExpose({ focusEditor, readEditorValue })
</script>
<template>
@@ -623,6 +954,7 @@ defineExpose({ focusEditor })
spellcheck="true"
@focus="onFocus"
@blur="onBlur"
@input="onEditorInput"
@paste="onPaste"
@keydown="onKeydown"
@compositionend="onCompositionEnd"
@@ -630,6 +962,10 @@ defineExpose({ focusEditor })
</template>
<style scoped>
.content-markdown-editable-inline {
user-select: text;
}
.content-markdown-editable-inline--idle {
cursor: text;
border-radius: 4px;

View File

@@ -4,8 +4,11 @@ import {
getImageDisplayCaption,
parseImageMarkdownLine
} from '../../lib/markdown-image.js'
import { paragraphTextToSourceLines, parseInlineSegments } from '../../lib/markdown-inline.js'
import { parseInlineSegments, readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
import {
appendTextToMarkdownLine,
getAppendTextForMerge,
getMergeJunctionDisplayOffset,
hasListMarker,
hasQuoteMarker,
isEmptyListMarkerLine,
@@ -14,6 +17,13 @@ import {
stripListMarker,
stripQuoteMarker
} from '../../lib/markdown-live-edit.js'
import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
import { parseCalloutOptions } from '../../lib/markdown-callout.js'
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
import ContentMarkdownCalloutEditor from './ContentMarkdownCalloutEditor.vue'
import ContentMarkdownToggleEditor from './ContentMarkdownToggleEditor.vue'
const props = defineProps({
content: {
@@ -24,10 +34,30 @@ const props = defineProps({
interactive: {
type: Boolean,
default: false
},
/** 슬래시 명령 메뉴 키보드 탐색 중 */
slashMenuActive: {
type: Boolean,
default: false
},
/** ESC로 슬래시 메뉴를 닫은 원본 줄 번호(0-based) */
slashSuppressedLines: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['gallery-reorder', 'block-content-change', 'append-paragraph', 'insert-after-line', 'delete-line'])
const emit = defineEmits([
'gallery-reorder',
'block-content-change',
'append-paragraph',
'insert-after-line',
'delete-line',
'merge-with-previous-line',
'slash-update',
'slash-end',
'slash-apply'
])
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
@@ -41,6 +71,8 @@ const galleryDropTarget = ref(null)
const pendingFocusLine = ref(null)
/** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */
const pendingFocusPosition = ref('auto')
/** @type {import('vue').Ref<number|null>} 포커스 후 텍스트 오프셋 */
const pendingFocusOffset = ref(null)
const rendererRootRef = ref(null)
/** @type {import('vue').Ref<Set<number>>} 원문(raw) 편집 중인 목록 줄 */
const rawEditingSourceLines = ref(new Set())
@@ -91,51 +123,11 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
calloutEmoji: options.calloutEmoji || '💡',
calloutBackground: options.calloutBackground || 'blue'
calloutBackground: options.calloutBackground || 'blue',
codeLanguage: options.codeLanguage || '',
codeShowLineNumbers: options.codeShowLineNumbers !== false
})
const calloutBackgroundOptions = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
/**
* 콜아웃 선언부 옵션을 파싱
* @param {string} line - 콜아웃 선언 라인
* @returns {{calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string}} 콜아웃 옵션
*/
const parseCalloutOptions = (line) => {
const options = {
calloutEmojiEnabled: true,
calloutEmoji: '💡',
calloutBackground: 'blue'
}
const tokens = line.trim().split(/\s+/).slice(1)
tokens.forEach((token) => {
const [rawKey, ...rawValueParts] = token.split('=')
if (!rawKey || !rawValueParts.length) {
return
}
const key = rawKey.toLowerCase()
const value = rawValueParts.join('=').trim()
if (key === 'emoji') {
if (!value || value === 'none') {
options.calloutEmojiEnabled = false
options.calloutEmoji = '💡'
} else {
options.calloutEmojiEnabled = true
options.calloutEmoji = value
}
return
}
if (key === 'bg' && calloutBackgroundOptions.includes(value)) {
options.calloutBackground = value
}
})
return options
}
/**
* 이미지 마크다운 행을 이미지 데이터로 변환
* @param {string} line - 마크다운 행
@@ -143,6 +135,26 @@ const parseCalloutOptions = (line) => {
*/
const parseImageLine = (line) => parseImageMarkdownLine(line)
/**
* 인용 마커 줄인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean}
*/
const isQuoteMarkerLine = (line) => {
const trimmed = String(line ?? '').trim()
return trimmed === '>' || /^>\s/.test(trimmed)
}
/**
* 불릿 목록 마커 줄인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean}
*/
const isBulletListMarkerLine = (line) => {
const trimmed = String(line ?? '').trim()
return trimmed === '-' || /^-\s/.test(trimmed)
}
/**
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
* @param {string} line - 마크다운 행
@@ -169,14 +181,7 @@ const isMarkdownBlockStart = (line) => {
}
/**
* 마크다운 hard break 표식이 있는 행인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} hard break 여부
*/
const hasMarkdownHardBreak = (line) => /( {2,}|\\)$/.test(line)
/**
* 문단 행에서 hard break 표식을 제거한다.
* 문단 행에서 hard break 표식(레거시)을 제거한다.
* @param {string} line - 마크다운 행
* @returns {string} 정리된 문단 행
*/
@@ -444,6 +449,7 @@ const parseMarkdownBlocks = (markdown) => {
if (trimmedLine.startsWith('```')) {
const startLine = index
const fenceOptions = parseCodeFenceLine(trimmedLine) || { language: '', showLineNumbers: true }
const codeLines = []
index += 1
@@ -453,7 +459,10 @@ const parseMarkdownBlocks = (markdown) => {
}
blocks.push(attachSourceRange(
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`),
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`, {
codeLanguage: fenceOptions.language,
codeShowLineNumbers: fenceOptions.showLineNumbers
}),
startLine,
index
))
@@ -481,11 +490,11 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if (trimmedLine.startsWith('> ')) {
if (isQuoteMarkerLine(line)) {
const startLine = index
const quoteLines = []
while (index < lines.length && lines[index].trim().startsWith('>')) {
while (index < lines.length && isQuoteMarkerLine(lines[index])) {
quoteLines.push(lines[index].trim().replace(/^>\s?/, ''))
index += 1
}
@@ -498,12 +507,12 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if (/^- /.test(trimmedLine)) {
if (isBulletListMarkerLine(line)) {
const startLine = index
const items = []
while (index < lines.length && /^- /.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^- /, ''))
while (index < lines.length && isBulletListMarkerLine(lines[index])) {
items.push(lines[index].trim().replace(/^-\s?/, ''))
index += 1
}
@@ -531,29 +540,12 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
const paragraphStartLine = index
const paragraphLines = [cleanParagraphLine(line)]
let shouldJoinNextLine = hasMarkdownHardBreak(line)
index += 1
while (shouldJoinNextLine && index < lines.length) {
const nextLine = lines[index]
const nextTrimmedLine = nextLine.trim()
if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) {
break
}
paragraphLines.push(cleanParagraphLine(nextLine))
shouldJoinNextLine = hasMarkdownHardBreak(nextLine)
index += 1
}
blocks.push(attachSourceRange(
createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`),
paragraphStartLine,
index - 1
createBlock('paragraph', cleanParagraphLine(line), null, `block-${blocks.length}`),
index,
index
))
index += 1
}
return blocks
@@ -569,12 +561,14 @@ watch(() => props.content, () => {
const line = pendingFocusLine.value
const position = pendingFocusPosition.value
const offset = pendingFocusOffset.value
pendingFocusLine.value = null
pendingFocusPosition.value = 'auto'
pendingFocusOffset.value = null
nextTick(() => {
nextTick(() => {
focusEditableAtLine(line, 0, position)
focusEditableAtLine(line, 0, position, offset)
})
})
})
@@ -584,9 +578,10 @@ watch(() => props.content, () => {
* @param {number} lineIndex - 줄 번호(0-based)
* @param {number} [attempt=0] - DOM 탐색 재시도 횟수
* @param {'start'|'end'|'auto'} [cursorPosition='auto'] - 커서 위치
* @param {number|null} [caretOffset=null] - 텍스트 오프셋
* @returns {void}
*/
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto') => {
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null) => {
if (!import.meta.client) {
return
}
@@ -596,7 +591,7 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto') =>
if (!element) {
if (attempt < 8) {
requestAnimationFrame(() => {
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition)
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset)
})
}
@@ -611,17 +606,31 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto') =>
element.innerHTML = ''
}
element.focus()
const range = document.createRange()
range.selectNodeContents(element)
const collapseToStart = cursorPosition === 'start'
|| (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))
range.collapse(collapseToStart)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
element.focus({ preventScroll: true })
if (typeof caretOffset === 'number' && caretOffset >= 0) {
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
return
}
if (cursorPosition === 'start' || (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))) {
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
return
}
if (cursorPosition === 'end') {
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
setEditableCaretOffset(/** @type {HTMLElement} */ (element), text.length)
return
}
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
}
defineExpose({
focusEditableAtLine
})
/**
* 인라인 편집 원문 모드 표시 상태를 갱신한다.
* @param {{ sourceLine: number, active: boolean }} payload - 줄 번호·활성 여부
@@ -690,6 +699,33 @@ const normalizeCommitPayload = (payload) => {
}
}
/**
* 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 - 줄 번호
@@ -770,6 +806,20 @@ const formatListLine = (value, raw, block, itemIndex) => {
return clean ? `- ${clean}` : '- '
}
/**
* 빈 목록 마커 줄을 만든다.
* @param {Object} block - 목록 블록
* @param {number} itemIndex - 항목 인덱스
* @returns {string} 마크다운 줄
*/
const createEmptyListMarkerLine = (block, itemIndex) => {
if (block.ordered) {
return `${getListMarkerNumber(block, itemIndex)}. `
}
return '- '
}
/**
* 인용 블록을 줄 단위로 분리한다.
* @param {Object} block - 인용 블록
@@ -790,6 +840,18 @@ const getQuoteLines = (block) => {
return fromText.slice(0, lineCount)
}
/**
* 코드 블록 줄 번호 목록을 만든다.
* @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 - 블록
@@ -814,15 +876,81 @@ const commitInlineBlockLines = (block, replacementLines) => {
* @param {string} text - 편집된 텍스트
* @returns {void}
*/
const onParagraphInlineCommit = (block, text) => {
const value = String(text ?? '')
/**
* 코드 블록 편집 반영
* @param {Object} block - 코드 블록
* @param {string[]} replacementLines - 마크다운 줄
* @returns {void}
*/
const onCodeBlockCommit = (block, replacementLines) => {
commitInlineBlockLines(block, replacementLines)
}
if (value.includes('\n')) {
commitInlineBlockLines(block, paragraphTextToSourceLines(value))
/**
* 코드 블록 마지막 줄에서 아래로 이탈 — 본문 저장 후 다음 문단 삽입
* @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 {string[]} replacementLines - 마크다운 줄
* @returns {void}
*/
const onCalloutBlockCommit = (block, replacementLines) => {
commitInlineBlockLines(block, replacementLines)
}
/**
* 토글 편집 반영
* @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,
body: value
}))
pendingFocusPosition.value = 'start'
pendingFocusOffset.value = 0
onInsertBelowBlock(block, { lines: [''] })
}
const onParagraphInlineCommit = (block, text) => {
const value = String(text ?? '').replace(/\r/g, '')
const lines = value.split('\n').map((line) => line.trimEnd())
if (lines.length <= 1) {
commitInlineBlockLines(block, [lines[0] ?? ''])
return
}
commitInlineBlockLines(block, [value])
commitInlineBlockLines(block, lines.filter((line) => line.length > 0))
}
/**
@@ -839,6 +967,27 @@ const onSpacerInlineCommit = (block, text) => {
commitInlineBlockLines(block, [text])
}
/**
* 문단 Enter 분리 결과 줄 배열을 만든다.
* @param {string} head - 커서 앞 텍스트
* @param {string} tail - 커서 뒤 텍스트
* @returns {string[]} 분리된 줄
*/
const buildParagraphSplitLines = (head, tail) => {
const h = String(head ?? '').replace(/\n/g, ' ')
const t = String(tail ?? '').replace(/\n/g, ' ')
if (!t.length) {
return [h, '']
}
if (!h.length) {
return ['', t]
}
return [h, t]
}
/**
* 문단 Enter 분리 — 마크다운에 빈 줄을 넣어 다음 문단을 만든다.
* @param {Object} block - 블록
@@ -854,23 +1003,11 @@ const onParagraphSplit = (block, { before, after }) => {
lastParagraphSplitAt = now
const head = String(before ?? '')
const tail = String(after ?? '')
let replacementLines = []
let focusLine = block.meta.startLine
if (!tail.length) {
replacementLines = [head, '']
focusLine = block.meta.startLine + 1
} else if (!head.length) {
replacementLines = ['', tail]
focusLine = block.meta.startLine + 1
} else {
replacementLines = [head, '', tail]
focusLine = block.meta.startLine + 2
}
const replacementLines = buildParagraphSplitLines(before, after)
const focusLine = block.meta.startLine + Math.max(replacementLines.length - 1, 1)
pendingFocusLine.value = focusLine
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, replacementLines)
}
@@ -900,9 +1037,27 @@ const onInsertBelowBlock = (block, options = {}) => {
* @returns {void}
*/
const onListItemInsertBelow = (block, itemIndex, payload) => {
const { value, raw } = normalizeCommitPayload(payload)
const { value, raw, before, after, caretAtStart, caretAtEnd } = normalizeInsertBelowPayload(payload)
const nextLines = getBlockSourceLines(block)
if (caretAtStart && after.length) {
nextLines[itemIndex] = formatListLine(after, raw, block, itemIndex)
nextLines.splice(itemIndex, 0, createEmptyListMarkerLine(block, itemIndex))
pendingFocusLine.value = block.meta.startLine + itemIndex
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
return
}
if (before.length && after.length) {
nextLines[itemIndex] = formatListLine(before, raw, block, itemIndex)
nextLines.splice(itemIndex + 1, 0, formatListLine(after, raw, block, itemIndex + 1))
pendingFocusLine.value = block.meta.startLine + itemIndex + 1
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
return
}
if (itemIndex < nextLines.length) {
nextLines[itemIndex] = formatListLine(value, raw, block, itemIndex)
}
@@ -917,10 +1072,13 @@ const onListItemInsertBelow = (block, itemIndex, payload) => {
return
}
nextLines.splice(itemIndex + 1, 0, '')
pendingFocusLine.value = block.meta.startLine + itemIndex + 1
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
if (caretAtEnd || !before.length) {
pendingFocusLine.value = (block.meta.endLine ?? block.meta.startLine) + 1
pendingFocusPosition.value = 'start'
onInsertBelowBlock(block, { lines: [''] })
}
}
/**
@@ -991,9 +1149,27 @@ const onQuoteLineInlineCommit = (block, lineIndex, payload) => {
* @returns {void}
*/
const onQuoteLineInsertBelow = (block, lineIndex, payload) => {
const { value, raw } = normalizeCommitPayload(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)
}
@@ -1056,6 +1232,35 @@ const onDeleteLine = (lineIndex) => {
emit('delete-line', lineIndex)
}
/**
* 현재 줄을 이전 줄 끝에 병합한다.
* @param {number} lineIndex - 줄 번호
* @param {string|Object} payload - 편집 내용
* @returns {void}
*/
const onMergeWithPreviousLine = (lineIndex, payload) => {
if (typeof lineIndex !== 'number' || lineIndex <= 0) {
return
}
const { value, raw } = normalizeCommitPayload(payload)
const lines = String(props.content || '').split('\n')
const prevLine = lines[lineIndex - 1] ?? ''
const appendText = getAppendTextForMerge(value, prevLine, raw)
if (!appendText) {
onDeleteLine(lineIndex)
return
}
const mergedLine = appendTextToMarkdownLine(prevLine, appendText)
pendingFocusLine.value = lineIndex - 1
pendingFocusPosition.value = 'auto'
pendingFocusOffset.value = getMergeJunctionDisplayOffset(prevLine, raw)
emit('merge-with-previous-line', { lineIndex, mergedLine })
}
/**
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
@@ -1267,12 +1472,17 @@ onBeforeUnmount(() => {
tag="p"
block-class="content-markdown-renderer__paragraph content-markdown-renderer__spacer-line text-base text-[var(--site-text)]"
enter-mode="split-paragraph"
allow-hard-break
:slash-menu-active="slashMenuActive"
:slash-command-suppressed="slashSuppressedLines.includes(block.meta.startLine)"
:source-line="block.meta.startLine"
:model-value="''"
@commit="onSpacerInlineCommit(block, $event)"
@split="onParagraphSplit(block, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
@slash-update="emit('slash-update', $event)"
@slash-end="emit('slash-end', $event)"
@slash-apply="emit('slash-apply', $event)"
/>
<div v-else-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
<ContentMarkdownEditableInline
@@ -1287,6 +1497,7 @@ onBeforeUnmount(() => {
@commit="onHeadingInlineCommit(block, $event)"
@insert-below="onInsertBelowBlock(block)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
/>
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
@@ -1313,6 +1524,7 @@ onBeforeUnmount(() => {
@commit="onQuoteLineInlineCommit(block, quoteLineIndex, $event)"
@insert-below="onQuoteLineInsertBelow(block, quoteLineIndex, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + quoteLineIndex, $event)"
@raw-mode="onInlineRawMode"
/>
</ProseBlockquote>
@@ -1352,6 +1564,7 @@ onBeforeUnmount(() => {
@commit="onListItemInlineCommit(block, itemIndex, $event)"
@insert-below="onListItemInsertBelow(block, itemIndex, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + itemIndex, $event)"
@raw-mode="onInlineRawMode"
/>
</li>
@@ -1390,6 +1603,15 @@ onBeforeUnmount(() => {
:caption="getImageDisplayCaption(block)"
:variant="block.width"
/>
<ContentMarkdownCalloutEditor
v-else-if="block.type === 'callout' && interactive"
:callout-emoji-enabled="block.calloutEmojiEnabled"
:callout-emoji="block.calloutEmoji"
:callout-background="block.calloutBackground"
:body-source-line="block.meta.startLine + 1"
:model-value="block.text"
@commit="onCalloutBlockCommit(block, $event)"
/>
<ProseCallout
v-else-if="block.type === 'callout'"
:emoji-enabled="block.calloutEmojiEnabled"
@@ -1404,7 +1626,16 @@ onBeforeUnmount(() => {
<template v-else>{{ segment.text }}</template>
</template>
</ProseCallout>
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
<ContentMarkdownToggleEditor
v-else-if="block.type === 'toggle' && interactive"
:title="block.title"
:title-source-line="block.meta.startLine"
:body-source-line="block.meta.startLine + 1"
:model-value="block.text"
@commit="onToggleBlockCommit(block, $event)"
@insert-below="onToggleBlockInsertBelow(block, $event)"
/>
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'" animated>
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
@@ -1467,22 +1698,43 @@ onBeforeUnmount(() => {
</figcaption>
</figure>
</div>
<pre
<ContentMarkdownCodeBlockEditor
v-else-if="block.type === 'code' && interactive"
:language="block.codeLanguage"
:show-line-numbers="block.codeShowLineNumbers"
:body-source-line="block.meta.startLine + 1"
:model-value="block.text"
@commit="onCodeBlockCommit(block, $event)"
@insert-below="onCodeBlockInsertBelow(block, $event)"
/>
<ProseCodeBlock
v-else-if="block.type === 'code'"
class="content-markdown-renderer__code overflow-x-auto rounded bg-[#15171a] px-4 py-3 mb-2.5 text-sm leading-6 text-white"
><code>{{ block.text }}</code></pre>
class="content-markdown-renderer__code"
:language="block.codeLanguage"
:show-line-numbers="block.codeShowLineNumbers"
:line-numbers="getCodeLineNumbers(block)"
show-copy
:copy-text="block.text"
>
<code>{{ block.text }}</code>
</ProseCodeBlock>
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-5 border-line">
<ContentMarkdownEditableInline
v-else-if="block.type === 'paragraph' && interactive"
tag="p"
block-class="content-markdown-renderer__paragraph content-markdown-renderer__paragraph--editable mb-2.5 min-h-[1.75rem] text-base text-[var(--site-text)] last:mb-0"
enter-mode="split-paragraph"
allow-hard-break
:slash-menu-active="slashMenuActive"
:slash-command-suppressed="slashSuppressedLines.includes(block.meta.startLine)"
:source-line="block.meta.startLine"
:model-value="block.text"
@commit="onParagraphInlineCommit(block, $event)"
@split="onParagraphSplit(block, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
@slash-update="emit('slash-update', $event)"
@slash-end="emit('slash-end', $event)"
@slash-apply="emit('slash-apply', $event)"
/>
<p v-else-if="block.type === 'paragraph'" class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
@@ -1593,6 +1845,10 @@ onBeforeUnmount(() => {
min-height: 1.75rem;
}
.content-markdown-renderer {
user-select: text;
}
.content-markdown-renderer__gallery-item--interactive {
cursor: grab;
}

View File

@@ -0,0 +1,154 @@
<script setup>
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
import ProseToggle from './ProseToggle.vue'
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
const props = defineProps({
/** 토글 제목 */
title: {
type: String,
default: ''
},
/** 토글 본문 */
modelValue: {
type: String,
default: ''
},
/** 선언 줄 source-line(0-based) */
titleSourceLine: {
type: Number,
required: true
},
/** 본문 첫 줄 source-line(0-based) */
bodySourceLine: {
type: Number,
required: true
}
})
const emit = defineEmits(['commit', 'insert-below'])
const titleEditorRef = ref(null)
const bodyEditorRef = ref(null)
const titleDraft = ref(props.title)
watch(() => props.title, (value) => {
titleDraft.value = value
})
/**
* 토글 마크다운을 반영한다.
* @param {{ title?: string, body?: string }} options - 옵션
* @returns {void}
*/
const commitToggle = (options = {}) => {
emit('commit', buildToggleBlockLines({
title: options.title ?? titleDraft.value,
body: options.body ?? props.modelValue
}))
}
/**
* 제목 편집 반영
* @param {string} value - 제목
* @returns {void}
*/
const onTitleCommit = (value) => {
titleDraft.value = String(value ?? '').trim()
commitToggle({ title: titleDraft.value })
}
/**
* 제목 필드 이탈 전 로컬 초안 동기화(한글 조합·↓ 이동 시 본문 오염 방지)
* @returns {void}
*/
const syncTitleDraft = () => {
titleDraft.value = String(titleEditorRef.value?.readEditorValue?.() ?? titleDraft.value).trim()
}
/**
* 제목 Enter — 본문으로 포커스 이동
* @returns {void}
*/
const onTitleEnterAdvance = () => {
const nextTitle = String(titleEditorRef.value?.readEditorValue?.() ?? titleDraft.value).trim()
titleDraft.value = nextTitle
commitToggle({ title: titleDraft.value })
nextTick(() => {
bodyEditorRef.value?.focusEditor('start')
})
}
/**
* 본문 편집 반영
* @param {string} body - 본문
* @returns {void}
*/
const onBodyCommit = (body) => {
commitToggle({ body })
}
/**
* 토글 아래로 이탈
* @param {Object} payload - insert-below 페이로드
* @returns {void}
*/
const onExitBelow = (payload) => {
emit('insert-below', payload)
}
</script>
<template>
<ProseToggle
class="content-markdown-toggle-editor"
data-editable-scope
:title="titleDraft"
default-open
animated
>
<template #title>
<ContentMarkdownEditableInline
ref="titleEditorRef"
tag="div"
block-class="content-markdown-toggle-editor__title min-h-[1.75rem] outline-none"
enter-mode="focus-next"
navigation-scope="parent"
:source-line="titleSourceLine"
:model-value="titleDraft"
@mousedown.stop
@click.stop
@commit="onTitleCommit"
@enter-advance="onTitleEnterAdvance"
@leave-block="syncTitleDraft"
/>
</template>
<ContentMarkdownEditableInline
ref="bodyEditorRef"
tag="div"
block-class="content-markdown-toggle-editor__body min-h-[3rem] outline-none"
enter-mode="multiline"
navigation-scope="parent"
plain-text
:source-line="bodySourceLine"
:model-value="modelValue"
@commit="onBodyCommit"
@insert-below="onExitBelow"
/>
</ProseToggle>
</template>
<style scoped>
.content-markdown-toggle-editor__title:empty::before {
content: '토글 제목';
color: var(--site-muted);
pointer-events: none;
}
.content-markdown-toggle-editor__body:empty::before {
content: '펼쳤을 때 보일 내용을 입력하세요';
color: var(--site-muted);
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup>
const props = defineProps({
/** 언어 라벨(공개 화면 표시) */
language: {
type: String,
default: ''
},
/** 줄 번호 표시 */
showLineNumbers: {
type: Boolean,
default: false
},
/** 복사 버튼 표시(공개 화면) */
showCopy: {
type: Boolean,
default: false
},
/** 복사할 코드 본문 */
copyText: {
type: String,
default: ''
},
/** 줄 번호 목록 */
lineNumbers: {
type: Array,
default: () => [1]
}
})
const copyDone = ref(false)
let copyDoneTimer = null
/**
* 코드 본문을 클립보드에 복사한다.
* @returns {Promise<void>}
*/
const copyToClipboard = async () => {
const text = String(props.copyText ?? '')
if (!import.meta.client || !text) {
return
}
try {
await navigator.clipboard.writeText(text)
copyDone.value = true
window.clearTimeout(copyDoneTimer)
copyDoneTimer = window.setTimeout(() => {
copyDone.value = false
}, 1600)
} catch {
copyDone.value = false
}
}
onBeforeUnmount(() => {
window.clearTimeout(copyDoneTimer)
})
</script>
<template>
<div
class="prose-code-block group relative mb-2.5 overflow-x-auto rounded bg-[#15171a] text-sm leading-6 text-white"
>
<div
v-if="$slots['header-tools'] || showCopy || language"
class="prose-code-block__header absolute right-3 top-2 z-10 flex items-center gap-2"
>
<slot name="header-tools" />
<span
v-if="language && !$slots['header-tools']"
class="prose-code-block__language text-xs text-white/50"
>{{ language }}</span>
<button
v-if="showCopy"
class="prose-code-block__copy rounded px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/10 hover:text-white"
type="button"
@click="copyToClipboard"
>
{{ copyDone ? '복사됨' : '복사' }}
</button>
</div>
<div class="prose-code-block__body flex">
<div
v-if="showLineNumbers"
class="prose-code-block__gutter shrink-0 select-none border-r border-white/10 py-3 pl-3 pr-2 font-mono text-xs leading-6 text-white/40 tabular-nums"
aria-hidden="true"
>
<div
v-for="lineNumber in lineNumbers"
:key="`prose-code-gutter-${lineNumber}`"
class="prose-code-block__gutter-line"
>
{{ lineNumber }}
</div>
</div>
<div class="prose-code-block__content min-w-0 flex-1 px-4 py-3 font-mono text-sm leading-6">
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.prose-code-block:focus-within {
outline: 2px solid rgb(255 255 255 / 0.22);
outline-offset: 0;
}
.prose-code-block__content :deep(code) {
display: block;
white-space: pre-wrap;
word-break: break-word;
background: transparent;
padding: 0;
color: inherit;
font-size: inherit;
line-height: inherit;
}
</style>

View File

@@ -1,19 +1,112 @@
<script setup>
defineProps({
const props = defineProps({
/** 접힌 상태 제목 */
title: {
type: String,
required: true
default: ''
},
/** 초기 펼침 여부 */
defaultOpen: {
type: Boolean,
default: false
},
/** 본문 열림·닫힘 애니메이션 */
animated: {
type: Boolean,
default: true
}
})
const isOpen = ref(props.defaultOpen)
watch(() => props.defaultOpen, (value) => {
isOpen.value = value
})
/**
* 토글 펼침 상태를 전환한다.
* @returns {void}
*/
const toggleOpen = () => {
isOpen.value = !isOpen.value
}
</script>
<template>
<details class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
<summary class="prose-toggle__summary cursor-pointer text-[15px] font-semibold leading-7 text-[var(--site-text)]">
{{ title }}
</summary>
<div class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]">
<slot />
<div
class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5"
:class="{ 'prose-toggle--open': isOpen }"
>
<div class="prose-toggle__header flex items-start gap-2">
<button
class="prose-toggle__trigger mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[var(--site-muted)] transition-colors hover:bg-black/5 hover:text-[var(--site-text)]"
type="button"
:aria-expanded="isOpen"
aria-label="토글 펼치기·접기"
@click="toggleOpen"
>
<svg
class="prose-toggle__chevron size-4 transition-transform duration-300 ease-out"
:class="{ 'prose-toggle__chevron--open': isOpen }"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M6 4l4 4-4 4"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<div class="prose-toggle__title min-w-0 flex-1 text-[15px] font-semibold leading-7 text-[var(--site-text)]">
<slot name="title">
{{ title || '더 보기' }}
</slot>
</div>
</div>
</details>
<div
class="prose-toggle__body-shell"
:class="[
animated ? 'prose-toggle__body-shell--animated' : '',
isOpen ? 'prose-toggle__body-shell--open' : ''
]"
>
<div class="prose-toggle__body-inner min-h-0 overflow-hidden">
<div
class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]"
>
<slot />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.prose-toggle__chevron--open {
transform: rotate(90deg);
}
.prose-toggle__body-shell--animated {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.32s ease;
}
.prose-toggle__body-shell--animated.prose-toggle__body-shell--open {
grid-template-rows: 1fr;
}
.prose-toggle__body-shell:not(.prose-toggle__body-shell--animated) .prose-toggle__body-inner {
display: none;
}
.prose-toggle__body-shell:not(.prose-toggle__body-shell--animated).prose-toggle__body-shell--open .prose-toggle__body-inner {
display: block;
}
</style>

View File

@@ -0,0 +1,58 @@
<script setup>
const props = defineProps({
/** 커버 이미지 URL */
imageUrl: {
type: String,
default: ''
},
/** 오버레이 제목 */
title: {
type: String,
default: ''
},
/** 오버레이 본문 */
text: {
type: String,
default: ''
}
})
/** @type {import('vue').ComputedRef<boolean>} */
const hasOverlay = computed(() => Boolean(props.title?.trim() || props.text?.trim()))
</script>
<template>
<section
v-if="imageUrl"
class="home-hero relative mx-auto w-full max-w-[720px] overflow-hidden rounded-[10px]"
data-home-hero
>
<div class="home-hero__frame relative aspect-[720/215] w-full bg-[var(--site-panel)]">
<img
class="home-hero__cover absolute inset-0 h-full w-full object-cover"
:src="imageUrl"
alt=""
loading="eager"
>
<div
v-if="hasOverlay"
class="home-hero__overlay pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/35 to-transparent px-4 pb-4 pt-12 sm:px-5 sm:pb-5"
>
<div class="home-hero__overlay-inner flex flex-col items-start gap-1 text-left">
<h2
v-if="title"
class="home-hero__title text-base font-semibold leading-snug text-white sm:text-lg"
>
{{ title }}
</h2>
<p
v-if="text"
class="home-hero__text max-w-[32rem] text-sm leading-relaxed text-white/85"
>
{{ text }}
</p>
</div>
</div>
</div>
</section>
</template>

View File

@@ -8,16 +8,15 @@ defineProps({
</script>
<template>
<article class="post-card site-section site-panel-hover">
<article class="post-card site-section site-panel-hover group">
<div class="post-card__body site-section-body flex gap-4">
<img
v-if="post.featuredImage"
class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-surface object-cover"
:src="post.featuredImage"
:alt="post.title"
loading="lazy"
>
<div v-else class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-[linear-gradient(135deg,#06333a,#f4a261)]" />
<PostCardMedia
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
link-class="h-20 w-36 shrink-0"
aspect-class="h-full w-full"
/>
<div class="post-card__content min-w-0">
<h2 class="post-card__title text-base font-semibold leading-tight">
<NuxtLink class="post-card__title-link site-interactive hover:opacity-70" :to="post.to">

View File

@@ -0,0 +1,62 @@
<script setup>
defineProps({
/** 게시물 링크 */
to: {
type: String,
required: true
},
/** 게시물 제목 */
title: {
type: String,
required: true
},
/** 대표 이미지 URL */
featuredImage: {
type: String,
default: ''
},
/** 썸네일 비율·크기 Tailwind 클래스 */
aspectClass: {
type: String,
default: 'aspect-square sm:aspect-video'
},
/** 링크 래퍼 추가 클래스 */
linkClass: {
type: String,
default: ''
},
/** 이미지 추가 클래스 */
imageClass: {
type: String,
default: ''
}
})
</script>
<template>
<NuxtLink
:to="to"
class="post-card-media relative block"
:class="linkClass"
data-post-card-media
>
<figure class="post-card-media__figure overflow-hidden rounded-[10px]">
<img
v-if="featuredImage"
class="post-card-media__image w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
:class="[aspectClass, imageClass]"
:src="featuredImage"
:alt="title"
loading="lazy"
>
<span
v-else
class="post-card-media__placeholder flex w-full items-center justify-center rounded-[inherit] bg-[#F7F4EF] p-4 text-center text-xs leading-snug text-[var(--site-muted)] transition-opacity duration-200 group-hover:opacity-90"
:class="aspectClass"
:aria-label="title"
>
<span class="post-card-media__placeholder-text line-clamp-4">{{ title }}</span>
</span>
</figure>
</NuxtLink>
</template>

View File

@@ -166,14 +166,6 @@ onBeforeUnmount(() => {
@click.prevent="toggleMenu"
>
<span v-if="menuOpen" class="site-header__menu-icon pointer-events-none">
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
</svg>
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
</svg>
</span>
<span v-else class="site-header__menu-icon pointer-events-none">
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
<path d="M9 4v16" />
@@ -184,6 +176,14 @@ onBeforeUnmount(() => {
<path d="m14 10 2 2-2 2" />
</svg>
</span>
<span v-else class="site-header__menu-icon pointer-events-none">
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
</svg>
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
</svg>
</span>
</button>
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
</NuxtLink>
@@ -195,7 +195,13 @@ onBeforeUnmount(() => {
aria-label="검색 열기"
@click="openSearchModal"
>
<span class="site-header__search-icon mr-2 text-lg leading-none"></span>
<span class="site-header__search-icon mr-2 text-lg leading-none">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="10" cy="10" r="7"></circle>
<line x1="21" y1="21" x2="15" y2="15"></line>
</svg>
</span>
<span class="site-header__search-text site-soft">Search</span>
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
</button>

View File

@@ -0,0 +1,4 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS home_cover_image_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS home_cover_title TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS home_cover_text TEXT NOT NULL DEFAULT '';

View File

@@ -142,6 +142,7 @@ docker compose --env-file .env.production exec sori-studio-db psql -U sori_studi
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/024_navigation_recommended_location.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/025_posts_status_no_private.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/026_site_settings_show_post_updated_at.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/027_site_settings_home_cover.sql
```
### Docker 네트워크 충돌 대응

View File

@@ -57,7 +57,9 @@
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 `GET /api/navigation``recommended` 카드 목록(외부 URL은 Google 파비콘 프록시 썸네일), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너·왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(대표 이미지 없으면 제목 텍스트 플레이스홀더) |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
@@ -67,7 +69,7 @@
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 툴바 `이미지`·`갤러리` + 미디어 모달, 커서 블록 컨텍스트·`block-panel` emit |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달, 커서 블록 컨텍스트·`block-panel` emit |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드) |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
@@ -90,9 +92,12 @@
| components/content/ProseImage.vue | 본문 내 이미지 |
| components/content/ProseList.vue | 목록 |
| components/content/ProseBlockquote.vue | 인용구 |
| components/content/ProseCodeBlock.vue | 코드 블록 공통 셸(다크 배경, 줄번호 gutter, 공개 복사 버튼) |
| components/content/ContentMarkdownCodeBlockEditor.vue | 라이브 모드 코드 블록 인라인 편집(Language·줄번호 토글) |
| components/content/ProseButton.vue | 버튼 |
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 배경 프리셋, 상단 여백 중심) |
| components/content/ProseToggle.vue | Toggle 카드 |
| components/content/ProseToggle.vue | Toggle 카드(펼침 애니메이션, chevron 트리거) |
| components/content/ContentMarkdownToggleEditor.vue | 라이브 모드 토글 제목·본문 인라인 편집 |
| components/content/ProseVideo.vue | 비디오 |
| components/content/ProseAudio.vue | 오디오 |
| components/content/ProseFile.vue | 파일 |
@@ -120,7 +125,7 @@
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·일반 강등, 일반 태그 검색/메인 전환/삭제, 일반 태그 헤더의 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 수정일 표시 토글·저장), 블로그 제목·설명·기타(로고·URL·저작권), 타임존·어나운스·Import/Export·스팸 플레이스홀더 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 수정일 표시 토글·저장), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(커버 이미지·오버레이 텍스트), 타임존·어나운스·Import/Export·스팸 플레이스홀더 |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
@@ -129,7 +134,7 @@
| 파일 | 화면 |
|------|------|
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 `HomeHero`, Featured/Latest, Latest 피드 List·Compact·Cards 보기, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |

View File

@@ -185,9 +185,8 @@ components/content/
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스, Esc 닫기·←/→ 이전·다음)
- 문단과 줄바꿈
- 관리자 Markdown-first 에디터에서 일반 Enter는 브라우저 기본 단일 줄 이동으로 새 문단을 만든다.
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 수정 모드에서 보이는 마크다운 hard break(`\\ + 줄바꿈`)를 삽입한다.
- 공개 본문 렌더러는 줄끝 백슬래시 또는 공백 2개 hard break가 있는 행만 같은 문단으로 묶고 `<br>`로 표시한다.
- 관리자 Markdown-first 에디터에서 Enter는 새 문단(마크다운 한 줄)만 사용한다. Shift+Enter·문단 내 hard break는 지원하지 않는다.
- 공개 본문 렌더러는 마크다운 한 줄을 한 문단으로 렌더링한다(레거시 줄끝 `\\`/공백 2개 표식은 표시 시 제거).
- 내용 없는 빈 줄과 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)는 spacer 블록으로 렌더링해 작성자가 비운 줄 수만큼 공백을 보존한다.
- 문단 하단 기본 간격은 10px(`mb-2.5`) 기준이며, 문단 글자 크기는 `ContentMarkdownRenderer` 문단에 `text-base`(16px·`1rem`)만 지정하고 행간은 Tailwind·브라우저 기본에 맡긴다.
- 제목은 `ProseHeading`에서 단계별 크기·굵기를 적용하고, 첫 제목(`first:`)을 제외한 상단 추가 여백은 컴포넌트 스타일로만 조정한다.
@@ -436,8 +435,9 @@ components/content/
- `DELETE /admin/api/tags/:id` - 태그 삭제
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt` 포함)
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`: 발행 후 수정일 보조 표시 여부)
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt` 포함)
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, `homeCoverImageUrl`, `homeCoverTitle`, `homeCoverText` 포함)
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 업로드(720px WebP)
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 홈 커버 필드 포함)
- `GET /admin/api/navigation` - 네비게이션 항목 목록
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
@@ -472,7 +472,7 @@ components/content/
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
- 관리자 게시물/페이지 저장 API는 레거시 블록 배열·객체 본문 값이 들어와도 마크다운 문자열로 정규화한 뒤 저장한다.
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
- 본문 작성 모드에서 일반 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 처리한다. 일반 Enter는 단일 줄 이동으로 보여야 하며, Shift+Enter는 수정 모드에서도 보이는 줄끝 백슬래시 hard break를 남긴다.
- 본문 작성 모드에서 Enter·Shift+Enter 모두 브라우저 기본 줄바꿈(한 줄)으로 동작한다. 문단 구분은 빈 줄로 한다.
- 클립보드에 `text/html`이 있으면 제목, 문단, 목록, 인용, 코드, 링크, 굵게, 기울임, 이미지를 기본 마크다운 조각으로 변환해 삽입한다.
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용하며, 툴바와 카드형 패널 외곽을 숨겨 본문만 표시한다.
- 툴바는 제목 1/2/3, 굵게, 기울임, 인라인 코드, 인용, 목록, 코드 블록, 구분선 삽입을 제공한다.
@@ -480,7 +480,7 @@ components/content/
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
- 관리자 미리보기에서 `ContentMarkdownRenderer``interactive`를 켠다. 갤러리 이미지는 드래그로 순서를 바꿀 수 있으며, 드래그 중 다른 셀 위에 올리면 해당 셀에 주황 테두리와 「여기로 이동」 표시로 드롭 위치를 보여 준 뒤 `gallery-reorder`로 마크다운을 갱신한다.
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록 항목을 렌더 스타일 그대로 contenteditable로 수정한다. **Enter**는 다음 문단(마크다운 빈 줄), **Shift+Enter**만 같은 문단 내 줄바꿈. 본문 하단 클릭으로 새 문단을 추가한다. 변경은 `block-content-change`·`append-paragraph`로 소스에 반영되며 소스 모드 줄 번호와 일치한다.
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하며, 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). 콜아웃 옵션은 첫 줄 `:::callout emoji=💡 bg=blue`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple|pink)로 지정하며, 라이브 모드에서는 아이콘 클릭으로 모달에서 편집한다(이모지 7종 프리셋·배경색 스와치, 직접 입력 없음). 코드 블록은 ` ```언어`·`nolinenos`(줄 번호 숨김)를 지원한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url)` 형식으로 삽입한다.
@@ -544,7 +544,7 @@ components/content/
- 콜아웃 블록은 선언부 옵션으로 `emoji`, `bg`를 저장할 수 있다. 예: `:::callout emoji=💡 bg=blue`
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
- 콜아웃 배경 프리셋은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`를 지원한다.
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 제목·본문을 인라인 편집하며, chevron으로 펼침·접힘 시 본문이 애니메이션된다.
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링한다.
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다.
@@ -564,6 +564,7 @@ components/content/
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존·어나운스 바·게시물 Import/Export·스팸 필터는 현재 **메뉴·안내 카드만** 제공하고 저장 API는 연결하지 않는다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다.
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- **메인 화면**(`home_cover_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 커버 이미지는 `/admin/api/settings/home-cover`로 업로드(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`). 텍스트·이미지 제거는 `PUT /admin/api/settings`로 저장한다.
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록·공개 글 상세에 수정 시각 보조 줄을 표시할지 여부.
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp``/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.

View File

@@ -1,5 +1,40 @@
# 업데이트 이력
## v1.2.9
- 홈 상단: Ghost형 헤딩·구독 폼 제거. 사이트 설정「메인 화면」에서 커버 이미지(720px)·오버레이 제목·본문 설정. `HomeHero.vue`, 마이그레이션 `027_site_settings_home_cover.sql`.
- 홈 Latest 피드: List(썸네일+본문)·Compact(텍스트만)·Cards(2열) 보기 구분. 메뉴 List/Compact 선택값과 레이아웃 일치. Default 클릭 시 Compact로 전환. Cards 상단 여백·테두리 클리핑 수정.
- 게시물 카드: 대표 이미지 없을 때 썸네일 영역에 제목 텍스트 플레이스홀더(`PostCardMedia`). 홈 Latest·태그·게시물 목록 공통.
- 슬래시 메뉴: 키보드 ↓ 이동 시 scrollIntoView+mouseenter 충돌로 하단 항목이 반복 선택되던 문제 수정.
- 라이브 토글: 제목 Enter→본문 포커스, ↑↓는 토글 내부만 이동(한글 조합·스크롤 점프 보정).
- 라이브 토글: `ContentMarkdownToggleEditor`로 제목·본문 인라인 편집. `ProseToggle` 펼침·접힘 애니메이션(그리드 전환).
- 라이브 코드 블록: Enter 줄바꿈·줄번호 반영 수정(`<br>` 읽기). 마지막 줄 ↓로 블록 이탈·다음 문단 삽입.
- 콜아웃 설정: 이모지 7종 프리셋 선택, 직접 입력 제거. 아이콘 토글 UI 정리.
- 코드 블록: `ProseCodeBlock.vue`로 라이브·공개 스타일 통일(`#15171a`, 줄번호 gutter). 라이브 호버 시 Language·줄번호 토글. 공개 화면 언어 라벨 옆 복사 버튼.
- 라이브 콜아웃: 아이콘 호버·클릭 시 설정 모달(이모지·배경색).
- 라이브 모드 코드 블록: `pre` 단일 편집 영역으로 공개 화면과 높이 맞춤, ↑↓는 블록 첫·마지막 줄에서만 밖으로 이동.
- 라이브 모드: `- `·`> ` 마커만 있는 줄도 목록·인용 블록으로 인식. 코드·콜아웃 본문 인라인 편집 추가.
- 슬래시 콜아웃 기본 삽입을 `:::callout emoji=💡 bg=blue` 형식으로 변경.
- 소스(작성) 모드 textarea에 `/` 슬래시 명령 추가(미리보기와 동일). 상단 마크다운 툴바 제거.
- 라이브·소스 슬래시: 기본 제목은 h2·h3·h4만 노출. h1은 `/h1` 검색 시에만 선택 가능(게시물 제목이 유일한 h1).
- 라이브 모드 슬래시: h2 등 명령 적용 후 `## ` 뒤로 포커스·커서 복원. Esc는 메뉴만 닫고 줄·`/…` 입력 유지(해당 줄 메뉴 재오픈 억제). Esc 시 줄 비우기로 글이 사라지던 문제 수정.
- 라이브 모드 슬래시 메뉴 Ghost 스타일: 아이콘+라벨만 표시, 호버·포커스 시 오른쪽 `/slug` 표시. `AdminSlashCommandIcon.vue` 추가.
- 라이브 모드 슬래시: 미디어 모달 Esc 닫기, ↑↓ 시 목록 scrollIntoView, 마우스·키보드 하이라이트 단일화.
- 라이브 모드 슬래시 명령: `/` 입력 시 메뉴, `/image`+Enter 이미지 삽입, ↑↓ 선택·Esc 취소. 메뉴 뷰포트 밖 넘침 시 위·좌우 보정.
- 라이브 모드 Cmd+A 후 Backspace: 전체 선택 삭제가 줄 병합으로 가로채지지 않도록 수정.
- Shift+Enter·문단 내 hard break 제거. Enter/Shift+Enter 모두 문단 분리. 마크다운 한 줄=한 문단. 소스 모드 Shift+Enter hard break 삽입 제거.
- 라이브 모드 ↑↓: 이전·다음 문단(블록) 이동, 같은 열 유지.
- 라이브 모드 ←→: 문단 끝 → 다음 블록 맨 앞, 문단 앞 → 이전 블록 맨 끝. ↑↓는 열 유지. Cmd+←→는 블록 내 맨 앞·맨 끝.
- 라이브 모드 병합(맨 앞 Backspace): 커서를 합쳐진 경계(이전 줄 본문 끝)에 둠.
- 라이브 모드 Enter(문단): 분리 후 blur가 이전 DOM 전체를 다시 저장하던 문제 수정(포커스 중 modelValue 동기화).
- contenteditable 줄바꿈(`<br>`·`<div>`) 읽기·커서 오프셋 계산 보강. `좋|아` 분리·복사 버그 수정.
- 라이브 모드 Enter(문단): 문장 중간 분리 시 빈 줄 없이 위·아래 두 줄만 생성.
- 라이브 모드 Enter(목록): 맨 앞·중간·끝 커서 위치별 분기. 중간은 항목 두 줄로 분리, 끝은 목록 블록 밖 빈 줄 삽입.
- 라이브 모드 Enter(인용): 맨 앞·중간 분리 시 `> ` 줄 분리, 끝은 `> ` 이어쓰기 유지.
- 라이브 모드 Backspace: 줄 맨 앞(내용 있음)에서 이전 줄 끝으로 병합. 빈 줄·원문 토글 순서 정리.
- `lib/markdown-live-edit.js`: `appendTextToMarkdownLine`, `getAppendTextForMerge` 추가.
- 패키지 버전 `1.2.9`로 갱신.
## v1.2.8
- 라이브 모드: 인용·목록 줄 단위 편집, `> `·`- ` 접두사 중복 표시 제거. 맨 앞 백스페이스로 마크다운 원문(`- 리스트 1` 등) 표시. Cmd+Shift+K 현재 줄 삭제.
@@ -14,7 +49,7 @@
- 라이브 모드 raw: 원문 모드 Enter 시 브라우저 기본 줄바꿈 대신 아래 줄 삽입. 마커(`>`·`-`) 제거 시 일반 문단으로 저장. 목록 raw 시 불릿 숨김.
- 라이브 모드 Enter: 커서 뒤 텍스트 잘림 버그 수정(문단 끝에서 이전 줄 내용이 복사되지 않음). 끝 Enter 시 빈 줄 1개만 삽입.
- 제목·인용·목록: 블록 안 줄바꿈 대신 아래 새 블록/항목 삽입, 여백 축소.
- 라이브 모드 ↑↓←→: 줄 경계에서 이전·다음 편집 영역 이동(끝→/↓ 다음 줄 앞, 앞←/↑ 이전 줄 끝, 원문 접두사 직후 포함).
- 라이브 모드 ↑↓·Cmd+←→: 블록 이동·블록 내 처음/끝(열 유지).
- 패키지 버전 `1.2.8`로 갱신.
## v1.2.7

63
lib/markdown-callout.js Normal file
View File

@@ -0,0 +1,63 @@
/** @type {string[]} */
export const CALLOUT_BACKGROUND_OPTIONS = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
/** @type {string[]} */
export const CALLOUT_EMOJI_OPTIONS = ['💡', '⚠️', '❗', '✅', '📌', '🔥', '💬']
/**
* 콜아웃 선언부 옵션을 파싱한다.
* @param {string} line - 콜아웃 선언 라인
* @returns {{ calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string }}
*/
export const parseCalloutOptions = (line) => {
const options = {
calloutEmojiEnabled: true,
calloutEmoji: '💡',
calloutBackground: 'blue'
}
const tokens = String(line ?? '').trim().split(/\s+/).slice(1)
tokens.forEach((token) => {
const [rawKey, ...rawValueParts] = token.split('=')
if (!rawKey || !rawValueParts.length) {
return
}
const key = rawKey.toLowerCase()
const value = rawValueParts.join('=').trim()
if (key === 'emoji') {
if (!value || value === 'none') {
options.calloutEmojiEnabled = false
options.calloutEmoji = '💡'
} else {
options.calloutEmojiEnabled = true
options.calloutEmoji = value
}
return
}
if (key === 'bg' && CALLOUT_BACKGROUND_OPTIONS.includes(value)) {
options.calloutBackground = value
}
})
return options
}
/**
* 콜아웃 선언 줄을 만든다.
* @param {{ calloutEmojiEnabled?: boolean, calloutEmoji?: string, calloutBackground?: string }} options - 옵션
* @returns {string} 선언 줄
*/
export const buildCalloutOpenerLine = (options = {}) => {
const emojiEnabled = options.calloutEmojiEnabled !== false
const emoji = emojiEnabled ? (options.calloutEmoji || '💡') : 'none'
const background = CALLOUT_BACKGROUND_OPTIONS.includes(options.calloutBackground)
? options.calloutBackground
: 'blue'
return `:::callout emoji=${emoji} bg=${background}`
}

View File

@@ -0,0 +1,63 @@
/**
* 코드 펜스 시작 줄을 파싱한다.
* @param {string} line - 마크다운 줄
* @returns {{ language: string, showLineNumbers: boolean }|null} 파싱 결과
*/
export const parseCodeFenceLine = (line) => {
const trimmed = String(line ?? '').trim()
if (!trimmed.startsWith('```')) {
return null
}
const rest = trimmed.slice(3).trim()
if (!rest) {
return { language: '', showLineNumbers: true }
}
const noLineNumberTokens = ['nolinenos', 'no-linenos', 'no-line-numbers']
const tokens = rest.split(/\s+/).filter(Boolean)
const showLineNumbers = !tokens.some((token) => noLineNumberTokens.includes(token.toLowerCase()))
const languageTokens = tokens.filter((token) => !noLineNumberTokens.includes(token.toLowerCase()))
const language = languageTokens[0] || ''
return { language, showLineNumbers }
}
/**
* 코드 펜스 시작 줄을 만든다.
* @param {{ language?: string, showLineNumbers?: boolean }} options - 옵션
* @returns {string} 펜스 시작 줄
*/
export const buildCodeFenceOpener = (options = {}) => {
const language = String(options.language ?? '').trim()
const showLineNumbers = options.showLineNumbers !== false
let opener = '```'
if (language) {
opener += language
}
if (!showLineNumbers) {
opener += language ? ' nolinenos' : 'nolinenos'
}
return opener
}
/**
* 코드 블록 마크다운 줄 배열을 만든다.
* @param {{ language?: string, showLineNumbers?: boolean, body?: string }} options - 옵션
* @returns {string[]} 마크다운 줄
*/
export const buildCodeBlockLines = (options = {}) => {
const body = String(options.body ?? '').replace(/\r/g, '')
const bodyLines = body.length ? body.split('\n') : ['']
return [
buildCodeFenceOpener(options),
...bodyLines,
'```'
]
}

View File

@@ -180,6 +180,219 @@ export const getRangeInnerHtml = (range) => {
return container.innerHTML
}
/** @type {Set<string>} contenteditable 줄 구분 블록 태그 */
const EDITABLE_BLOCK_TAGS = new Set(['div', 'p'])
/**
* 루트 직계 자식이 줄 구분 블록인지 확인한다.
* @param {HTMLElement} element - 요소
* @param {HTMLElement} root - contenteditable 루트
* @returns {boolean}
*/
const isEditableBlockBreak = (element, root) => {
if (!element || element === root) {
return false
}
return EDITABLE_BLOCK_TAGS.has(element.tagName.toLowerCase())
&& element.parentElement === root
}
/**
* contenteditable 텍스트 단위를 순회한다.
* @param {HTMLElement} root - contenteditable 루트
* @yields {{ kind: 'text'|'break'|'block-break', node: Node|null, length: number }}
*/
function* iterateEditableTextUnits(root) {
/**
* @param {Node} node - 순회 노드
* @param {boolean} parentIsRoot - 루트 직계 여부
* @param {number} indexInParent - 형제 인덱스
* @returns {Generator<{ kind: string, node: Node|null, length: number }>}
*/
const visit = function* (node, parentIsRoot, indexInParent) {
if (node.nodeType === Node.TEXT_NODE) {
yield { kind: 'text', node, length: node.textContent?.length ?? 0 }
return
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return
}
const element = /** @type {HTMLElement} */ (node)
const tag = element.tagName.toLowerCase()
if (tag === 'br') {
yield { kind: 'break', node, length: 1 }
return
}
if (isEditableBlockBreak(element, root)) {
if (parentIsRoot && indexInParent > 0) {
yield { kind: 'block-break', node: null, length: 1 }
}
const children = [...element.childNodes]
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
yield* visit(children[childIndex], false, childIndex)
}
return
}
const children = [...element.childNodes]
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
yield* visit(children[childIndex], parentIsRoot, childIndex)
}
}
const children = [...root.childNodes]
for (let index = 0; index < children.length; index += 1) {
yield* visit(children[index], true, index)
}
}
/**
* contenteditable 루트에서 텍스트를 읽는다.
* @param {HTMLElement} root - contenteditable 루트
* @returns {string} 마크다운 인라인 텍스트
*/
export const readEditableTextFromElement = (root) => {
if (!root) {
return ''
}
const parts = []
for (const unit of iterateEditableTextUnits(root)) {
if (unit.kind === 'text') {
parts.push(unit.node?.textContent || '')
continue
}
parts.push('\n')
}
return parts.join('').replace(/\u00a0/g, ' ').trimEnd()
}
/**
* contenteditable 루트에서 커서 오프셋을 계산한다.
* @param {HTMLElement} root - contenteditable 루트
* @param {Range} range - 선택 범위
* @returns {number} 텍스트 오프셋
*/
export const getEditableCaretOffset = (root, range) => {
if (!root || !range) {
return 0
}
if (range.startContainer === root) {
let offset = 0
const children = [...root.childNodes]
const measureRoot = document.createElement('div')
for (let index = 0; index < Math.min(range.startOffset, children.length); index += 1) {
measureRoot.appendChild(children[index].cloneNode(true))
}
for (const unit of iterateEditableTextUnits(/** @type {HTMLElement} */ (measureRoot))) {
offset += unit.length
}
return offset
}
let offset = 0
let found = false
for (const unit of iterateEditableTextUnits(root)) {
if (found) {
break
}
if (unit.kind === 'text' && unit.node === range.startContainer) {
offset += range.startOffset
found = true
break
}
if (unit.node === range.startContainer) {
found = true
break
}
offset += unit.length
}
return offset
}
/**
* contenteditable 루트에 커서를 텍스트 오프셋으로 둔다.
* @param {HTMLElement} root - contenteditable 루트
* @param {number} targetOffset - 텍스트 오프셋
* @returns {void}
*/
export const setEditableCaretOffset = (root, targetOffset) => {
if (!root || !import.meta.client) {
return
}
const safeOffset = Math.max(0, targetOffset)
let walked = 0
let placed = false
for (const unit of iterateEditableTextUnits(root)) {
if (placed) {
break
}
if (unit.kind === 'text' && unit.node?.nodeType === Node.TEXT_NODE) {
if (walked + unit.length >= safeOffset) {
const range = document.createRange()
const charOffset = Math.min(safeOffset - walked, unit.length)
range.setStart(unit.node, charOffset)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
placed = true
break
}
walked += unit.length
continue
}
if (walked + unit.length >= safeOffset) {
const range = document.createRange()
range.selectNodeContents(root)
range.collapse(false)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
placed = true
break
}
walked += unit.length
}
if (!placed) {
const range = document.createRange()
range.selectNodeContents(root)
range.collapse(false)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
}
/**
* contenteditable 내부 HTML을 인라인 마크다운으로 변환한다.
* @param {string} html - innerHTML
@@ -191,22 +404,8 @@ export const convertEditableHtmlToMarkdown = (html) => {
}
const document = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
const parts = []
document.body.childNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE && /** @type {HTMLElement} */ (node).tagName.toLowerCase() === 'br') {
parts.push('\n')
return
}
const converted = convertHtmlInlineNodeToMarkdown(node)
if (converted) {
parts.push(converted)
}
})
return parts.join('').replace(/\u00a0/g, ' ').trimEnd()
return readEditableTextFromElement(/** @type {HTMLElement} */ (document.body))
}
/**
@@ -297,15 +496,11 @@ export const convertHtmlToMarkdown = (html) => {
* @returns {string[]} 마크다운 줄
*/
export const paragraphTextToSourceLines = (text) => {
const parts = String(text || '').split('\n')
const parts = String(text || '').split('\n').map((part) => part.trimEnd())
if (!parts.length) {
return ['']
}
if (parts.length === 1) {
return [parts[0]]
}
return parts.map((part, index) => (index < parts.length - 1 ? `${part} ` : part))
return parts
}

View File

@@ -110,6 +110,118 @@ export const isEmptyQuoteMarkerLine = (line) => {
return !stripQuoteMarker(line).trim()
}
/**
* 이전 마크다운 줄 끝에 텍스트를 이어 붙인다.
* @param {string} line - 이전 줄
* @param {string} appendText - 붙일 본문
* @returns {string} 병합된 줄
*/
export const appendTextToMarkdownLine = (line, appendText) => {
const prev = String(line ?? '')
const add = String(appendText ?? '')
if (!add) {
return prev
}
const ordered = parseOrderedListMarker(prev)
if (ordered) {
return `${ordered.number}. ${ordered.body}${add}`
}
if (hasQuoteMarker(prev)) {
const body = stripQuoteMarker(prev)
return `> ${body}${add}`
}
if (hasListMarker(prev, false)) {
const body = stripListMarker(prev, false)
return `- ${body}${add}`
}
return `${prev}${add}`
}
/**
* 편집 값에서 이전 줄에 붙일 본문만 추출한다.
* @param {string} value - 편집 값
* @param {string} previousLine - 이전 마크다운 줄
* @param {boolean} raw - 원문 모드 여부
* @returns {string} 본문
*/
export const getAppendTextForMerge = (value, previousLine, raw = false) => {
const text = String(value ?? '').trim()
if (!text) {
return ''
}
if (raw) {
if (hasQuoteMarker(text)) {
return stripQuoteMarker(text)
}
if (hasListMarker(text, true)) {
return stripListMarker(text, true)
}
if (hasListMarker(text, false)) {
return stripListMarker(text, false)
}
return text
}
if (hasListMarker(previousLine, true)) {
return stripListMarker(text, true)
}
if (hasListMarker(previousLine, false)) {
return stripListMarker(text, false)
}
if (hasQuoteMarker(previousLine)) {
return stripQuoteMarker(text)
}
return text
}
/**
* 줄 병합 후 편집 영역에 둘 커서 오프셋(이전 줄 본문 끝)을 반환한다.
* @param {string} previousLine - 이전 마크다운 줄
* @param {boolean} [raw=false] - 원문 모드 여부
* @returns {number} 표시 텍스트 기준 오프셋
*/
export const getMergeJunctionDisplayOffset = (previousLine, raw = false) => {
const prev = String(previousLine ?? '')
if (raw) {
return prev.length
}
const ordered = parseOrderedListMarker(prev)
if (ordered) {
return ordered.body.length
}
if (hasListMarker(prev, true)) {
return stripListMarker(prev, true).length
}
if (hasListMarker(prev, false)) {
return stripListMarker(prev, false).length
}
if (hasQuoteMarker(prev)) {
return stripQuoteMarker(prev).length
}
return prev.length
}
/**
* 제목 접두사 제거
* @param {string} value - 원본

View File

@@ -0,0 +1,195 @@
/**
* 라이브/마크다운 에디터 슬래시 명령 정의
* @typedef {Object} MarkdownSlashCommand
* @property {string} id - 명령 ID
* @property {string} label - 표시 이름
* @property {string} description - 설명
* @property {string[]} keywords - 검색 키워드
* @property {'media-image'|'media-gallery'|'lines'} action - 실행 유형
* @property {string[]} [lines] - 삽입할 마크다운 줄(action이 lines일 때)
* @property {boolean} [showInDefaultMenu=true] - `/`만 입력했을 때 메뉴에 표시할지
*/
/** @type {MarkdownSlashCommand[]} */
export const MARKDOWN_SLASH_COMMANDS = [
{
id: 'image',
label: '이미지',
description: '단일 이미지 삽입',
keywords: ['image', 'img', 'photo', '사진', '이미지'],
action: 'media-image'
},
{
id: 'gallery',
label: '갤러리',
description: '여러 이미지 갤러리',
keywords: ['gallery', 'images', '갤러리'],
action: 'media-gallery'
},
{
id: 'h1',
label: '제목 1',
description: '큰 제목(게시물 제목 외에는 비권장)',
keywords: ['h1', 'heading1'],
action: 'lines',
lines: ['# '],
showInDefaultMenu: false
},
{
id: 'h2',
label: '제목 2',
description: '섹션 제목',
keywords: ['h2', 'heading', 'subtitle', '제목'],
action: 'lines',
lines: ['## ']
},
{
id: 'h3',
label: '제목 3',
description: '작은 섹션 제목',
keywords: ['h3', 'heading', '제목'],
action: 'lines',
lines: ['### ']
},
{
id: 'h4',
label: '제목 4',
description: '소제목',
keywords: ['h4', 'heading', '제목'],
action: 'lines',
lines: ['#### ']
},
{
id: 'quote',
label: '인용',
description: '인용문 블록',
keywords: ['quote', 'blockquote', '인용'],
action: 'lines',
lines: ['> ']
},
{
id: 'list',
label: '목록',
description: '불릿 목록',
keywords: ['list', 'bullet', 'ul', '목록'],
action: 'lines',
lines: ['- ']
},
{
id: 'code',
label: '코드',
description: '코드 블록',
keywords: ['code', 'pre', '코드'],
action: 'lines',
lines: ['```', '', '```']
},
{
id: 'divider',
label: '구분선',
description: '가로 구분선',
keywords: ['divider', 'hr', 'line', '구분선'],
action: 'lines',
lines: ['---']
},
{
id: 'callout',
label: '콜아웃',
description: '강조 안내(첫 줄: :::callout emoji=💡 bg=blue)',
keywords: ['callout', 'notice', 'info', '콜아웃'],
action: 'lines',
lines: [':::callout emoji=💡 bg=blue', '', ':::']
},
{
id: 'toggle',
label: '토글',
description: '접기/펼치기 블록',
keywords: ['toggle', 'details', '토글'],
action: 'lines',
lines: [':::toggle 제목', '', ':::']
},
{
id: 'embed',
label: '임베드',
description: 'YouTube·X 등 외부 링크',
keywords: ['embed', 'youtube', 'link', '임베드', '유튜브'],
action: 'lines',
lines: [':::embed', '', ':::']
}
]
/**
* 슬래시 입력 문자열을 파싱한다.
* @param {string} value - 편집 값
* @returns {{ query: string, raw: string }|null} 파싱 결과
*/
export const parseSlashInput = (value) => {
const raw = String(value ?? '')
if (!raw.startsWith('/') || raw.includes('\n')) {
return null
}
return {
query: raw.slice(1).trim().toLowerCase(),
raw
}
}
/**
* 검색어에 맞는 슬래시 명령을 필터링한다.
* @param {string} query - 검색어(/ 제외)
* @returns {MarkdownSlashCommand[]} 명령 목록
*/
export const filterSlashCommands = (query) => {
const normalized = String(query ?? '').trim().toLowerCase()
if (!normalized) {
return MARKDOWN_SLASH_COMMANDS.filter((command) => command.showInDefaultMenu !== false)
}
return MARKDOWN_SLASH_COMMANDS.filter((command) => [
command.id,
command.label,
command.description,
...command.keywords
].some((keyword) => String(keyword).toLowerCase().includes(normalized)))
}
/**
* 검색어와 일치하는 최우선 명령을 반환한다.
* @param {string} query - 검색어
* @returns {MarkdownSlashCommand|null} 명령
*/
export const resolveSlashCommand = (query) => {
const normalized = String(query ?? '').trim().toLowerCase()
const matches = filterSlashCommands(normalized)
if (!matches.length) {
return null
}
const exact = matches.find((command) => command.id === normalized
|| command.keywords.some((keyword) => keyword.toLowerCase() === normalized))
return exact ?? matches[0]
}
/**
* 슬래시 명령 적용 후 포커스할 줄·커서 오프셋을 계산한다.
* @param {number} startLine - 교체 시작 줄(0-based)
* @param {string[]} lines - 삽입 줄
* @returns {{ line: number, offset: number }} 포커스 대상
*/
export const getSlashCommandFocusTarget = (startLine, lines) => {
const safeStart = Number.isFinite(startLine) ? startLine : 0
if (!Array.isArray(lines) || !lines.length) {
return { line: safeStart, offset: 0 }
}
if (lines.length === 1) {
return { line: safeStart, offset: String(lines[0] ?? '').length }
}
return { line: safeStart + 1, offset: 0 }
}

16
lib/markdown-toggle.js Normal file
View File

@@ -0,0 +1,16 @@
/**
* 토글 블록 마크다운 줄 배열을 만든다.
* @param {{ title?: string, body?: string }} options - 옵션
* @returns {string[]} 마크다운 줄
*/
export const buildToggleBlockLines = (options = {}) => {
const title = String(options.title ?? '').trim() || '더 보기'
const body = String(options.body ?? '').replace(/\r/g, '')
const bodyLines = body.length ? body.split('\n') : ['']
return [
`:::toggle ${title}`,
...bodyLines,
':::'
]
}

View File

@@ -0,0 +1,82 @@
/**
* textarea 커서 위치의 박스 기준 좌표를 계산한다(미러 div 방식).
* @param {HTMLTextAreaElement} textarea - 대상 textarea
* @param {number} position - 문자 인덱스
* @returns {{ top: number, left: number, height: number }} top·left·line height(px)
*/
export const getTextareaCaretCoordinates = (textarea, position) => {
const style = window.getComputedStyle(textarea)
const mirror = document.createElement('div')
mirror.setAttribute('aria-hidden', 'true')
mirror.style.position = 'absolute'
mirror.style.visibility = 'hidden'
mirror.style.whiteSpace = 'pre-wrap'
mirror.style.wordWrap = 'break-word'
mirror.style.overflow = 'hidden'
const properties = [
'direction',
'boxSizing',
'width',
'height',
'overflowX',
'overflowY',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'lineHeight',
'fontFamily',
'textAlign',
'textTransform',
'textIndent',
'textDecoration',
'letterSpacing',
'wordSpacing',
'tabSize'
]
for (const property of properties) {
mirror.style[property] = style[property]
}
const isBoxSizingBorderBox = style.boxSizing === 'border-box'
const width = isBoxSizingBorderBox
? textarea.offsetWidth
: textarea.offsetWidth
- parseFloat(style.borderLeftWidth)
- parseFloat(style.borderRightWidth)
- parseFloat(style.paddingLeft)
- parseFloat(style.paddingRight)
mirror.style.width = `${width}px`
const value = textarea.value
const before = value.slice(0, position)
const after = value.slice(position) || '.'
mirror.textContent = before
const marker = document.createElement('span')
marker.textContent = after
mirror.appendChild(marker)
document.body.appendChild(mirror)
const top = marker.offsetTop + parseFloat(style.borderTopWidth) + parseFloat(style.paddingTop)
const left = marker.offsetLeft + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
const height = parseFloat(style.lineHeight) || marker.offsetHeight || 0
document.body.removeChild(mirror)
return { top, left, height }
}

View File

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

View File

@@ -8,10 +8,13 @@ const router = useRouter()
const savingTitleDesc = ref(false)
const savingMisc = ref(false)
const savingPost = ref(false)
const savingHomeCover = ref(false)
const uploadingLogo = ref(false)
const uploadingHomeCover = ref(false)
const errorMessage = ref('')
const toast = ref(null)
const logoInputRef = ref(null)
const homeCoverInputRef = ref(null)
const mainScrollRef = ref(null)
const navSearchQuery = ref('')
const activeSectionId = ref('admin-settings-section-title')
@@ -22,6 +25,8 @@ const editTitleDesc = ref(false)
const editMisc = ref(false)
/** POST 설정 카드 편집 모드 여부 */
const editPost = ref(false)
/** 메인 화면 커버 카드 편집 모드 여부 */
const editHomeCover = ref(false)
/** 편집 시작 시점의 제목·설명(취소 시 복원용) */
const titleDescSnapshot = reactive({
title: '',
@@ -39,6 +44,12 @@ const miscSnapshot = reactive({
const postSnapshot = reactive({
showPostUpdatedAt: false
})
/** 편집 시작 시점의 메인 화면 커버(취소 시 복원용) */
const homeCoverSnapshot = reactive({
homeCoverImageUrl: '',
homeCoverTitle: '',
homeCoverText: ''
})
let toastTimer = null
let scrollSpyFrame = null
@@ -52,7 +63,10 @@ const form = reactive({
logoUrl: settings.value?.logoUrl || '',
faviconUrl: settings.value?.faviconUrl || '',
copyrightText: settings.value?.copyrightText || '©2026 sori.studio',
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt)
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt),
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
homeCoverTitle: settings.value?.homeCoverTitle || '',
homeCoverText: settings.value?.homeCoverText || ''
})
/**
@@ -83,6 +97,16 @@ const hasMiscChanges = computed(() => editMisc.value && (
const hasPostChanges = computed(() => editPost.value
&& form.showPostUpdatedAt !== postSnapshot.showPostUpdatedAt)
/**
* 메인 화면 커버 변경 여부
* @returns {boolean} 변경 여부
*/
const hasHomeCoverChanges = computed(() => editHomeCover.value && (
form.homeCoverImageUrl !== homeCoverSnapshot.homeCoverImageUrl
|| form.homeCoverTitle !== homeCoverSnapshot.homeCoverTitle
|| form.homeCoverText !== homeCoverSnapshot.homeCoverText
))
/**
* 수정일 표시 라벨
* @returns {string} 표시 문구
@@ -111,6 +135,7 @@ const settingsNavGroups = [
{
heading: '사이트',
items: [
{ id: 'admin-settings-section-home-cover', label: '메인 화면', keywords: 'home cover hero banner image' },
{ id: 'admin-settings-section-announcement', label: '어나운스 바', keywords: 'announcement banner notice' }
]
},
@@ -308,7 +333,10 @@ const buildSiteSettingsPayload = () => ({
logoUrl: form.logoUrl,
faviconUrl: form.faviconUrl,
copyrightText: form.copyrightText,
showPostUpdatedAt: Boolean(form.showPostUpdatedAt)
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
homeCoverImageUrl: form.homeCoverImageUrl || '',
homeCoverTitle: form.homeCoverTitle || '',
homeCoverText: form.homeCoverText || ''
})
/**
@@ -466,6 +494,108 @@ const savePostSection = async () => {
}
}
/**
* 메인 화면 커버 파일 선택 창을 연다.
* @returns {void}
*/
const openHomeCoverFilePicker = () => {
if (uploadingHomeCover.value) {
return
}
homeCoverInputRef.value?.click()
}
/**
* 메인 화면 커버 이미지를 업로드한다.
* @param {Event} event - 파일 선택 이벤트
* @returns {Promise<void>}
*/
const uploadHomeCover = async (event) => {
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
const file = target?.files?.[0]
if (!file || uploadingHomeCover.value) {
return
}
uploadingHomeCover.value = true
errorMessage.value = ''
showToast('info', '커버 이미지를 업로드하는 중입니다.')
try {
const formData = new FormData()
formData.append('file', file)
const updatedSettings = await $fetch('/admin/api/settings/home-cover', {
method: 'POST',
body: formData
})
Object.assign(form, updatedSettings)
homeCoverSnapshot.homeCoverImageUrl = form.homeCoverImageUrl
showToast('success', '커버 이미지가 등록되었습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || '커버 이미지 업로드에 실패했습니다.'
showToast('error', errorMessage.value)
} finally {
uploadingHomeCover.value = false
if (target) {
target.value = ''
}
}
}
/**
* 메인 화면 커버 이미지를 제거한다.
* @returns {void}
*/
const clearHomeCoverImage = () => {
form.homeCoverImageUrl = ''
}
/**
* 메인 화면 커버 편집 모드 진입
* @returns {void}
*/
const beginEditHomeCover = () => {
homeCoverSnapshot.homeCoverImageUrl = form.homeCoverImageUrl
homeCoverSnapshot.homeCoverTitle = form.homeCoverTitle
homeCoverSnapshot.homeCoverText = form.homeCoverText
editHomeCover.value = true
}
/**
* 메인 화면 커버 편집 취소
* @returns {void}
*/
const cancelEditHomeCover = () => {
form.homeCoverImageUrl = homeCoverSnapshot.homeCoverImageUrl
form.homeCoverTitle = homeCoverSnapshot.homeCoverTitle
form.homeCoverText = homeCoverSnapshot.homeCoverText
editHomeCover.value = false
}
/**
* 메인 화면 커버 저장
* @returns {Promise<void>}
*/
const saveHomeCoverSection = async () => {
if (!hasHomeCoverChanges.value) {
return
}
const ok = await persistSiteSettings({
successToast: '메인 화면 설정이 저장되었습니다.',
savingFlag: savingHomeCover
})
if (ok) {
homeCoverSnapshot.homeCoverImageUrl = form.homeCoverImageUrl
homeCoverSnapshot.homeCoverTitle = form.homeCoverTitle
homeCoverSnapshot.homeCoverText = form.homeCoverText
editHomeCover.value = false
}
}
/**
* Escape 키: 제목·설명 편집 중이면 취소, 아니면 설정 화면 닫기
* @param {KeyboardEvent} event - 키보드 이벤트
@@ -982,6 +1112,150 @@ onBeforeUnmount(() => {
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
사이트
</h2>
<section
id="admin-settings-section-home-cover"
class="admin-settings-screen__card admin-settings-screen__card--home-cover relative flex flex-col gap-6 rounded-xl border border-[#d8dce1] bg-white p-5 transition-all hover:border-[#c5cbd3] hover:shadow-sm md:p-7"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
메인 화면
</h2>
<p
v-if="!editHomeCover"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
상단에 720px 너비 커버 이미지를 표시합니다. 제목·짧은 문구는 이미지 왼쪽 하단에 겹쳐 보입니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
<template v-if="!editHomeCover">
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
type="button"
@click="beginEditHomeCover"
>
편집
</button>
</template>
<template v-else>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
type="button"
:disabled="savingHomeCover"
@click="cancelEditHomeCover"
>
취소
</button>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
type="button"
:disabled="savingHomeCover || !hasHomeCoverChanges"
@click="saveHomeCoverSection"
>
{{ savingHomeCover ? '저장 중' : '저장' }}
</button>
</template>
</div>
</div>
<div
v-if="!editHomeCover"
class="admin-settings-screen__home-cover-readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
>
<div v-if="form.homeCoverImageUrl" class="overflow-hidden rounded-lg border border-[#e6e8eb]">
<img
class="aspect-[720/215] w-full max-w-[360px] object-cover"
:src="form.homeCoverImageUrl"
alt="메인 화면 커버 미리보기"
>
</div>
<p v-else class="text-[#657080]">
등록된 커버 이미지가 없습니다. 상단 배너는 표시되지 않습니다.
</p>
<div v-if="form.homeCoverTitle || form.homeCoverText" class="grid gap-1">
<p v-if="form.homeCoverTitle" class="font-semibold text-[#15171a]">
{{ form.homeCoverTitle }}
</p>
<p v-if="form.homeCoverText" class="text-[#657080]">
{{ form.homeCoverText }}
</p>
</div>
</div>
<div v-else class="admin-settings-screen__home-cover-edit grid gap-6 border-t border-[#eceff2] pt-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium text-[#3f4650]">
커버 이미지
</h3>
<p class="mt-1 max-w-md text-sm leading-relaxed text-[#657080]">
가로 720px 기준으로 저장됩니다. JPG·PNG·WebP를 사용할 있습니다.
</p>
<div
v-if="form.homeCoverImageUrl"
class="mt-3 overflow-hidden rounded-lg border border-[#e6e8eb]"
>
<img
class="aspect-[720/215] w-full max-w-[360px] object-cover"
:src="form.homeCoverImageUrl"
alt="커버 미리보기"
>
</div>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<button
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingHomeCover"
@click="openHomeCoverFilePicker"
>
{{ uploadingHomeCover ? '업로드 중' : form.homeCoverImageUrl ? '이미지 변경' : '이미지 등록' }}
</button>
<button
v-if="form.homeCoverImageUrl"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingHomeCover"
@click="clearHomeCoverImage"
>
이미지 제거
</button>
</div>
<input
ref="homeCoverInputRef"
class="hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="uploadingHomeCover"
@change="uploadHomeCover"
>
</div>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">오버레이 제목</span>
<input
v-model="form.homeCoverTitle"
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="text"
maxlength="120"
placeholder="선택 사항"
>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">오버레이 본문</span>
<textarea
v-model="form.homeCoverText"
class="min-h-[4.5rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
maxlength="280"
rows="3"
placeholder="짧은 소개 문구 (선택 사항)"
/>
</label>
</div>
</section>
<section
id="admin-settings-section-announcement"
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"

View File

@@ -7,24 +7,97 @@ const { data: tags } = await useFetch('/api/tags', {
default: () => []
})
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({})
})
const postFeedStyleStorageKey = 'POST_FEED_STYLE'
const postFeedStyleOpen = ref(false)
const postFeedStyle = ref('compact')
/** @typedef {'list' | 'compact' | 'cards'} PostFeedStyle */
/**
* 저장·표시용 피드 보기 방식을 정규화한다.
* @param {string|null|undefined} value - 원본 값
* @returns {PostFeedStyle}
*/
const normalizePostFeedStyle = (value) => {
if (value === 'list' || value === 'cards') {
return value
}
return 'compact'
}
/**
* Latest 피드 보기 방식을 저장한다.
* @param {'list' | 'compact' | 'cards' | 'articles'} value - 보기 방식
* @returns {void}
*/
const setPostFeedStyle = (value) => {
postFeedStyle.value = value
const nextStyle = normalizePostFeedStyle(value === 'articles' ? 'compact' : value)
postFeedStyle.value = nextStyle
if (import.meta.client) {
localStorage.setItem(postFeedStyleStorageKey, value)
localStorage.setItem(postFeedStyleStorageKey, nextStyle)
}
}
/** @type {import('vue').ComputedRef<boolean>} */
const isPostFeedCards = computed(() => postFeedStyle.value === 'cards')
/** @type {import('vue').ComputedRef<boolean>} */
const showPostFeedMedia = computed(() => postFeedStyle.value === 'list' || postFeedStyle.value === 'cards')
/**
* Latest 피드 컨테이너 클래스
* @param {PostFeedStyle} style - 보기 방식
* @returns {string}
*/
const getPostFeedContainerClass = (style) => {
if (style === 'cards') {
return 'post-feed post-feed--cards mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2'
}
return 'post-feed post-feed--stack flex flex-col divide-y divide-[var(--site-line)]'
}
/**
* Latest 게시물 카드 클래스
* @param {PostFeedStyle} style - 보기 방식
* @returns {string}
*/
const getPostFeedArticleClass = (style) => {
if (style === 'cards') {
return 'post-feed__card group relative flex flex-col rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3'
}
if (style === 'compact') {
return 'post-feed__item post-feed__item--compact group relative flex flex-row gap-3 py-3'
}
return 'post-feed__item post-feed__item--list group relative flex flex-row gap-3 py-4'
}
/**
* 요약 줄 수 클래스
* @param {PostFeedStyle} style - 보기 방식
* @returns {string}
*/
const getPostFeedExcerptClass = (style) => {
if (style === 'list') {
return 'line-clamp-3'
}
if (style === 'compact') {
return 'line-clamp-1'
}
return 'line-clamp-2'
}
const closePostFeedStyleMenu = () => {
postFeedStyleOpen.value = false
}
@@ -163,9 +236,7 @@ onMounted(() => {
}
const storedStyle = localStorage.getItem(postFeedStyleStorageKey)
if (storedStyle === 'list' || storedStyle === 'compact' || storedStyle === 'cards' || storedStyle === 'articles') {
postFeedStyle.value = storedStyle
}
postFeedStyle.value = normalizePostFeedStyle(storedStyle)
document.addEventListener('pointerdown', onDocumentPointerDown)
})
@@ -211,26 +282,12 @@ const scrollFeatured = (direction) => {
<template>
<MainColumn>
<section class="py-6 px-6 md:py-8">
<div class="mx-auto flex max-w-[720px] flex-col-reverse gap-6">
<div class="z-[2] flex flex-col items-center justify-center gap-2 text-center">
<h1 class="text-xl font-semibold leading-[1.125] md:text-2xl">
Ideas <em>published</em> for meaningful conversation, <em>discussed</em> and shaped by the community
</h1>
<p class="max-w-md text-base leading-snug site-muted">
A modern Ghost theme for curated, community-driven publishing, where members join the conversation.
</p>
<form class="group relative mt-1 flex w-full max-w-xs flex-col items-start">
<fieldset class="flex w-full flex-wrap gap-2 text-sm">
<legend class="sr-only">Personal information</legend>
<input class="site-input flex-[2] rounded-[10px] px-3 py-1.5 text-sm" type="email" placeholder="Your email" aria-label="Your email">
<button class="site-button flex-1 cursor-pointer rounded-[10px] border border-[var(--site-invert)] bg-gradient-to-b from-[rgba(17,17,17,0.75)] to-[rgba(17,17,17,0.95)] px-3 py-1.5 font-medium text-[var(--site-invert-text)] hover:opacity-90" type="button">
Subscribe
</button>
</fieldset>
</form>
</div>
</div>
<section v-if="siteSettings?.homeCoverImageUrl" class="home-page__hero px-6 pb-2 pt-6 md:pt-8">
<HomeHero
:image-url="siteSettings.homeCoverImageUrl"
:title="siteSettings.homeCoverTitle"
:text="siteSettings.homeCoverText"
/>
</section>
<section v-if="featuredPosts.length" class="py-4 px-6">
@@ -326,13 +383,6 @@ const scrollFeatured = (direction) => {
<path d="M4 16a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2" />
</svg>
</span>
<span class="pointer-events-none" v-show="postFeedStyle === 'articles'">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.06 13a9 9 0 1 0 .49-4.087" />
<path d="M3 4.001v5h5" />
<path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
</svg>
</span>
<span class="pointer-events-none opacity-75">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6" />
@@ -386,7 +436,7 @@ const scrollFeatured = (direction) => {
</button>
</li>
<li class="w-full">
<button class="flex w-full cursor-pointer items-center gap-1.5 rounded-[10px] px-2 py-1 hover:bg-[var(--site-panel)]" type="button" @click="setPostFeedStyle('articles'); closePostFeedStyleMenu()">
<button class="flex w-full cursor-pointer items-center gap-1.5 rounded-[10px] px-2 py-1 hover:bg-[var(--site-panel)]" type="button" @click="setPostFeedStyle('compact'); closePostFeedStyleMenu()">
<span class="pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.06 13a9 9 0 1 0 .49-4.087" />
@@ -400,47 +450,33 @@ const scrollFeatured = (direction) => {
</menu>
</div>
</div>
<div class="mb-8 flex flex-col">
<div class="post-feed-section mb-8 flex flex-col">
<div
class="flex flex-col divide-y divide-[var(--site-line)]"
:class="postFeedStyle === 'cards' ? 'divide-y-0 gap-4 sm:grid sm:grid-cols-2 sm:gap-4' : ''"
class="post-feed-section__list"
data-post-feed="latest"
:class="getPostFeedContainerClass(postFeedStyle)"
>
<article
v-for="post in latestPosts"
:key="post.to"
class="group relative overflow-hidden"
:class="postFeedStyle === 'cards' ? 'rounded-[10px] border border-[var(--site-line)] p-3' : 'flex flex-row gap-3 py-4'"
data-post-card
:data-featured="post.isFeatured ? '' : undefined"
:class="getPostFeedArticleClass(postFeedStyle)"
>
<NuxtLink
<PostCardMedia
v-if="showPostFeedMedia"
:to="post.to"
class="relative flex-1"
:class="postFeedStyle === 'cards' ? 'mb-3 block aspect-video w-full' : 'aspect-square min-w-16 sm:aspect-video'"
>
<figure class="overflow-hidden rounded-[10px]">
<img
v-if="post.featuredImage"
:src="post.featuredImage"
:alt="post.title"
class="w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
:class="postFeedStyle === 'cards' ? 'aspect-video' : 'aspect-square sm:aspect-video'"
loading="lazy"
>
<div
v-else
class="w-full rounded-[inherit] bg-[linear-gradient(135deg,#253444,#8f9dad)]"
:class="postFeedStyle === 'cards' ? 'aspect-video' : 'aspect-square sm:aspect-video'"
/>
</figure>
</NuxtLink>
:title="post.title"
:featured-image="post.featuredImage"
:link-class="isPostFeedCards ? 'post-feed__media post-feed__media--cards mb-3 block aspect-video w-full' : 'post-feed__media post-feed__media--list relative flex-1 aspect-square min-w-16 sm:aspect-video'"
:aspect-class="isPostFeedCards ? 'aspect-video' : 'aspect-square sm:aspect-video'"
/>
<div
class="relative"
:class="postFeedStyle === 'cards' ? '' : 'flex-[3] md:flex-[4]'"
class="post-feed__content relative min-w-0"
:class="isPostFeedCards ? 'flex flex-col' : 'flex flex-[3] flex-col gap-1.5 md:flex-[4]'"
>
<div
class="flex h-full flex-col gap-1.5"
:class="postFeedStyle === 'cards' ? '' : ''"
>
<div class="post-feed__content-inner flex min-h-0 flex-1 flex-col gap-1.5">
<h2 class="max-w-[90%] text-sm font-medium leading-tight">
<NuxtLink :to="post.to" class="flex items-center transition-opacity duration-200 hover:opacity-75">
<span v-if="post.isFeatured" class="post-feed__featured-icon mr-1 inline-flex h-4 w-4 items-center justify-center text-[var(--site-accent)]">
@@ -453,8 +489,8 @@ const scrollFeatured = (direction) => {
</h2>
<p
class="flex-1 text-[0.8rem] leading-tight site-muted"
:class="postFeedStyle === 'list' ? 'line-clamp-3' : postFeedStyle === 'articles' ? 'line-clamp-4' : 'line-clamp-2'"
class="flex-1 text-[0.8rem] leading-tight site-muted text-[#6E6661]"
:class="getPostFeedExcerptClass(postFeedStyle)"
>
{{ post.excerpt }}
</p>
@@ -482,7 +518,7 @@ const scrollFeatured = (direction) => {
<button
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75"
:class="postFeedStyle === 'cards' ? '' : 'md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100'"
:class="isPostFeedCards ? '' : 'md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100'"
type="button"
aria-label="Share this post"
>

View File

@@ -52,24 +52,13 @@ const tagPosts = computed(() => posts.value
class="tag-posts-list__item site-section site-panel-hover group relative text-[var(--site-text)]"
>
<div class="tag-posts-list__body site-section-body flex flex-row gap-3">
<NuxtLink
<PostCardMedia
:to="post.to"
class="relative aspect-square min-w-16 flex-1 sm:aspect-video"
>
<figure class="overflow-hidden rounded-[10px]">
<img
v-if="post.featuredImage"
:src="post.featuredImage"
:alt="post.title"
class="aspect-square w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90 sm:aspect-video"
loading="lazy"
>
<div
v-else
class="aspect-square w-full rounded-[inherit] bg-[linear-gradient(135deg,#253444,#8f9dad)] sm:aspect-video"
/>
</figure>
</NuxtLink>
:title="post.title"
:featured-image="post.featuredImage"
link-class="relative aspect-square min-w-16 flex-1 sm:aspect-video"
aspect-class="aspect-square w-full sm:aspect-video"
/>
<div class="relative flex-[3] md:flex-[4]">
<div class="flex h-full flex-col gap-1.5">

View File

@@ -94,6 +94,9 @@ const mapSiteSettingsRow = (row) => ({
faviconUrl: row.favicon_url || '',
copyrightText: row.copyright_text,
showPostUpdatedAt: Boolean(row.show_post_updated_at),
homeCoverImageUrl: row.home_cover_image_url || '',
homeCoverTitle: row.home_cover_title || '',
homeCoverText: row.home_cover_text || '',
updatedAt: row.updated_at.toISOString()
})
@@ -810,6 +813,9 @@ export const updateSiteSettings = async (input) => {
favicon_url,
copyright_text,
show_post_updated_at,
home_cover_image_url,
home_cover_title,
home_cover_text,
updated_at
)
VALUES (
@@ -822,6 +828,9 @@ export const updateSiteSettings = async (input) => {
${input.faviconUrl || ''},
${input.copyrightText},
${input.showPostUpdatedAt ? true : false},
${input.homeCoverImageUrl || ''},
${input.homeCoverTitle || ''},
${input.homeCoverText || ''},
now()
)
ON CONFLICT (id) DO UPDATE
@@ -834,6 +843,9 @@ export const updateSiteSettings = async (input) => {
favicon_url = EXCLUDED.favicon_url,
copyright_text = EXCLUDED.copyright_text,
show_post_updated_at = EXCLUDED.show_post_updated_at,
home_cover_image_url = EXCLUDED.home_cover_image_url,
home_cover_title = EXCLUDED.home_cover_title,
home_cover_text = EXCLUDED.home_cover_text,
updated_at = now()
RETURNING *
`
@@ -877,6 +889,39 @@ export const updateSiteLogo = async (input) => {
return mapSiteSettingsRow(rows[0])
}
/**
* 메인 화면 커버 이미지 URL을 수정한다.
* @param {{ homeCoverImageUrl: string }} input - 커버 이미지 URL
* @returns {Promise<Object>} 수정된 사이트 설정
*/
export const updateSiteHomeCoverImage = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
INSERT INTO site_settings (
id,
home_cover_image_url,
updated_at
)
VALUES (
1,
${input.homeCoverImageUrl || ''},
now()
)
ON CONFLICT (id) DO UPDATE
SET
home_cover_image_url = EXCLUDED.home_cover_image_url,
updated_at = now()
RETURNING *
`
return mapSiteSettingsRow(rows[0])
}
/**
* 네비게이션 항목 목록 조회
* @param {Object} options - 조회 옵션

View File

@@ -0,0 +1,90 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { updateSiteHomeCoverImage } from '../../../../repositories/content-repository'
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
const homeCoverWidth = 720
/**
* 시스템 자산 파일명 접미사
* @returns {string}
*/
const createSystemAssetSuffix = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
return `${year}${month}-${Math.random().toString(36).slice(2, 8)}`
}
/**
* 메인 화면 커버 이미지 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 사이트 설정
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const config = useRuntimeConfig()
const maxFileSize = Number(config.maxFileSize || 10485760)
const formData = await readMultipartFormData(event)
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
if (!file) {
throw createError({
statusCode: 400,
message: '업로드할 커버 이미지가 없습니다.'
})
}
if (!allowedImageTypes.has(file.type)) {
throw createError({
statusCode: 400,
message: 'JPG, PNG, WebP 이미지만 커버로 사용할 수 있습니다.'
})
}
if (file.data.length > maxFileSize) {
throw createError({
statusCode: 413,
message: '업로드 가능한 파일 크기를 초과했습니다.'
})
}
const metadata = await sharp(file.data).metadata()
if (!metadata.width || !metadata.height) {
throw createError({
statusCode: 400,
message: '이미지 메타데이터를 읽을 수 없습니다.'
})
}
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const assetSuffix = createSystemAssetSuffix()
const coverFileName = `home-cover-${assetSuffix}.webp`
const coverPath = join(directoryPath, coverFileName)
const homeCoverImageUrl = `${uploadBaseUrl}/system/${coverFileName}`
await mkdir(directoryPath, { recursive: true })
const coverBuffer = await sharp(file.data)
.rotate()
.resize({
width: homeCoverWidth,
withoutEnlargement: true
})
.webp({ quality: 88 })
.toBuffer()
await writeFile(coverPath, coverBuffer)
await upsertMediaMetadataCategory(homeCoverImageUrl, '시스템')
return updateSiteHomeCoverImage({ homeCoverImageUrl })
})

View File

@@ -8,7 +8,10 @@ export const adminSiteSettingsInputSchema = z.object({
logoUrl: z.string().trim().max(500).optional().default(''),
faviconUrl: z.string().trim().max(500).optional().default(''),
copyrightText: z.string().trim().min(1),
showPostUpdatedAt: z.boolean().optional().default(false)
showPostUpdatedAt: z.boolean().optional().default(false),
homeCoverImageUrl: z.string().trim().max(500).optional().default(''),
homeCoverTitle: z.string().trim().max(120).optional().default(''),
homeCoverText: z.string().trim().max(280).optional().default('')
})
/**

View File

@@ -15,6 +15,9 @@ export const getDefaultSiteSettings = () => {
faviconUrl: '',
copyrightText: `©${new Date().getFullYear()} ${title}`,
showPostUpdatedAt: false,
homeCoverImageUrl: '',
homeCoverTitle: '',
homeCoverText: '',
updatedAt: null
}
}