관리자 블록 에디터 입력 안정화
This commit is contained in:
@@ -12,6 +12,7 @@ const editorBlocks = ref([])
|
|||||||
const blockRefs = ref([])
|
const blockRefs = ref([])
|
||||||
const activeBlockId = ref('')
|
const activeBlockId = ref('')
|
||||||
const slashQuery = ref('')
|
const slashQuery = ref('')
|
||||||
|
const slashMenuDirection = ref('down')
|
||||||
const isApplyingExternalValue = ref(false)
|
const isApplyingExternalValue = ref(false)
|
||||||
let blockIdSeed = 0
|
let blockIdSeed = 0
|
||||||
|
|
||||||
@@ -211,6 +212,10 @@ const emitContent = () => {
|
|||||||
const setBlockRef = (element, index) => {
|
const setBlockRef = (element, index) => {
|
||||||
if (element) {
|
if (element) {
|
||||||
blockRefs.value[index] = element
|
blockRefs.value[index] = element
|
||||||
|
|
||||||
|
if (element.innerText !== editorBlocks.value[index]?.text) {
|
||||||
|
element.innerText = editorBlocks.value[index]?.text || ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +242,28 @@ const focusBlock = (index) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 슬래시 메뉴 표시 방향 갱신
|
||||||
|
* @param {number} index - 블록 인덱스
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const updateSlashMenuDirection = (index) => {
|
||||||
|
nextTick(() => {
|
||||||
|
const element = blockRefs.value[index]
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
slashMenuDirection.value = 'down'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const menuHeight = 280
|
||||||
|
slashMenuDirection.value = window.innerHeight - rect.bottom < menuHeight && rect.top > menuHeight
|
||||||
|
? 'up'
|
||||||
|
: 'down'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 블록 타입에 맞는 태그명 반환
|
* 블록 타입에 맞는 태그명 반환
|
||||||
* @param {Object} block - 에디터 블록
|
* @param {Object} block - 에디터 블록
|
||||||
@@ -291,6 +318,7 @@ const updateBlockText = (event, index) => {
|
|||||||
activeBlockId.value = block.id
|
activeBlockId.value = block.id
|
||||||
applyMarkdownShortcut(block, index)
|
applyMarkdownShortcut(block, index)
|
||||||
updateSlashQuery(block)
|
updateSlashQuery(block)
|
||||||
|
updateSlashMenuDirection(index)
|
||||||
emitContent()
|
emitContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +406,12 @@ const applyCommand = (command) => {
|
|||||||
block.type = command.type
|
block.type = command.type
|
||||||
block.level = command.level || null
|
block.level = command.level || null
|
||||||
block.text = ''
|
block.text = ''
|
||||||
|
const element = blockRefs.value[index]
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.innerText = ''
|
||||||
|
}
|
||||||
|
|
||||||
slashQuery.value = ''
|
slashQuery.value = ''
|
||||||
|
|
||||||
if (command.type === 'divider') {
|
if (command.type === 'divider') {
|
||||||
@@ -406,6 +440,10 @@ const handleEnter = (event, index) => {
|
|||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (!currentBlock.text.trim() && currentBlock.type === 'paragraph') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (currentBlock.type === 'divider') {
|
if (currentBlock.type === 'divider') {
|
||||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
||||||
emitContent()
|
emitContent()
|
||||||
@@ -452,10 +490,23 @@ const handleBackspace = (event, index) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const activateBlock = (block) => {
|
const activateBlock = (block) => {
|
||||||
|
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
|
||||||
activeBlockId.value = block.id
|
activeBlockId.value = block.id
|
||||||
updateSlashQuery(block)
|
updateSlashQuery(block)
|
||||||
|
updateSlashMenuDirection(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블록 placeholder 표시 여부 반환
|
||||||
|
* @param {Object} block - 에디터 블록
|
||||||
|
* @param {number} index - 블록 인덱스
|
||||||
|
* @returns {boolean} placeholder 표시 여부
|
||||||
|
*/
|
||||||
|
const shouldShowPlaceholder = (block, index) => !block.text && (
|
||||||
|
activeBlockId.value === block.id ||
|
||||||
|
(index === 0 && editorBlocks.value.length === 1)
|
||||||
|
)
|
||||||
|
|
||||||
watch(() => props.modelValue, (value) => {
|
watch(() => props.modelValue, (value) => {
|
||||||
if (isApplyingExternalValue.value) {
|
if (isApplyingExternalValue.value) {
|
||||||
return
|
return
|
||||||
@@ -479,7 +530,7 @@ watch(editorBlocks, () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-block-editor rounded border border-line bg-white px-5 py-5">
|
<div class="admin-block-editor min-h-[32rem] bg-transparent py-4">
|
||||||
<div class="admin-block-editor__surface post-prose grid gap-1">
|
<div class="admin-block-editor__surface post-prose grid gap-1">
|
||||||
<div
|
<div
|
||||||
v-for="(block, index) in editorBlocks"
|
v-for="(block, index) in editorBlocks"
|
||||||
@@ -495,13 +546,12 @@ watch(editorBlocks, () => {
|
|||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
spellcheck="true"
|
spellcheck="true"
|
||||||
:data-placeholder="index === 0 ? '본문을 입력하거나 / 를 눌러 블록을 선택하세요' : '/ 를 눌러 블록 선택'"
|
:data-placeholder="index === 0 ? '본문을 입력하거나 / 를 눌러 블록을 선택하세요' : '/ 를 눌러 블록 선택'"
|
||||||
|
:data-show-placeholder="shouldShowPlaceholder(block, index)"
|
||||||
@focus="activateBlock(block)"
|
@focus="activateBlock(block)"
|
||||||
@input="updateBlockText($event, index)"
|
@input="updateBlockText($event, index)"
|
||||||
@keydown.enter="handleEnter($event, index)"
|
@keydown.enter="handleEnter($event, index)"
|
||||||
@keydown.backspace="handleBackspace($event, index)"
|
@keydown.backspace="handleBackspace($event, index)"
|
||||||
>
|
/>
|
||||||
{{ block.text }}
|
|
||||||
</component>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="block.type === 'divider'"
|
v-if="block.type === 'divider'"
|
||||||
@@ -515,7 +565,8 @@ watch(editorBlocks, () => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="visibleCommands.length && activeBlockId === block.id"
|
v-if="visibleCommands.length && activeBlockId === block.id"
|
||||||
class="admin-block-editor__slash-menu absolute left-0 top-full z-20 mt-2 w-72 overflow-hidden rounded border border-line bg-white shadow-lg"
|
class="admin-block-editor__slash-menu absolute left-0 z-20 w-72 overflow-hidden rounded border border-line bg-white shadow-lg"
|
||||||
|
:class="slashMenuDirection === 'up' ? 'bottom-full mb-2' : 'top-full mt-2'"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="command in visibleCommands"
|
v-for="command in visibleCommands"
|
||||||
@@ -534,7 +585,7 @@ watch(editorBlocks, () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-block-editor__block:empty::before {
|
.admin-block-editor__block:empty[data-show-placeholder="true"]::before {
|
||||||
color: var(--site-soft);
|
color: var(--site-soft);
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,22 +92,15 @@ const submitPost = () => {
|
|||||||
<form class="admin-post-form grid gap-6" @submit.prevent="submitPost">
|
<form class="admin-post-form grid gap-6" @submit.prevent="submitPost">
|
||||||
<div class="admin-post-form__main grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
<div class="admin-post-form__main grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||||
<section class="admin-post-form__content grid gap-4">
|
<section class="admin-post-form__content grid gap-4">
|
||||||
<label class="admin-post-form__field grid gap-2 text-sm">
|
<input
|
||||||
<span class="admin-post-form__label font-medium">제목</span>
|
v-model="form.title"
|
||||||
<input
|
class="admin-post-form__title-input border-0 bg-transparent px-0 py-2 text-5xl font-semibold leading-tight outline-none placeholder:text-soft"
|
||||||
v-model="form.title"
|
type="text"
|
||||||
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
|
placeholder="제목"
|
||||||
type="text"
|
required
|
||||||
required
|
>
|
||||||
>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="admin-post-form__field grid gap-2 text-sm">
|
<div class="admin-post-form__field grid gap-2 text-sm">
|
||||||
<div class="admin-post-form__editor-header flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<span class="admin-post-form__label font-medium">본문</span>
|
|
||||||
<span class="admin-post-form__editor-note text-xs text-muted">/ 명령과 마크다운 단축 입력 지원</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AdminBlockEditor v-model="form.content" />
|
<AdminBlockEditor v-model="form.content" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.10
|
||||||
|
|
||||||
|
### 블록 에디터 입력 안정화 결정
|
||||||
|
|
||||||
|
관리자 블록 에디터는 `contenteditable` 요소 안의 텍스트를 Vue 템플릿 보간으로 직접 렌더링하지 않고 DOM 참조를 통해 동기화한다. Vue의 렌더 패치와 브라우저의 조합 입력이 동시에 같은 텍스트 노드를 수정하면 `/` 입력이나 한글 필터 입력이 중복되는 문제가 생기기 때문이다.
|
||||||
|
|
||||||
|
슬래시 메뉴는 고정적으로 아래에 열지 않고 활성 블록 위치와 화면 높이를 기준으로 위 또는 아래에 표시한다. 글 하단에서 블록을 추가할 때 메뉴가 화면 밖으로 밀리는 문제를 줄이기 위해서다.
|
||||||
|
|
||||||
|
제목은 별도 폼 영역이 아니라 에디터 상단의 큰 제목 입력으로 유지한다. Ghost 작성 화면처럼 제목과 본문이 하나의 흐름으로 보이는 편이 글쓰기 집중도와 결과 화면 예측에 더 가깝기 때문이다.
|
||||||
|
|
||||||
## 2026-05-01 v0.0.9
|
## 2026-05-01 v0.0.9
|
||||||
|
|
||||||
### 관리자 블록형 글쓰기 방식 결정
|
### 관리자 블록형 글쓰기 방식 결정
|
||||||
|
|||||||
@@ -204,8 +204,11 @@ components/content/
|
|||||||
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
|
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
|
||||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
|
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
|
||||||
- `/` 입력 시 블록 선택 메뉴를 표시한다.
|
- `/` 입력 시 블록 선택 메뉴를 표시한다.
|
||||||
|
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
||||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 인용, 목록, 코드, 구분선을 제공한다.
|
- 블록 메뉴는 문단, 제목 2, 제목 3, 인용, 목록, 코드, 구분선을 제공한다.
|
||||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
|
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
|
||||||
|
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
||||||
|
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||||
|
|
||||||
### 관리자 인증
|
### 관리자 인증
|
||||||
@@ -285,6 +288,6 @@ APP_PORT=43118
|
|||||||
|
|
||||||
## 버전 관리
|
## 버전 관리
|
||||||
|
|
||||||
- 현재 버전: v0.0.9
|
- 현재 버전: v0.0.10
|
||||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.10
|
||||||
|
|
||||||
|
- 관리자 블록 에디터의 `contenteditable` 입력 중복 문제 수정.
|
||||||
|
- 관리자 블록 에디터의 `/` 명령 메뉴가 화면 하단에서 위로 열리도록 수정.
|
||||||
|
- 빈 블록 placeholder가 활성 블록에만 표시되도록 수정.
|
||||||
|
- 관리자 글 제목 입력을 본문 흐름 안의 큰 제목 필드로 변경.
|
||||||
|
- 패키지 버전을 0.0.10으로 갱신.
|
||||||
|
|
||||||
## v0.0.9
|
## v0.0.9
|
||||||
|
|
||||||
- 관리자 글 작성/수정 폼을 textarea 방식에서 블록형 에디터 방식으로 변경.
|
- 관리자 글 작성/수정 폼을 textarea 방식에서 블록형 에디터 방식으로 변경.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.9",
|
"version": "0.0.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.9",
|
"version": "0.0.10",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.9",
|
"version": "0.0.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user