글쓰기 에디터 문단 처리와 설정 패널 액션 보정
This commit is contained in:
@@ -508,6 +508,37 @@ const updateSlashMenuDirection = (index) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 블록 DOM의 현재 텍스트 반환
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @returns {string} 현재 텍스트
|
||||
*/
|
||||
const getTextBlockDomText = (index) => {
|
||||
const element = blockRefs.value[index]
|
||||
|
||||
return element?.innerText.replace(/\n$/, '') || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 블록 DOM 값을 상태에 즉시 반영
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @returns {Object|undefined} 갱신한 블록
|
||||
*/
|
||||
const syncTextBlockFromDom = (index) => {
|
||||
const block = editorBlocks.value[index]
|
||||
|
||||
if (!block || !isTextBlock(block)) {
|
||||
return block
|
||||
}
|
||||
|
||||
block.text = getTextBlockDomText(index)
|
||||
activeBlockId.value = block.id
|
||||
updateSlashQuery(block)
|
||||
updateSlashMenuDirection(index)
|
||||
|
||||
return block
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 타입에 맞는 태그명 반환
|
||||
* @param {Object} block - 에디터 블록
|
||||
@@ -537,15 +568,15 @@ const getBlockTag = (block) => {
|
||||
const getBlockClass = (block) => [
|
||||
'admin-block-editor__block outline-none transition-colors',
|
||||
{
|
||||
'admin-block-editor__paragraph min-h-8 text-[17px] leading-8': block.type === 'paragraph',
|
||||
'admin-block-editor__heading mt-8 min-h-10 font-semibold leading-tight': block.type === 'heading',
|
||||
'admin-block-editor__paragraph min-h-8 whitespace-pre-wrap text-[17px] leading-8': block.type === 'paragraph',
|
||||
'admin-block-editor__heading min-h-10 font-semibold leading-tight': block.type === 'heading',
|
||||
'admin-block-editor__heading--h1 text-5xl': block.type === 'heading' && block.level === 1,
|
||||
'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 my-5 border-l-4 border-ink bg-surface px-5 py-3 text-xl font-medium leading-8': block.type === 'quote',
|
||||
'admin-block-editor__callout my-5 min-h-14 rounded border border-line bg-surface px-5 py-4 text-[16px] leading-7': block.type === 'callout',
|
||||
'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 my-5 min-h-14 whitespace-pre-wrap rounded bg-[#15171a] px-4 py-3 font-mono text-sm leading-6 text-white': block.type === 'code'
|
||||
'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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -574,7 +605,7 @@ const getImageWidthClass = (width) => {
|
||||
*/
|
||||
const updateBlockText = (event, index) => {
|
||||
const block = editorBlocks.value[index]
|
||||
const text = event.target.innerText.replace(/\n$/, '')
|
||||
const text = getTextBlockDomText(index)
|
||||
|
||||
block.text = text
|
||||
activeBlockId.value = block.id
|
||||
@@ -608,7 +639,14 @@ const finishTextComposition = (event, index) => {
|
||||
|
||||
nextTick(() => {
|
||||
window.setTimeout(() => {
|
||||
updateBlockText(event, index)
|
||||
const block = syncTextBlockFromDom(index)
|
||||
|
||||
if (!block) {
|
||||
return
|
||||
}
|
||||
|
||||
applyMarkdownShortcut(block, index)
|
||||
emitContent()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
@@ -938,6 +976,8 @@ const removeGalleryImage = (block, imageIndex) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const highlightNextCommand = (event) => {
|
||||
syncTextBlockFromDom(activeBlockIndex.value)
|
||||
|
||||
if (!visibleCommands.value.length) {
|
||||
return
|
||||
}
|
||||
@@ -952,6 +992,8 @@ const highlightNextCommand = (event) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const highlightPreviousCommand = (event) => {
|
||||
syncTextBlockFromDom(activeBlockIndex.value)
|
||||
|
||||
if (!visibleCommands.value.length) {
|
||||
return
|
||||
}
|
||||
@@ -969,16 +1011,25 @@ const highlightPreviousCommand = (event) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleEnter = (event, index) => {
|
||||
const currentBlock = editorBlocks.value[index]
|
||||
const currentBlock = syncTextBlockFromDom(index)
|
||||
|
||||
if (isComposingText.value || event.isComposing || event.keyCode === 229) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (visibleCommands.value.length && currentBlock.text.startsWith('/')) {
|
||||
if (event.shiftKey && isTextBlock(currentBlock)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentBlock.text.startsWith('/')) {
|
||||
event.preventDefault()
|
||||
applyCommand(highlightedCommand.value || visibleCommands.value[0])
|
||||
const command = highlightedCommand.value || visibleCommands.value[0]
|
||||
|
||||
if (command) {
|
||||
applyCommand(command)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1198,15 +1249,17 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-block-editor min-h-[32rem] bg-transparent py-4 text-ink">
|
||||
<div class="admin-block-editor__surface post-prose grid gap-1">
|
||||
<div class="admin-block-editor bg-transparent py-4 text-ink">
|
||||
<div class="admin-block-editor__surface post-prose">
|
||||
<div
|
||||
v-for="(block, index) in editorBlocks"
|
||||
:key="block.id"
|
||||
class="admin-block-editor__row group/block relative rounded transition-colors"
|
||||
:class="{
|
||||
'admin-block-editor__row--selected bg-[#eff1f2] ring-1 ring-[#d8dde1]': selectedBlockId === block.id,
|
||||
'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id
|
||||
'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id,
|
||||
'admin-block-editor__row--text': isTextBlock(block),
|
||||
'admin-block-editor__row--structure': !isTextBlock(block)
|
||||
}"
|
||||
:data-editor-block-id="block.id"
|
||||
@dragover.prevent
|
||||
@@ -1226,11 +1279,11 @@ defineExpose({
|
||||
<span aria-hidden="true">⋮⋮</span>
|
||||
</button>
|
||||
|
||||
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider my-6 border-line">
|
||||
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider border-line">
|
||||
|
||||
<figure
|
||||
v-else-if="block.type === 'image'"
|
||||
class="admin-block-editor__media group my-6"
|
||||
class="admin-block-editor__media group"
|
||||
:class="getImageWidthClass(block.width)"
|
||||
tabindex="0"
|
||||
@focus="activateBlock(block)"
|
||||
@@ -1281,7 +1334,7 @@ defineExpose({
|
||||
|
||||
<figure
|
||||
v-else-if="block.type === 'gallery'"
|
||||
class="admin-block-editor__gallery group my-6"
|
||||
class="admin-block-editor__gallery group"
|
||||
tabindex="0"
|
||||
@focus="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@@ -1317,7 +1370,7 @@ defineExpose({
|
||||
|
||||
<section
|
||||
v-else-if="block.type === 'toggle'"
|
||||
class="admin-block-editor__toggle my-6 rounded border border-line bg-paper p-5"
|
||||
class="admin-block-editor__toggle rounded border border-line bg-paper p-5"
|
||||
@focusin="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@@ -1341,7 +1394,7 @@ defineExpose({
|
||||
|
||||
<section
|
||||
v-else-if="block.type === 'embed'"
|
||||
class="admin-block-editor__embed my-6 rounded border border-dashed border-line bg-surface p-5"
|
||||
class="admin-block-editor__embed rounded border border-dashed border-line bg-surface p-5"
|
||||
@focusin="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@@ -1460,6 +1513,18 @@ defineExpose({
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.admin-block-editor__row + .admin-block-editor__row--text {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.admin-block-editor__row + .admin-block-editor__row--structure {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.admin-block-editor__row--structure + .admin-block-editor__row--structure {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.admin-block-editor__code {
|
||||
background: #15171a;
|
||||
color: #f8fafc;
|
||||
|
||||
@@ -11,10 +11,26 @@ const props = defineProps({
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canViewPost: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
publicUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
deleting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit', 'preview'])
|
||||
const emit = defineEmits(['submit', 'preview', 'delete'])
|
||||
|
||||
const autosaveStoragePrefix = 'SORI_ADMIN_POST_AUTOSAVE'
|
||||
const slugTouched = ref(Boolean(props.initialPost.slug))
|
||||
@@ -88,6 +104,8 @@ const form = reactive({
|
||||
})
|
||||
|
||||
const autosaveKey = computed(() => `${autosaveStoragePrefix}:${props.initialPost.id || 'new'}`)
|
||||
const postUrlLabel = computed(() => form.slug || toSlug(form.title) || '')
|
||||
const postUrlHint = computed(() => props.publicUrl || (postUrlLabel.value ? `/post/${postUrlLabel.value}/` : '/post/'))
|
||||
|
||||
/**
|
||||
* 문자열을 URL 슬러그로 변환
|
||||
@@ -439,6 +457,14 @@ const previewPost = () => {
|
||||
emit('preview', createPostPayload())
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 삭제 요청
|
||||
* @returns {void}
|
||||
*/
|
||||
const deletePost = () => {
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 패널 표시 상태 전환
|
||||
* @returns {void}
|
||||
@@ -597,7 +623,36 @@ defineExpose({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__settings-body grid content-start gap-4 overflow-y-auto px-6 pb-8 pt-8">
|
||||
<div class="admin-post-form__settings-body grid flex-1 content-start gap-4 overflow-y-auto px-6 pb-8 pt-8">
|
||||
<div class="admin-post-form__field grid gap-1 text-sm">
|
||||
<div class="admin-post-form__post-url-header flex h-[22px] items-center justify-between">
|
||||
<span class="admin-post-form__label font-bold text-[#15171a]">Post URL</span>
|
||||
<NuxtLink
|
||||
v-if="canViewPost"
|
||||
class="admin-post-form__view-post inline-flex items-center gap-1 rounded px-3 py-1.5 text-xs text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
|
||||
:to="publicUrl"
|
||||
target="_blank"
|
||||
>
|
||||
<span>View Post</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<label class="admin-post-form__post-url-input flex h-[38px] items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
|
||||
<span class="admin-post-form__post-url-icon text-sm text-[#394047]" aria-hidden="true">⌘</span>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
class="admin-post-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none"
|
||||
type="text"
|
||||
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||
required
|
||||
@input="touchSlug"
|
||||
>
|
||||
</label>
|
||||
<p class="admin-post-form__post-url-hint text-xs text-[#7c8b9a]">
|
||||
{{ postUrlHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2 text-sm">
|
||||
<span class="admin-post-form__label font-medium">상태</span>
|
||||
<select v-model="form.status" class="admin-post-form__select h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
|
||||
@@ -621,18 +676,6 @@ defineExpose({
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2 text-sm">
|
||||
<span class="admin-post-form__label font-medium">슬러그</span>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
class="admin-post-form__input h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
type="text"
|
||||
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||
required
|
||||
@input="touchSlug"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2 text-sm">
|
||||
<span class="admin-post-form__label font-medium">요약</span>
|
||||
<textarea
|
||||
@@ -743,6 +786,17 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showDelete" class="admin-post-form__settings-bottom shrink-0 border-t border-[#e3e6e8] px-8 py-6">
|
||||
<button
|
||||
class="admin-post-form__delete-post flex h-10 w-full items-center justify-center gap-2 rounded border border-[#d21a26] text-sm font-bold text-[#d21a26] transition-colors hover:bg-red-50 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deleting"
|
||||
@click="deletePost"
|
||||
>
|
||||
<span aria-hidden="true">⌫</span>
|
||||
<span>{{ deleting ? '삭제 중' : 'Delete post' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user