운영 시작 버전 v1.0.0 정리
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user