From 3b331b8fe637bbd763705ef82cf2eea49d3ac5aa Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 14 May 2026 10:49:25 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20v1.0.0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminBlockEditor.vue | 518 +++++++++++++++++- components/admin/AdminPostForm.vue | 379 ++++++++++++- .../content/ContentMarkdownRenderer.vue | 58 +- components/content/ProseCallout.vue | 87 ++- docs/changelog.md | 9 + docs/convention.md | 7 + docs/deploy.md | 9 +- docs/history.md | 40 ++ docs/map.md | 12 +- docs/spec.md | 11 +- docs/todo.md | 7 +- docs/update.md | 50 ++ package-lock.json | 491 ++++++++++++++++- package.json | 5 +- pages/post/[slug].vue | 2 +- scripts/check-js-syntax.js | 72 +++ server/repositories/postgres-client.js | 10 + server/utils/member-auth.js | 6 +- 18 files changed, 1679 insertions(+), 94 deletions(-) create mode 100644 scripts/check-js-syntax.js diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue index 204d00f..7d4f6db 100644 --- a/components/admin/AdminBlockEditor.vue +++ b/components/admin/AdminBlockEditor.vue @@ -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) })