v1.4.1: 관리자 미디어 업로드 한도·라이브 에디터 UX 개선

종류별 업로드 크기 한도와 413 안내를 추가하고, 임베드·미디어 라이브 프리뷰·제목 Enter 포커스·스크롤 동작을 보정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-21 15:33:23 +09:00
parent f8e04003fd
commit 095a8fa5f0
25 changed files with 1445 additions and 103 deletions

View File

@@ -168,11 +168,15 @@ const isMarkdownBlockStart = (line) => {
trimmedLine === ':::bookmark' ||
trimmedLine === ':::signup' ||
trimmedLine === ':::gallery' ||
trimmedLine === ':::video' ||
trimmedLine === ':::audio' ||
trimmedLine === ':::file' ||
trimmedLine === ':::embed' ||
trimmedLine.startsWith(':::callout') ||
trimmedLine.startsWith(':::toggle') ||
trimmedLine.startsWith('```') ||
trimmedLine === '---' ||
isStandaloneUrlLine(trimmedLine) ||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
trimmedLine.startsWith('> ') ||
/^- /.test(trimmedLine) ||
@@ -187,6 +191,13 @@ const isMarkdownBlockStart = (line) => {
*/
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
/**
* 단독 URL 행인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} 단독 URL 여부
*/
const isStandaloneUrlLine = (line) => /^https?:\/\/\S+$/i.test(String(line || '').trim())
/**
* 빈 줄 공백 블록 높이를 반환한다.
* @param {Object} block - 렌더링 블록
@@ -305,6 +316,67 @@ const parseSignupMeta = (raw) => {
return meta
}
/**
* 미디어 fenced 블록 본문에서 URL과 표시 메타를 파싱한다.
* @param {string} raw - fenced 내부 텍스트
* @returns {{url: string, title: string, description: string, poster: string, caption: string, fileName: string, size: string}} 미디어 메타
*/
const parseMediaMeta = (raw) => {
const meta = {
url: '',
title: '',
description: '',
poster: '',
caption: '',
fileName: '',
size: ''
}
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
for (const line of lines) {
const kv = line.match(/^(\w+)=(.*)$/)
if (kv) {
const key = kv[1].toLowerCase()
const val = kv[2].trim()
if (key === 'url' || key === 'src') {
meta.url = val
} else if (key === 'title') {
meta.title = val
} else if (key === 'description' || key === 'desc') {
meta.description = val
} else if (key === 'poster' || key === 'thumbnail') {
meta.poster = val
} else if (key === 'caption') {
meta.caption = val
} else if (key === 'name' || key === 'filename' || key === 'file') {
meta.fileName = val
} else if (key === 'size') {
meta.size = val
}
continue
}
if (!meta.url && (/^https?:\/\//i.test(line) || line.startsWith('/'))) {
meta.url = line
continue
}
if (meta.url && !meta.title) {
meta.title = line
continue
}
if (meta.url && meta.title && !meta.description) {
meta.description = line
}
}
return meta
}
/**
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
* @param {string} markdown - 마크다운 문자열
@@ -401,6 +473,22 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if ([':::video', ':::audio', ':::file'].includes(trimmedLine)) {
const startLine = index
const blockType = trimmedLine.replace(':::', '')
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const mediaMeta = parseMediaMeta(contentLines.join('\n'))
blocks.push(attachSourceRange(
createBlock(blockType, '', null, `block-${blocks.length}`, { meta: mediaMeta }),
startLine,
nextIndex
))
index = nextIndex
continue
}
if (trimmedLine.startsWith(':::callout')) {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
@@ -438,6 +526,17 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if (isStandaloneUrlLine(trimmedLine)) {
const startLine = index
blocks.push(attachSourceRange(
createBlock('embed', '', null, `block-${blocks.length}`, { url: trimmedLine }),
startLine,
startLine
))
index += 1
continue
}
const image = parseImageLine(trimmedLine)
if (image) {
@@ -586,7 +685,13 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
return
}
const element = rendererRootRef.value?.querySelector(`[data-source-line="${lineIndex}"]`)
const matches = rendererRootRef.value
? [...rendererRootRef.value.querySelectorAll(`[data-source-line="${lineIndex}"]`)]
: []
const element = matches.find((node) => node.getAttribute('contenteditable') === 'true')
|| matches[0]
|| null
if (!element) {
if (attempt < 8) {
@@ -602,12 +707,19 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
const isBlankMarker = /^>\s*$/.test(line) || /^[-*+]\s*$/.test(line) || /^\d+\.\s*$/.test(line.trim())
if (isBlankMarker || !line.trim()) {
element.textContent = ''
element.innerHTML = ''
if (element.getAttribute('contenteditable') === 'true') {
element.textContent = ''
element.innerHTML = ''
}
}
element.focus({ preventScroll: true })
if (element.getAttribute('contenteditable') !== 'true') {
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
return
}
if (typeof caretOffset === 'number' && caretOffset >= 0) {
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
return
@@ -625,6 +737,7 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
}
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
defineExpose({
@@ -683,6 +796,18 @@ const getHeadingEditableClass = (level) => {
return `${base} text-[clamp(0.9rem,0.875rem+0.1vw,1rem)]`
}
/**
* 제목 블록 마크다운 줄을 만든다.
* @param {number} level - 제목 레벨
* @param {string} value - 제목 텍스트
* @returns {string} 제목 마크다운 줄
*/
const buildHeadingLine = (level, value) => {
const headingPrefix = `${'#'.repeat(Math.min(Math.max(level, 1), 6))} `
const cleanText = String(value ?? '').replace(/\s+/g, ' ').trim()
return `${headingPrefix}${cleanText}`.trimEnd() || headingPrefix.trim()
}
/**
* commit 이벤트 페이로드를 정규화한다.
* @param {string|{ value: string, raw?: boolean }} payload - 페이로드
@@ -1029,6 +1154,120 @@ const onInsertBelowBlock = (block, options = {}) => {
})
}
/**
* 선택형 프리뷰 카드 블록에 포커스를 준다.
* @param {Event} event - 포커스 유도 이벤트
* @returns {void}
*/
const focusPreviewCardBlock = (event) => {
const target = event.currentTarget
const element = target instanceof HTMLElement
? target.closest('[data-preview-card-block="true"]') || target
: null
if (element instanceof HTMLElement) {
element.focus({ preventScroll: true })
}
}
/**
* 선택형 프리뷰 카드에서 이전/다음 줄로 이동한다.
* @param {Object} block - 프리뷰 카드 블록
* @param {1|-1} direction - 이동 방향
* @returns {void}
*/
const moveFromPreviewCardBlock = (block, direction) => {
const startLine = block.meta?.startLine
const endLine = block.meta?.endLine ?? startLine
if (typeof startLine !== 'number' || typeof endLine !== 'number') {
return
}
if (direction > 0) {
const lines = String(props.content || '').split('\n')
const nextLine = endLine + 1
if (nextLine >= lines.length) {
onInsertBelowBlock(block)
return
}
focusEditableAtLine(nextLine, 0, 'start')
return
}
if (startLine <= 0) {
return
}
focusEditableAtLine(startLine - 1, 0, 'end')
}
/**
* 선택형 프리뷰 카드 블록 전체를 삭제한다.
* @param {Object} block - 프리뷰 카드 블록
* @returns {void}
*/
const deletePreviewCardBlock = (block) => {
if (!props.interactive || typeof block.meta?.startLine !== 'number') {
return
}
const startLine = block.meta.startLine
const endLine = block.meta.endLine ?? startLine
pendingFocusLine.value = startLine > 0 ? startLine - 1 : 0
pendingFocusPosition.value = startLine > 0 ? 'end' : 'start'
emit('block-content-change', {
startLine,
endLine,
replacementLines: []
})
}
/**
* 선택형 프리뷰 카드 블록 키보드 조작을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @param {Object} block - 프리뷰 카드 블록
* @returns {void}
*/
const onPreviewCardKeydown = (event, block) => {
const isDeleteShortcut = (event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k'
const isActionButton = event.target instanceof HTMLElement
&& Boolean(event.target.closest('.content-markdown-renderer__preview-card-action'))
if (isActionButton && (event.key === 'Enter' || event.key === ' ')) {
return
}
if (event.key === 'Backspace' || event.key === 'Delete' || isDeleteShortcut) {
event.preventDefault()
event.stopPropagation()
deletePreviewCardBlock(block)
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
event.stopPropagation()
moveFromPreviewCardBlock(block, 1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
event.stopPropagation()
moveFromPreviewCardBlock(block, -1)
return
}
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
onInsertBelowBlock(block)
}
}
/**
* 목록 항목 Enter — 빈 마커 줄이면 문단으로 탈출, 내용이 있으면 아래에 빈 줄만 삽입한다.
* @param {Object} block - 목록 블록
@@ -1118,9 +1357,20 @@ const onHeadingInlineCommit = (block, payload) => {
return
}
const headingPrefix = `${'#'.repeat(Math.min(Math.max(block.level, 1), 6))} `
const cleanText = String(value ?? '').replace(/\s+/g, ' ').trim()
commitInlineBlockLines(block, [`${headingPrefix}${cleanText}`.trimEnd() || headingPrefix.trim()])
commitInlineBlockLines(block, [buildHeadingLine(block.level, value)])
}
/**
* 제목 Enter — 현재 제목 값을 저장하고 아래에 빈 줄을 만든다.
* @param {Object} block - 제목 블록
* @param {string|Object} payload - insert-below 페이로드
* @returns {void}
*/
const onHeadingInsertBelow = (block, payload) => {
const { value } = normalizeInsertBelowPayload(payload)
pendingFocusLine.value = block.meta.startLine + 1
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, [buildHeadingLine(block.level, value), ''])
}
/**
@@ -1490,12 +1740,10 @@ onBeforeUnmount(() => {
:tag="`h${Math.min(Math.max(block.level, 1), 6)}`"
:block-class="getHeadingEditableClass(block.level)"
enter-mode="insert-below"
allow-raw-toggle
:raw-line="getMarkdownLine(block.meta.startLine)"
:source-line="block.meta.startLine"
:model-value="block.text"
@commit="onHeadingInlineCommit(block, $event)"
@insert-below="onInsertBelowBlock(block)"
@insert-below="onHeadingInsertBelow(block, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
/>
@@ -1658,6 +1906,89 @@ onBeforeUnmount(() => {
:button-label="block.meta.button"
:placeholder="block.meta.placeholder"
/>
<section
v-else-if="['video', 'audio', 'file'].includes(block.type) && interactive"
class="content-markdown-renderer__preview-card group relative my-8 rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
:data-source-line="block.meta.startLine"
data-preview-card-block="true"
tabindex="0"
role="group"
:aria-label="`${block.type} 블록`"
@mousedown.capture="focusPreviewCardBlock"
@keydown="onPreviewCardKeydown($event, block)"
>
<button
class="content-markdown-renderer__preview-card-delete content-markdown-renderer__preview-card-action absolute right-2 top-2 z-10 rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white opacity-0 shadow transition-opacity hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)] group-hover:opacity-100 group-focus-within:opacity-100"
type="button"
:aria-label="`${block.type} 삭제`"
@click.stop="deletePreviewCardBlock(block)"
>
삭제
</button>
<ProseVideo
v-if="block.type === 'video'"
:src="block.meta.url"
:title="block.meta.title"
:poster="block.meta.poster"
:caption="block.meta.caption"
/>
<ProseAudio
v-else-if="block.type === 'audio'"
:src="block.meta.url"
:title="block.meta.title"
:description="block.meta.description"
/>
<ProseFile
v-else
:href="block.meta.url"
:title="block.meta.title"
:description="block.meta.description"
:file-name="block.meta.fileName"
:size="block.meta.size"
/>
</section>
<ProseVideo
v-else-if="block.type === 'video'"
:src="block.meta.url"
:title="block.meta.title"
:poster="block.meta.poster"
:caption="block.meta.caption"
/>
<ProseAudio
v-else-if="block.type === 'audio'"
:src="block.meta.url"
:title="block.meta.title"
:description="block.meta.description"
/>
<ProseFile
v-else-if="block.type === 'file'"
:href="block.meta.url"
:title="block.meta.title"
:description="block.meta.description"
:file-name="block.meta.fileName"
:size="block.meta.size"
/>
<section
v-else-if="block.type === 'embed' && interactive"
class="content-markdown-renderer__embed-live group relative my-8 rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
:data-source-line="block.meta.startLine"
data-preview-card-block="true"
tabindex="0"
role="group"
aria-label="임베드 블록"
@mousedown.capture="focusPreviewCardBlock"
@keydown="onPreviewCardKeydown($event, block)"
>
<button
class="content-markdown-renderer__embed-delete content-markdown-renderer__preview-card-action absolute right-2 top-2 z-10 rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white opacity-0 shadow transition-opacity hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)] group-hover:opacity-100 group-focus-within:opacity-100"
type="button"
aria-label="임베드 삭제"
@click.stop="deletePreviewCardBlock(block)"
>
삭제
</button>
<ProseEmbed :url="block.url" />
</section>
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
<div
v-else-if="block.type === 'gallery'"