운영 시작 버전 v1.0.0 정리

This commit is contained in:
2026-05-14 10:49:25 +09:00
parent 069d1bfbd4
commit 3b331b8fe6
18 changed files with 1679 additions and 94 deletions

View File

@@ -28,8 +28,13 @@ const isLoadingMedia = ref(false)
const isComposingText = ref(false)
const isNormalizingTrailingBlock = ref(false)
const pendingSoftLineBreakIndex = ref(-1)
const pendingSlashCommandIndex = ref(-1)
const draggingGalleryImage = ref(null)
const galleryDragTarget = ref(null)
const isKeyboardPriorityMode = ref(false)
const calloutEmojiPickerBlockId = ref('')
const calloutColorPopoverBlockId = ref('')
const calloutEmojiComposingBlockId = ref('')
let blockIdSeed = 0
const imageWidthOptions = [
@@ -38,6 +43,16 @@ const imageWidthOptions = [
{ value: 'full', label: '풀사이즈' }
]
const calloutBackgroundOptions = [
{ value: 'gray', label: '회색' },
{ value: 'blue', label: '파랑' },
{ value: 'green', label: '초록' },
{ value: 'yellow', label: '노랑' },
{ value: 'red', label: '빨강' },
{ value: 'purple', label: '보라' },
{ value: 'pink', label: '핑크' }
]
const blockCommands = [
{
type: 'paragraph',
@@ -135,9 +150,52 @@ const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '',
alt: options.alt || '',
title: options.title || '',
width: options.width || 'regular',
images: options.images || []
images: options.images || [],
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
calloutEmoji: options.calloutEmoji || '💡',
calloutBackground: options.calloutBackground || 'blue'
})
/**
* 콜아웃 선언부 옵션을 파싱
* @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.some((item) => item.value === value)) {
options.calloutBackground = value
}
})
return options
}
/**
* 이미지 마크다운 행을 블록 옵션으로 변환
* @param {string} line - 마크다운 행
@@ -219,9 +277,9 @@ const parseMarkdownToBlocks = (markdown) => {
continue
}
if (trimmedLine === ':::callout') {
if (trimmedLine.startsWith(':::callout')) {
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(createEditorBlock('callout', contentLines.join('\n'), null, `editor-block-${blocks.length}`))
blocks.push(createEditorBlock('callout', contentLines.join('\n'), null, `editor-block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
index = nextIndex
continue
}
@@ -343,8 +401,14 @@ const serializeBlocks = () => {
}
if (block.type === 'callout') {
const emoji = block.calloutEmojiEnabled
? (block.calloutEmoji || '💡')
: 'none'
const bg = calloutBackgroundOptions.some((item) => item.value === block.calloutBackground)
? block.calloutBackground
: 'blue'
return text
? { type: block.type, value: `:::callout\n${text}\n:::` }
? { type: block.type, value: `:::callout emoji=${emoji} bg=${bg}\n${text}\n:::` }
: null
}
@@ -415,6 +479,27 @@ const emitContent = () => {
emit('update:modelValue', serializeBlocks())
}
/**
* 키보드 입력 우선 모드 활성화
* @returns {void}
*/
const enableKeyboardPriorityMode = () => {
isKeyboardPriorityMode.value = true
}
/**
* 마우스 상호작용 시 hover 복귀
* @param {MouseEvent} event - 마우스 이동 이벤트
* @returns {void}
*/
const handleEditorMouseMove = (event) => {
if (event.buttons !== 0) {
return
}
isKeyboardPriorityMode.value = false
}
/**
* 텍스트 입력 블록 여부 반환
* @param {Object} block - 에디터 블록
@@ -682,7 +767,6 @@ const getBlockClass = (block) => [
'admin-block-editor__heading--h2 text-4xl': block.type === 'heading' && block.level === 2,
'admin-block-editor__heading--h3 text-3xl': block.type === 'heading' && block.level === 3,
'admin-block-editor__quote border-l-4 border-ink bg-surface px-5 py-3 text-xl font-medium leading-8': block.type === 'quote',
'admin-block-editor__callout min-h-14 rounded border border-line bg-surface px-5 py-4 text-[16px] leading-7': block.type === 'callout',
'admin-block-editor__list relative min-h-8 pl-7 text-[17px] leading-8 before:absolute before:left-2 before:top-3 before:h-2 before:w-2 before:rounded-full before:bg-current': block.type === 'list',
'admin-block-editor__code min-h-14 whitespace-pre-wrap rounded bg-[#15171a] px-4 py-3 font-mono text-sm leading-6 text-white': block.type === 'code'
}
@@ -712,6 +796,8 @@ const getImageWidthClass = (width) => {
* @returns {void}
*/
const updateBlockText = (event, index) => {
enableKeyboardPriorityMode()
const block = editorBlocks.value[index]
const text = getTextBlockDomText(index)
@@ -745,26 +831,49 @@ const startTextComposition = () => {
const finishTextComposition = (event, index) => {
isComposingText.value = false
nextTick(() => {
window.setTimeout(() => {
const block = syncTextBlockFromDom(index)
if (!block) {
pendingSoftLineBreakIndex.value = -1
return
}
applyMarkdownShortcut(block, index)
if (pendingSoftLineBreakIndex.value === index && isTextBlock(block) && block.type !== 'code') {
pendingSoftLineBreakIndex.value = -1
insertSoftLineBreak(index)
return
}
const syncAfterComposition = () => {
const block = syncTextBlockFromDom(index)
if (!block) {
pendingSoftLineBreakIndex.value = -1
emitContent()
}, 0)
pendingSlashCommandIndex.value = -1
return
}
applyMarkdownShortcut(block, index)
const trimmedText = (block.text || '').trim()
const canApplyPendingSlashCommand = pendingSlashCommandIndex.value === index
&& block.text.startsWith('/')
&& visibleCommands.value.length > 0
&& trimmedText !== '/'
&& trimmedText !== '//'
if (canApplyPendingSlashCommand) {
pendingSlashCommandIndex.value = -1
const command = highlightedCommand.value || visibleCommands.value[0]
if (command) {
applyCommand(command)
return
}
}
if (pendingSoftLineBreakIndex.value === index && isTextBlock(block) && block.type !== 'code') {
pendingSoftLineBreakIndex.value = -1
pendingSlashCommandIndex.value = -1
insertSoftLineBreak(index)
return
}
pendingSoftLineBreakIndex.value = -1
pendingSlashCommandIndex.value = -1
emitContent()
}
nextTick(() => {
syncAfterComposition()
window.setTimeout(syncAfterComposition, 0)
})
}
@@ -856,6 +965,21 @@ const visibleCommands = computed(() => {
})
const highlightedCommand = computed(() => visibleCommands.value[highlightedCommandIndex.value])
const activeCalloutBlock = computed(() => {
const activeBlock = editorBlocks.value.find((block) => block.id === activeBlockId.value)
if (activeBlock?.type === 'callout') {
return activeBlock
}
const selectedBlock = editorBlocks.value.find((block) => block.id === selectedBlockId.value)
if (selectedBlock?.type === 'callout') {
return selectedBlock
}
return null
})
/**
* 슬래시 메뉴 명령 적용
@@ -1336,6 +1460,8 @@ const navigateAcrossBlocks = (event, index, direction) => {
* @returns {void}
*/
const handleEnter = (event, index) => {
enableKeyboardPriorityMode()
const currentBlock = syncTextBlockFromDom(index)
if (isComposingText.value || event.isComposing || event.keyCode === 229) {
@@ -1345,6 +1471,12 @@ const handleEnter = (event, index) => {
pendingSoftLineBreakIndex.value = index
}
if (!event.shiftKey && (currentBlock.text || '').startsWith('/')) {
pendingSlashCommandIndex.value = index
} else {
pendingSlashCommandIndex.value = -1
}
return
}
@@ -1358,7 +1490,13 @@ const handleEnter = (event, index) => {
return
}
if (currentBlock.text.startsWith('/')) {
const trimmedText = (currentBlock.text || '').trim()
const canApplySlashCommand = currentBlock.text.startsWith('/')
&& visibleCommands.value.length > 0
&& trimmedText !== '/'
&& trimmedText !== '//'
if (canApplySlashCommand) {
event.preventDefault()
const command = highlightedCommand.value || visibleCommands.value[0]
@@ -1379,7 +1517,7 @@ const handleEnter = (event, index) => {
return
}
if (!currentBlock.text.trim() && currentBlock.type !== 'paragraph') {
if (!currentBlock.text.trim() && !['paragraph', 'callout'].includes(currentBlock.type)) {
currentBlock.type = 'paragraph'
currentBlock.level = null
normalizeTrailingTextBlock()
@@ -1426,6 +1564,51 @@ const handleBackspace = (event, index) => {
*/
const getBlockIndex = (blockId) => editorBlocks.value.findIndex((block) => block.id === blockId)
/**
* 포커스 이탈 시 자동 정리할 구조형 블록인지 확인
* @param {Object|undefined} block - 에디터 블록
* @returns {boolean} 자동 정리 대상 여부
*/
const isDiscardableStructuredBlock = (block) => {
if (!block) {
return false
}
if (block.type === 'image') {
return !block.url
}
if (block.type === 'gallery') {
return !block.images.length
}
return false
}
/**
* 다른 블록으로 이동할 때 미사용 구조형 블록을 정리
* @param {string} nextBlockId - 다음 활성 블록 ID
* @returns {void}
*/
const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => {
const previousActiveBlockId = activeBlockId.value
if (!previousActiveBlockId || previousActiveBlockId === nextBlockId) {
return
}
const previousBlockIndex = getBlockIndex(previousActiveBlockId)
const previousBlock = editorBlocks.value[previousBlockIndex]
if (!isDiscardableStructuredBlock(previousBlock) || editorBlocks.value.length <= 1) {
return
}
editorBlocks.value.splice(previousBlockIndex, 1)
normalizeTrailingTextBlock()
emitContent()
}
/**
* 블록 선택 상태 적용
* @param {Object} block - 선택할 블록
@@ -1580,6 +1763,8 @@ const finishBlockDrag = () => {
* @returns {void}
*/
const activateBlock = (block) => {
cleanupUnusedStructuredBlockOnActivate(block.id)
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
activeBlockId.value = block.id
selectedBlockId.value = ''
@@ -1607,6 +1792,123 @@ const updateStructuredBlock = () => {
emitContent()
}
/**
* 콜아웃 이모지 표시 상태를 전환
* @param {Object} block - 콜아웃 블록
* @returns {void}
*/
const toggleCalloutEmoji = (block) => {
block.calloutEmojiEnabled = !block.calloutEmojiEnabled
if (!block.calloutEmojiEnabled) {
calloutEmojiPickerBlockId.value = ''
}
updateStructuredBlock()
}
/**
* 콜아웃 이모지를 변경
* @param {Object} block - 콜아웃 블록
* @param {string} emoji - 이모지 문자열
* @returns {void}
*/
const updateCalloutEmoji = (block, emoji) => {
block.calloutEmoji = emoji || ''
block.calloutEmojiEnabled = true
updateStructuredBlock()
}
/**
* 콜아웃 배경 프리셋을 변경
* @param {Object} block - 콜아웃 블록
* @param {string} background - 배경 프리셋 값
* @returns {void}
*/
const updateCalloutBackground = (block, background) => {
block.calloutBackground = background
calloutColorPopoverBlockId.value = ''
updateStructuredBlock()
}
/**
* 콜아웃 이모지 팝업 토글
* @param {Object} block - 콜아웃 블록
* @returns {void}
*/
const toggleCalloutEmojiPicker = (block) => {
calloutEmojiPickerBlockId.value = calloutEmojiPickerBlockId.value === block.id
? ''
: block.id
}
/**
* 콜아웃 배경색 팝오버 토글
* @param {Object} block - 콜아웃 블록
* @returns {void}
*/
const toggleCalloutColorPopover = (block) => {
calloutColorPopoverBlockId.value = calloutColorPopoverBlockId.value === block.id
? ''
: block.id
}
/**
* 콜아웃 이모지 입력값 적용
* @param {Object} block - 콜아웃 블록
* @param {Event} event - 입력 이벤트
* @returns {void}
*/
const updateCalloutEmojiFromInput = (block, event) => {
if (calloutEmojiComposingBlockId.value === block.id || event?.isComposing) {
return
}
const rawValue = String(event.target?.value || event.target?.textContent || '')
block.calloutEmoji = rawValue
block.calloutEmojiEnabled = true
updateStructuredBlock()
}
/**
* 콜아웃 이모지 입력 조합 시작 처리
* @param {Object} block - 콜아웃 블록
* @returns {void}
*/
const startCalloutEmojiComposition = (block) => {
calloutEmojiComposingBlockId.value = block.id
}
/**
* 콜아웃 이모지 입력 조합 종료 처리
* @param {Object} block - 콜아웃 블록
* @param {CompositionEvent} event - 조합 종료 이벤트
* @returns {void}
*/
const finishCalloutEmojiComposition = (block, event) => {
calloutEmojiComposingBlockId.value = ''
updateCalloutEmojiFromInput(block, event)
}
/**
* 콜아웃 이모지 입력을 1문자 슬롯으로 정규화
* @param {Object} block - 콜아웃 블록
* @param {FocusEvent} event - blur 이벤트
* @returns {void}
*/
const normalizeCalloutEmojiInput = (block, event) => {
const rawValue = String(block.calloutEmoji || '').trim()
const graphemes = typeof Intl !== 'undefined' && Intl.Segmenter
? Array.from(new Intl.Segmenter('ko', { granularity: 'grapheme' }).segment(rawValue), (segment) => segment.segment)
: Array.from(rawValue)
const nextValue = graphemes.length ? graphemes[graphemes.length - 1] : ''
block.calloutEmoji = nextValue
updateStructuredBlock()
if (event?.target && 'value' in event.target) {
event.target.value = nextValue
}
}
watch(() => props.modelValue, (value) => {
if (isApplyingExternalValue.value) {
return
@@ -1637,13 +1939,24 @@ watch(
}
)
watch(activeCalloutBlock, (nextBlock) => {
if (!nextBlock) {
calloutEmojiPickerBlockId.value = ''
calloutColorPopoverBlockId.value = ''
}
})
defineExpose({
focusFirstBlock: () => focusBlock(0)
})
</script>
<template>
<div class="admin-block-editor bg-transparent py-4 text-ink">
<div
class="admin-block-editor bg-transparent py-4 text-ink"
:class="{ 'admin-block-editor--keyboard-priority': isKeyboardPriorityMode }"
@mousemove="handleEditorMouseMove"
>
<div class="admin-block-editor__surface post-prose">
<div
v-for="(block, index) in editorBlocks"
@@ -1654,6 +1967,7 @@ defineExpose({
'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id,
'admin-block-editor__row--drop-before': dragTargetIndex === index && dragTargetPosition === 'before',
'admin-block-editor__row--drop-after': dragTargetIndex === index && dragTargetPosition === 'after',
'admin-block-editor__row--callout': block.type === 'callout',
'admin-block-editor__row--menu-open z-30': visibleCommands.length && activeBlockId === block.id,
'admin-block-editor__row--text': isTextBlock(block),
'admin-block-editor__row--structure': !isTextBlock(block)
@@ -1663,7 +1977,7 @@ defineExpose({
@drop="dropBlock($event, index)"
>
<button
class="admin-block-editor__handle absolute -left-9 bottom-0 top-0 z-10 flex w-5 cursor-grab items-stretch justify-center rounded opacity-0 outline-none transition-opacity duration-150 group-hover/block:opacity-100 focus:opacity-100 active:cursor-grabbing"
class="admin-block-editor__handle absolute -left-9 bottom-0 top-0 z-10 flex w-5 cursor-grab items-stretch justify-center rounded opacity-0 outline-none transition-opacity duration-150 focus:opacity-100 active:cursor-grabbing"
type="button"
draggable="true"
aria-label="블록 이동 선택"
@@ -1784,6 +2098,138 @@ defineExpose({
</div>
</figure>
<section
v-else-if="block.type === 'callout'"
class="admin-block-editor__callout-editor relative"
@focusin="activateBlock(block)"
@click="activateBlock(block)"
@keydown.enter="handleEnter($event, index)"
@keydown.backspace="handleBackspace($event, index)"
>
<ProseCallout
:emoji-enabled="false"
:background="block.calloutBackground"
>
<div class="flex items-start gap-2">
<button
v-if="block.calloutEmojiEnabled"
class="inline-flex size-9 shrink-0 items-center justify-center cursor-pointer rounded-md text-xl text-[#1f2328] hover:bg-[#7f8da1]/20"
type="button"
@click.stop="toggleCalloutEmojiPicker(block)"
>
{{ block.calloutEmoji || '💡' }}
</button>
<div
:ref="(element) => setBlockRef(element, index)"
class="admin-block-editor__callout-input w-full bg-transparent text-[15px] leading-8 text-[#1f2328] outline-none"
contenteditable="true"
spellcheck="true"
data-placeholder="콜아웃 텍스트 입력은 이렇게"
:data-show-placeholder="!block.text"
@focus="activateBlock(block)"
@input="updateBlockText($event, index)"
@compositionstart="startTextComposition"
@compositionend="finishTextComposition($event, index)"
@keydown.enter="handleEnter($event, index)"
@keydown.down="block.text.startsWith('/') ? highlightNextCommand($event) : navigateAcrossBlocks($event, index, 'down')"
@keydown.up="block.text.startsWith('/') ? highlightPreviousCommand($event) : navigateAcrossBlocks($event, index, 'up')"
@keydown.backspace="handleBackspace($event, index)"
/>
</div>
</ProseCallout>
<div
v-if="activeCalloutBlock?.id === block.id"
class="admin-block-editor__callout-settings absolute left-full top-0 z-[70] ml-4 flex w-[320px] flex-col gap-3 rounded-lg bg-white p-6 shadow-lg"
>
<label class="flex w-full items-center justify-between">
<div class="text-sm font-medium text-ink">Emoji</div>
<button
class="relative inline-flex h-4 w-7 items-center rounded-full transition-colors"
:class="block.calloutEmojiEnabled ? 'bg-black' : 'bg-[#d0d7de]'"
type="button"
@click="toggleCalloutEmoji(block)"
>
<span
class="absolute size-3 rounded-full bg-white transition-transform"
:class="block.calloutEmojiEnabled ? 'translate-x-[12px]' : 'translate-x-[2px]'"
></span>
</button>
</label>
<div class="relative flex w-full items-center justify-between">
<div class="text-sm font-medium text-ink">Background</div>
<button
class="relative size-6 shrink-0 cursor-pointer rounded-full p-[2px]"
type="button"
@click="toggleCalloutColorPopover(block)"
>
<span
class="absolute inset-0 rounded-full"
style="background: conic-gradient(rgb(255,0,0), rgb(255,0,191), rgb(128,0,255), rgb(0,64,255), rgb(0,255,255), rgb(0,255,64), rgb(128,255,0), rgb(255,191,0), rgb(255,0,0)); mask: linear-gradient(#fff 0 0) content-box exclude, linear-gradient(#fff 0 0); padding:3px;"
></span>
<span
class="relative block size-full rounded-full border-2 border-white ring-1 ring-black/10"
:style="{
background: block.calloutBackground === 'gray' ? 'rgba(100,116,139,0.28)'
: block.calloutBackground === 'blue' ? 'rgba(59,130,246,0.3)'
: block.calloutBackground === 'green' ? 'rgba(34,197,94,0.3)'
: block.calloutBackground === 'yellow' ? 'rgba(245,158,11,0.34)'
: block.calloutBackground === 'red' ? 'rgba(239,68,68,0.3)'
: block.calloutBackground === 'purple' ? 'rgba(168,85,247,0.3)'
: 'rgba(236,72,153,0.3)'
}"
></span>
</button>
<div
v-if="calloutColorPopoverBlockId === block.id"
class="absolute -right-2 bottom-full mb-2 z-[90] rounded-lg bg-white px-3 py-2 shadow"
>
<ul class="flex items-center gap-1">
<li v-for="backgroundOption in calloutBackgroundOptions" :key="`color-pop-${block.id}-${backgroundOption.value}`">
<button
class="group relative flex size-6 cursor-pointer items-center justify-center rounded-full border-2"
:class="block.calloutBackground === backgroundOption.value ? 'border-[#22c55e]' : 'border-transparent'"
type="button"
:aria-label="backgroundOption.label"
@click="updateCalloutBackground(block, backgroundOption.value)"
>
<span
class="size-[1.4rem] rounded-full border border-black/10"
:style="{
background: backgroundOption.value === 'gray' ? 'rgba(100,116,139,0.28)'
: backgroundOption.value === 'blue' ? 'rgba(59,130,246,0.3)'
: backgroundOption.value === 'green' ? 'rgba(34,197,94,0.3)'
: backgroundOption.value === 'yellow' ? 'rgba(245,158,11,0.34)'
: backgroundOption.value === 'red' ? 'rgba(239,68,68,0.3)'
: backgroundOption.value === 'purple' ? 'rgba(168,85,247,0.3)'
: 'rgba(236,72,153,0.3)'
}"
></span>
</button>
</li>
</ul>
</div>
</div>
<div v-if="block.calloutEmojiEnabled && calloutEmojiPickerBlockId === block.id" class="rounded-lg border border-line bg-white p-3">
<p class="mb-2 text-xs text-muted">이모지를 붙여넣거나 시스템 이모지 입력을 사용하세요</p>
<input
class="w-16 rounded-lg border border-line px-2 py-2 text-center text-2xl text-[#1f2328] outline-none"
type="text"
:value="block.calloutEmoji || ''"
maxlength="8"
spellcheck="false"
@input="updateCalloutEmojiFromInput(block, $event)"
@compositionstart="startCalloutEmojiComposition(block)"
@compositionend="finishCalloutEmojiComposition(block, $event)"
@blur="normalizeCalloutEmojiInput(block, $event)"
>
</div>
</div>
</section>
<section
v-else-if="block.type === 'toggle'"
class="admin-block-editor__toggle rounded border border-line bg-paper p-5"
@@ -1986,13 +2432,18 @@ defineExpose({
transform 120ms ease;
}
.admin-block-editor__row:hover::before,
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover::before,
.admin-block-editor__row--selected::before {
background: #eff1f2;
opacity: 1;
transform: scaleX(1);
}
.admin-block-editor__row--callout:hover::before,
.admin-block-editor__row--callout.admin-block-editor__row--selected::before {
opacity: 0;
}
.admin-block-editor__row--drop-before::after {
top: -18px;
opacity: 1;
@@ -2009,6 +2460,10 @@ defineExpose({
min-height: 32px;
}
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__handle {
opacity: 1;
}
.admin-block-editor__handle-container {
display: flex;
width: 16px;
@@ -2031,7 +2486,7 @@ defineExpose({
opacity 160ms ease;
}
.admin-block-editor__row:hover .admin-block-editor__handle-grabber,
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__handle-grabber,
.admin-block-editor__row--selected .admin-block-editor__handle-grabber,
.admin-block-editor__handle:focus .admin-block-editor__handle-grabber {
height: 100%;
@@ -2057,6 +2512,11 @@ defineExpose({
caret-color: #f8fafc;
}
.admin-block-editor__callout-input:empty[data-show-placeholder="true"]::before {
color: var(--site-soft);
content: attr(data-placeholder);
}
.admin-block-editor__gallery-item--drop-before::before,
.admin-block-editor__gallery-item--drop-after::before {
position: absolute;

View File

@@ -51,6 +51,11 @@ const isTitleInputComposing = ref(false)
const activeMediaPickerTab = ref('upload')
const selectedMediaPickerUrl = ref('')
const savedPostSnapshot = ref('')
const isPublishModalOpen = ref(false)
const publishStatus = ref('draft')
const publishTiming = ref('now')
const scheduledPublishAt = ref('')
const publishModalExpandedSection = ref(null)
/**
* ISO 날짜를 datetime-local 입력값으로 변환
@@ -236,6 +241,49 @@ const editorStatusLabel = computed(() => {
return '초안'
})
/**
* 발행 모달에 표시할 게시 상태 요약 문구
* @returns {string} 요약 문구
*/
const publishStatusSummaryLabel = computed(() => {
if (publishStatus.value === 'published') {
return '발행'
}
if (publishStatus.value === 'private') {
return '비공개'
}
return '초안'
})
/**
* 발행 모달에 표시할 발행 시점 요약 문구
* @returns {string} 요약 문구
*/
const publishTimingSummaryLabel = computed(() => {
if (publishTiming.value === 'now') {
return '지금 바로'
}
const raw = scheduledPublishAt.value
if (!raw) {
return '예약'
}
const date = new Date(raw)
if (Number.isNaN(date.getTime())) {
return '예약'
}
return new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date)
})
/**
* 게시물 입력값 생성
* @returns {Object} 게시물 입력값
@@ -580,6 +628,7 @@ const focusContentEditor = (event) => {
* @returns {void}
*/
const submitPost = () => {
isPublishModalOpen.value = false
emit('submit', createPostPayload())
}
@@ -607,6 +656,100 @@ const toggleSettingsPanel = () => {
isSettingsOpen.value = !isSettingsOpen.value
}
/**
* 발행 모달을 현재 폼 상태로 초기화한다.
* @returns {void}
*/
const syncPublishModalStateFromForm = () => {
publishStatus.value = form.status || 'draft'
scheduledPublishAt.value = form.publishedAt || toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
publishTiming.value = isScheduledPost() ? 'schedule' : 'now'
}
/**
* 발행 모달 열기
* @returns {void}
*/
const openPublishModal = () => {
syncPublishModalStateFromForm()
publishModalExpandedSection.value = null
isPublishModalOpen.value = true
}
/**
* 발행 모달 닫기
* @returns {void}
*/
const closePublishModal = () => {
isPublishModalOpen.value = false
}
/**
* 발행 모달에서 선택한 값을 폼에 반영
* @returns {void}
*/
const applyPublishSelectionToForm = () => {
form.status = publishStatus.value
if (publishStatus.value !== 'published') {
form.publishedAt = ''
return
}
if (publishTiming.value === 'schedule') {
form.publishedAt = scheduledPublishAt.value || toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
return
}
form.publishedAt = toDateTimeLocalValue(new Date().toISOString())
}
/**
* 발행 모달에서 최종 저장/발행 확정
* @returns {void}
*/
const submitFromPublishModal = () => {
applyPublishSelectionToForm()
submitPost()
}
/**
* 발행 모달에서 설정 행 펼침을 토글한다.
* @param {'status' | 'timing'} section - 펼칠 행
* @returns {void}
*/
const togglePublishModalSection = (section) => {
publishModalExpandedSection.value =
publishModalExpandedSection.value === section ? null : section
}
/**
* 발행 모달에서 게시 상태를 선택한다.
* @param {'published' | 'draft' | 'private'} status - 선택 상태
* @returns {void}
*/
const selectPublishStatus = (status) => {
publishStatus.value = status
publishModalExpandedSection.value = null
if (status !== 'published') {
publishTiming.value = 'now'
}
}
/**
* 발행 모달에서 발행 시점을 선택한다.
* @param {'now' | 'schedule'} timing - 즉시 또는 예약
* @returns {void}
*/
const selectPublishTiming = (timing) => {
publishTiming.value = timing
if (timing === 'now') {
publishModalExpandedSection.value = null
}
}
/**
* 현재 입력값을 저장 완료 기준점으로 표시한다.
* @returns {void}
@@ -615,6 +758,16 @@ const markSaved = () => {
savedPostSnapshot.value = serializePostPayload()
}
watch(publishStatus, (next) => {
if (next !== 'published') {
publishTiming.value = 'now'
if (publishModalExpandedSection.value === 'timing') {
publishModalExpandedSection.value = null
}
}
})
watch(form, scheduleAutosave, { deep: true })
onMounted(() => {
@@ -652,18 +805,40 @@ defineExpose({
</script>
<template>
<form class="admin-post-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="submitPost">
<form class="admin-post-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="openPublishModal">
<div class="admin-post-form__workspace flex min-w-0 flex-1 flex-col bg-white">
<header class="admin-post-form__toolbar flex h-[56px] shrink-0 items-center bg-white px-8">
<div class="admin-post-form__toolbar-inner flex h-[34px] min-w-0 flex-1 items-center justify-between">
<div class="admin-post-form__toolbar-left flex h-full min-w-0 items-center gap-3">
<NuxtLink class="admin-post-form__toolbar-link inline-flex items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" to="/admin/posts">
<div class="admin-post-form__toolbar-left flex h-full min-w-0 flex-1 items-center gap-3">
<NuxtLink class="admin-post-form__toolbar-link inline-flex shrink-0 items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" to="/admin/posts">
<span class="admin-post-form__toolbar-back text-lg leading-none" aria-hidden="true">&lt;</span>
<span>Posts</span>
</NuxtLink>
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
{{ editorStatusLabel }}
</span>
<div class="admin-post-form__toolbar-status-row flex min-w-0 flex-1 items-center gap-2">
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
{{ editorStatusLabel }}
</span>
<div
v-if="autosaveNotice"
class="admin-post-form__toolbar-autosave-actions flex shrink-0 items-center gap-1.5"
>
<button
class="admin-post-form__toolbar-autosave-restore rounded px-2 py-1 text-xs font-semibold text-[#15171a] ring-1 ring-inset ring-[#d7dde2] transition-colors hover:bg-[#eff1f2]"
type="button"
@click="restoreAutosave"
>
복원
</button>
<button
class="admin-post-form__toolbar-autosave-discard rounded px-2 py-1 text-xs font-semibold text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
type="button"
title="이 기기에만 있는 자동 저장 초안을 삭제합니다"
@click="discardAutosave"
>
무시
</button>
</div>
</div>
</div>
<div class="admin-post-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
<button
@@ -675,8 +850,9 @@ defineExpose({
</button>
<button
class="admin-post-form__toolbar-submit rounded px-3 py-1.5 text-sm font-bold text-[#2bba3c] transition-colors hover:bg-[#eaf8ec] hover:text-[#159624] disabled:pointer-events-none disabled:text-[#8e9cac] disabled:opacity-60"
type="submit"
type="button"
:disabled="saving || !hasUnsavedPostChanges"
@click="openPublishModal"
>
{{ saving ? '저장 중' : submitLabel }}
</button>
@@ -728,23 +904,6 @@ defineExpose({
@compositionend="isTitleInputComposing = false"
>
<div
v-if="autosaveNotice"
class="admin-post-form__autosave-notice flex flex-wrap items-center justify-between gap-3 rounded border border-line bg-surface px-4 py-3 text-sm"
>
<p class="admin-post-form__autosave-message text-muted">
{{ formatAutosaveTime(autosaveNotice.savedAt) }} 저장된 작성 내용이 있습니다.
</p>
<div class="admin-post-form__autosave-actions flex gap-2">
<button class="admin-post-form__autosave-restore rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-black" type="button" @click="restoreAutosave">
복원
</button>
<button class="admin-post-form__autosave-discard rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-[#eff1f2]" type="button" @click="discardAutosave">
삭제
</button>
</div>
</div>
<div class="admin-post-form__field admin-post-form__content-editor text-sm">
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
</div>
@@ -998,5 +1157,177 @@ defineExpose({
@stay="stayOnUnsavedPage"
@leave="leaveUnsavedPage"
/>
<div
v-if="isPublishModalOpen"
class="admin-post-form__publish-modal fixed inset-0 z-[70] flex flex-col bg-white"
role="dialog"
aria-modal="true"
aria-labelledby="admin-post-form-publish-modal-title"
>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<header class="admin-post-form__publish-modal-header flex h-14 shrink-0 items-center justify-between px-6">
<h2 id="admin-post-form-publish-modal-title" class="text-[15px] font-semibold text-[#15171a]">
발행
</h2>
<div class="flex items-center gap-2">
<button class="rounded px-3 py-1.5 text-[13px] font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closePublishModal">
닫기
</button>
<button class="rounded border border-[#d7dde2] px-3 py-1.5 text-[13px] font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="previewPost">
미리보기
</button>
</div>
</header>
<div class="admin-post-form__publish-modal-body flex flex-1 flex-col items-center px-6 pb-16 pt-10 sm:pt-14">
<div class="w-full max-w-[640px]">
<div class="admin-post-form__publish-modal-hero mb-10 sm:mb-12">
<p class="text-[clamp(28px,7vw,46px)] font-black text-[#2bba3c]">
준비됐어요, 발행하세요.
</p>
<p class="text-[clamp(28px,7vw,46px)] font-black leading-[0.95] text-[#15171a]">
세상과 공유해 보세요.
</p>
</div>
<div class="admin-post-form__publish-settings w-full">
<div class="admin-post-form__publish-setting">
<button
class="admin-post-form__publish-setting-title flex w-full items-center gap-3 py-4 text-left text-[15px] font-semibold text-[#15171a] transition-colors"
type="button"
:aria-expanded="publishModalExpandedSection === 'status'"
aria-controls="admin-post-form-publish-status-panel"
data-test-setting="publish-type"
@click="togglePublishModalSection('status')"
>
<span class="flex size-6 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M23 1L6.21 13.013v9.408L12 17.355" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M1 9.105L23 1l-3.474 22L1 9.105z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</span>
<span class="min-w-0 flex-1">{{ publishStatusSummaryLabel }}</span>
<span class="flex size-2.5 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg
class="size-[22px] transition-transform duration-200 ease-out"
:class="{ 'rotate-180': publishModalExpandedSection === 'status' }"
fill="currentColor"
viewBox="0 0 26 24"
xmlns="http://www.w3.org/2000/svg"
>
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill-rule="evenodd" />
</svg>
</span>
</button>
<div
v-show="publishModalExpandedSection === 'status'"
id="admin-post-form-publish-status-panel"
class="admin-post-form__publish-setting-panel px-4 pb-4"
>
<div class="flex flex-wrap gap-2">
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishStatus === 'published' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishStatus('published')"
>
발행
</button>
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishStatus === 'draft' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishStatus('draft')"
>
초안
</button>
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishStatus === 'private' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishStatus('private')"
>
비공개
</button>
</div>
</div>
</div>
<div v-if="publishStatus === 'published'" class="admin-post-form__publish-setting admin-post-form__publish-setting--timing border-t border-[#e3e6e8]">
<button
class="admin-post-form__publish-setting-title flex w-full items-center gap-3 py-4 text-left text-[15px] font-semibold text-[#15171a] transition-colors"
type="button"
:aria-expanded="publishModalExpandedSection === 'timing'"
aria-controls="admin-post-form-publish-timing-panel"
data-test-setting="publish-at"
@click="togglePublishModalSection('timing')"
>
<span class="flex size-6 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 23c6.075 0 11-4.925 11-11S18.075 1 12 1 1 5.925 1 12s4.925 11 11 11z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M12 6v6h6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</span>
<span class="min-w-0 flex-1">{{ publishTimingSummaryLabel }}</span>
<span class="flex size-2.5 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg
class="size-[22px] transition-transform duration-200 ease-out"
:class="{ 'rotate-180': publishModalExpandedSection === 'timing' }"
fill="currentColor"
viewBox="0 0 26 24"
xmlns="http://www.w3.org/2000/svg"
>
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill-rule="evenodd" />
</svg>
</span>
</button>
<div
v-show="publishModalExpandedSection === 'timing'"
id="admin-post-form-publish-timing-panel"
class="admin-post-form__publish-setting-panel px-4 pb-4"
>
<div class="flex flex-wrap gap-2">
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishTiming === 'now' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishTiming('now')"
>
지금 바로
</button>
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishTiming === 'schedule' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishTiming('schedule')"
>
예약
</button>
</div>
<input
v-if="publishTiming === 'schedule'"
v-model="scheduledPublishAt"
class="admin-post-form__publish-schedule-input mt-3 h-[38px] w-full max-w-[320px] rounded border border-[#e3e6e8] bg-white px-3 py-2 text-[13px] text-[#15171a] outline-none focus:border-[#8e9cac]"
type="datetime-local"
>
</div>
</div>
</div>
<div class="admin-post-form__publish-modal-actions mt-10">
<button
class="rounded bg-[#15171a] px-5 py-2.5 text-[14px] font-semibold text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:bg-[#d7dce0] disabled:text-[#8e9cac]"
type="button"
:disabled="saving"
@click="submitFromPublishModal"
>
{{ saving ? '저장 중…' : '최종 확인하고 저장 →' }}
</button>
</div>
</div>
</div>
</div>
</div>
</form>
</template>