v1.4.7: 라이브 인라인 서식·인용 배경·소스→라이브 스크롤 보정
- 라이브 모드 blur 시 인라인 마크다운(**·*)이 사라지던 문제 수정 - 인용 블록에 > [!bg=색상] 옵션으로 콜아웃과 동일한 배경 프리셋 지정 - 소스 모드에서 라이브 전환 시 현재 커서 줄을 화면 중앙에 가깝게 스크롤
This commit is contained in:
@@ -639,7 +639,7 @@ watch(activeMode, (mode) => {
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
previewRendererRef.value?.focusEditableAtLine(previewFocus.line, 0, 'auto', previewFocus.offset)
|
||||
previewRendererRef.value?.focusEditableAtLine(previewFocus.line, 0, 'auto', previewFocus.offset, 'center')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../../lib/markdown-live-edit.js'
|
||||
import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
|
||||
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
|
||||
import { parseCalloutOptions } from '../../lib/markdown-callout.js'
|
||||
import { CALLOUT_BACKGROUND_OPTIONS, parseCalloutOptions } from '../../lib/markdown-callout.js'
|
||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
import ContentMarkdownCalloutEditor from './ContentMarkdownCalloutEditor.vue'
|
||||
@@ -140,6 +140,7 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
|
||||
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
|
||||
calloutEmoji: options.calloutEmoji || '💡',
|
||||
calloutBackground: options.calloutBackground || 'blue',
|
||||
quoteBackground: options.quoteBackground || 'pink',
|
||||
codeLanguage: options.codeLanguage || '',
|
||||
codeShowLineNumbers: options.codeShowLineNumbers !== false
|
||||
})
|
||||
@@ -161,6 +162,43 @@ const isQuoteMarkerLine = (line) => {
|
||||
return trimmed === '>' || /^>\s/.test(trimmed)
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 마커를 제거한 본문을 반환한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {string} 인용 본문
|
||||
*/
|
||||
const getQuoteLineBody = (line) => String(line ?? '').trim().replace(/^>\s?/, '')
|
||||
|
||||
/**
|
||||
* 인용 옵션 줄을 파싱한다.
|
||||
* @param {string} value - 인용 본문 줄
|
||||
* @returns {{ quoteBackground: string }|null} 인용 옵션
|
||||
*/
|
||||
const parseQuoteOptions = (value) => {
|
||||
const raw = String(value ?? '').trim()
|
||||
const bracketMatch = raw.match(/^\[!(.+)\]$/)
|
||||
const braceMatch = raw.match(/^\{(.+)\}$/)
|
||||
const optionSource = bracketMatch?.[1] || braceMatch?.[1] || ''
|
||||
|
||||
if (!optionSource) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tokens = optionSource.trim().split(/\s+/)
|
||||
let quoteBackground = ''
|
||||
|
||||
tokens.forEach((token) => {
|
||||
const [key, rawOptionValue] = token.split('=')
|
||||
const optionValue = String(rawOptionValue || '').trim()
|
||||
|
||||
if (key?.toLowerCase() === 'bg' && CALLOUT_BACKGROUND_OPTIONS.includes(optionValue)) {
|
||||
quoteBackground = optionValue
|
||||
}
|
||||
})
|
||||
|
||||
return quoteBackground ? { quoteBackground } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 불릿 목록 마커 줄인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
@@ -624,15 +662,24 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
|
||||
if (isQuoteMarkerLine(line)) {
|
||||
const startLine = index
|
||||
const quoteLines = []
|
||||
const rawQuoteLines = []
|
||||
|
||||
while (index < lines.length && isQuoteMarkerLine(lines[index])) {
|
||||
quoteLines.push(lines[index].trim().replace(/^>\s?/, ''))
|
||||
rawQuoteLines.push(getQuoteLineBody(lines[index]))
|
||||
index += 1
|
||||
}
|
||||
|
||||
const quoteOptions = parseQuoteOptions(rawQuoteLines[0])
|
||||
const quoteLines = quoteOptions ? rawQuoteLines.slice(1) : rawQuoteLines
|
||||
const contentStartLine = startLine + (quoteOptions ? 1 : 0)
|
||||
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('quote', quoteLines.join('\n'), null, `block-${blocks.length}`),
|
||||
createBlock('quote', (quoteLines.length ? quoteLines : ['']).join('\n'), null, `block-${blocks.length}`, {
|
||||
...(quoteOptions || {}),
|
||||
meta: {
|
||||
quoteContentStartLine: contentStartLine
|
||||
}
|
||||
}),
|
||||
startLine,
|
||||
index - 1
|
||||
))
|
||||
@@ -785,9 +832,10 @@ watch(() => props.content, () => {
|
||||
* @param {number} [attempt=0] - DOM 탐색 재시도 횟수
|
||||
* @param {'start'|'end'|'auto'} [cursorPosition='auto'] - 커서 위치
|
||||
* @param {number|null} [caretOffset=null] - 텍스트 오프셋
|
||||
* @param {'nearest'|'center'} [scrollBlock='nearest'] - 스크롤 정렬 위치
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null) => {
|
||||
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null, scrollBlock = 'nearest') => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
@@ -803,7 +851,7 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
|
||||
if (!element) {
|
||||
if (attempt < 8) {
|
||||
requestAnimationFrame(() => {
|
||||
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset)
|
||||
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset, scrollBlock)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -823,28 +871,31 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
|
||||
element.focus({ preventScroll: true })
|
||||
|
||||
if (element.getAttribute('contenteditable') !== 'true') {
|
||||
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof caretOffset === 'number' && caretOffset >= 0) {
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
if (cursorPosition === 'start' || (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))) {
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
if (cursorPosition === 'end') {
|
||||
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), text.length)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
|
||||
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -1053,23 +1104,38 @@ const createEmptyListMarkerLine = (block, itemIndex) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 블록을 줄 단위로 분리한다.
|
||||
* 인용 블록의 본문 시작 줄을 반환한다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @returns {string[]} 줄 목록
|
||||
* @returns {number} 본문 시작 줄
|
||||
*/
|
||||
const getQuoteLines = (block) => {
|
||||
const lineCount = (block.meta.endLine ?? block.meta.startLine) - block.meta.startLine + 1
|
||||
const getQuoteContentStartLine = (block) => {
|
||||
if (typeof block.meta?.quoteContentStartLine === 'number') {
|
||||
return block.meta.quoteContentStartLine
|
||||
}
|
||||
|
||||
return block.meta.startLine
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 블록을 편집 가능한 줄 단위로 분리한다.
|
||||
* @param {Object} block - 인용 블록
|
||||
* @returns {Array<{ text: string, sourceLine: number, sourceIndex: number }>} 줄 목록
|
||||
*/
|
||||
const getQuoteLineEntries = (block) => {
|
||||
const contentStartLine = getQuoteContentStartLine(block)
|
||||
const endLine = block.meta.endLine ?? block.meta.startLine
|
||||
const lineCount = Math.max(1, endLine - contentStartLine + 1)
|
||||
const fromText = String(block.text ?? '').split('\n')
|
||||
|
||||
while (fromText.length < lineCount) {
|
||||
fromText.push('')
|
||||
}
|
||||
|
||||
if (!fromText.length) {
|
||||
return ['']
|
||||
}
|
||||
|
||||
return fromText.slice(0, lineCount)
|
||||
return fromText.slice(0, lineCount).map((text, index) => ({
|
||||
text,
|
||||
sourceLine: contentStartLine + index,
|
||||
sourceIndex: contentStartLine + index - block.meta.startLine
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2269,24 +2335,29 @@ onBeforeUnmount(() => {
|
||||
<ProseBlockquote
|
||||
v-else-if="block.type === 'quote' && interactive && block.variant !== 'alt'"
|
||||
:variant="block.variant || 'default'"
|
||||
:background="block.quoteBackground"
|
||||
>
|
||||
<ContentMarkdownEditableInline
|
||||
v-for="(quoteLine, quoteLineIndex) in getQuoteLines(block)"
|
||||
:key="`quote-line-${block.meta.startLine + quoteLineIndex}`"
|
||||
v-for="quoteLine in getQuoteLineEntries(block)"
|
||||
:key="`quote-line-${quoteLine.sourceLine}`"
|
||||
block-class="content-markdown-renderer__quote-line"
|
||||
:model-value="quoteLine"
|
||||
:model-value="quoteLine.text"
|
||||
enter-mode="insert-below"
|
||||
allow-raw-toggle
|
||||
:raw-line="getMarkdownLine(block.meta.startLine + quoteLineIndex)"
|
||||
:source-line="block.meta.startLine + quoteLineIndex"
|
||||
@commit="onQuoteLineInlineCommit(block, quoteLineIndex, $event)"
|
||||
@insert-below="onQuoteLineInsertBelow(block, quoteLineIndex, $event)"
|
||||
:raw-line="getMarkdownLine(quoteLine.sourceLine)"
|
||||
:source-line="quoteLine.sourceLine"
|
||||
@commit="onQuoteLineInlineCommit(block, quoteLine.sourceIndex, $event)"
|
||||
@insert-below="onQuoteLineInsertBelow(block, quoteLine.sourceIndex, $event)"
|
||||
@delete-line="onDeleteLine"
|
||||
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + quoteLineIndex, $event)"
|
||||
@merge-with-previous="onMergeWithPreviousLine(quoteLine.sourceLine, $event)"
|
||||
@raw-mode="onInlineRawMode"
|
||||
/>
|
||||
</ProseBlockquote>
|
||||
<ProseBlockquote v-else-if="block.type === 'quote'" :variant="block.variant || 'default'">
|
||||
<ProseBlockquote
|
||||
v-else-if="block.type === 'quote'"
|
||||
:variant="block.variant || 'default'"
|
||||
:background="block.quoteBackground"
|
||||
>
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
background: {
|
||||
type: String,
|
||||
default: 'pink'
|
||||
}
|
||||
})
|
||||
|
||||
const backgroundClass = computed(() => {
|
||||
if (props.background === 'gray') {
|
||||
return 'prose-blockquote--gray'
|
||||
}
|
||||
|
||||
if (props.background === 'blue') {
|
||||
return 'prose-blockquote--blue'
|
||||
}
|
||||
|
||||
if (props.background === 'green') {
|
||||
return 'prose-blockquote--green'
|
||||
}
|
||||
|
||||
if (props.background === 'yellow') {
|
||||
return 'prose-blockquote--yellow'
|
||||
}
|
||||
|
||||
if (props.background === 'red') {
|
||||
return 'prose-blockquote--red'
|
||||
}
|
||||
|
||||
if (props.background === 'purple') {
|
||||
return 'prose-blockquote--purple'
|
||||
}
|
||||
|
||||
return 'prose-blockquote--pink'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -12,10 +44,47 @@ defineProps({
|
||||
class="prose-blockquote mb-2.5 text-[15px] leading-8"
|
||||
:class="variant === 'alt'
|
||||
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic text-[var(--site-text)]'
|
||||
: 'rounded-[10px] border-l-2 border-[#FF1A75] bg-[color-mix(in_srgb,#FF1A75_10%,#ffffff)] px-5 py-4 font-medium text-[#15171a]'"
|
||||
: ['rounded-[10px] border-l-2 px-5 py-4 font-medium text-[#15171a]', backgroundClass]"
|
||||
>
|
||||
<span class="whitespace-pre-line">
|
||||
<slot />
|
||||
</span>
|
||||
</blockquote>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prose-blockquote--gray {
|
||||
border-color: rgba(100, 116, 139, 0.72);
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
}
|
||||
|
||||
.prose-blockquote--blue {
|
||||
border-color: rgba(59, 130, 246, 0.78);
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
|
||||
.prose-blockquote--green {
|
||||
border-color: rgba(34, 197, 94, 0.78);
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
}
|
||||
|
||||
.prose-blockquote--yellow {
|
||||
border-color: rgba(245, 158, 11, 0.82);
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
}
|
||||
|
||||
.prose-blockquote--red {
|
||||
border-color: rgba(239, 68, 68, 0.78);
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
|
||||
.prose-blockquote--purple {
|
||||
border-color: rgba(168, 85, 247, 0.78);
|
||||
background: rgba(168, 85, 247, 0.14);
|
||||
}
|
||||
|
||||
.prose-blockquote--pink {
|
||||
border-color: #ff1a75;
|
||||
background: color-mix(in srgb, #ff1a75 10%, #ffffff);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.4.7
|
||||
|
||||
- 글쓰기 라이브 모드에서 문단 이동 시 인라인 마크다운 서식이 사라지던 문제를 수정했다.
|
||||
- 인용 블록에서 `> [!bg=yellow]` 형식으로 배경색을 지정할 수 있다.
|
||||
- 소스 모드에서 라이브 모드로 전환할 때 현재 커서 줄 주변으로 스크롤되도록 보정했다.
|
||||
|
||||
## v1.4.6
|
||||
|
||||
- 관리자 사이트 설정에서 로고와 메인 커버 이미지가 저장 버튼을 통해 반영되도록 정리했다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-22 v1.4.7 — 라이브 모드 인라인 마크다운 직렬화
|
||||
|
||||
라이브 편집 영역은 화면에 `<strong>`·`<em>` 등으로 표시되지만, blur 시 저장 경로가 `textContent`만 읽으면 `**`·`*` 마커가 빠진다. 문단 이동 시 이전 블록이 blur·commit 되므로 방향키만으로도 서식이 사라진 것처럼 보였다. `readEditableTextFromElement`가 DOM 인라인 노드를 마크다운으로 다시 직렬화하도록 수정한다.
|
||||
|
||||
인용 블록은 표준 `>` 문법을 유지하되, 첫 줄에 `> [!bg=yellow]`처럼 옵션 줄을 둘 수 있게 한다. 새 fenced block을 추가하지 않으면 기존 마크다운과 호환되고, 콜아웃에서 이미 쓰는 배경 프리셋을 공유할 수 있다. 소스에서 라이브로 전환할 때는 커서 줄에 포커스만 두고 스크롤하지 않는 경로가 있어 화면 위치가 어긋났으므로, 해당 전환에서는 대상 줄을 중앙에 가깝게 스크롤한다.
|
||||
|
||||
## 2026-05-22 v1.4.6 — 사이트 설정 이미지 저장 흐름 통일
|
||||
|
||||
관리자 사이트 설정은 섹션별 `편집` 후 `저장`으로 반영되는 컨셉이므로, 로고 업로드도 DB를 즉시 갱신하지 않고 업로드된 파일 URL만 폼에 반영한 뒤 기타 설정 저장 시 함께 저장하도록 정리한다. 홈 커버는 공개 라이트·다크 테마에 따라 이미지 톤이 크게 달라질 수 있어 기존 라이트 이미지를 기본값으로 유지하면서 다크 전용 URL을 별도 컬럼으로 추가한다. 다크 이미지가 없으면 기존 이미지로 fallback해 기존 설정과 공개 화면 동작을 유지한다.
|
||||
|
||||
@@ -98,11 +98,11 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
|
||||
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
|
||||
| components/content/ProseList.vue | 목록 |
|
||||
| components/content/ProseBlockquote.vue | 인용구, 다크모드 기본 인용 텍스트 가독성 보정 |
|
||||
| components/content/ProseBlockquote.vue | 인용구, 콜아웃과 같은 배경 프리셋, 다크모드 기본 인용 텍스트 가독성 보정 |
|
||||
| components/content/ProseCodeBlock.vue | 코드 블록 공통 셸(다크 배경, 줄번호 gutter, 공개 복사 버튼) |
|
||||
| components/content/ContentMarkdownCodeBlockEditor.vue | 라이브 모드 코드 블록 인라인 편집(Language·줄번호 토글) |
|
||||
| components/content/ProseButton.vue | 버튼 |
|
||||
|
||||
@@ -543,7 +543,8 @@ components/content/
|
||||
- 라이브 모드 단일 이미지 블록을 기존 갤러리 이미지 셀에 드롭하면 해당 셀 뒤에 이미지를 추가하고 원래 단일 이미지 줄은 제거한다(`insert-image-to-gallery`).
|
||||
- 라이브 모드 갤러리 이미지를 블록 사이 얇은 삽입선(또는 문서 맨 아래 삽입선)에 드롭하면 해당 위치에 단일 이미지 마크다운 줄을 삽입하고 갤러리에서 제거한다(`extract-gallery-image`). 갤러리에 이미지가 1장만 남으면 갤러리 블록을 단일 이미지 줄로 바꾸고, 0장이면 갤러리 블록을 제거한다.
|
||||
- `ProseImage`는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다.
|
||||
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하며, 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). 콜아웃 옵션은 첫 줄 `:::callout emoji=💡 bg=blue`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple|pink)로 지정하며, 라이브 모드에서는 아이콘 클릭으로 모달에서 편집한다(이모지 7종 프리셋·배경색 스와치, 직접 입력 없음). 코드 블록은 ` ```언어`·`nolinenos`(줄 번호 숨김)를 지원한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
|
||||
- 인용(`>`) 블록은 첫 인용 줄에 `> [!bg=yellow]` 또는 `> {bg=yellow}` 옵션 줄을 두면 해당 줄은 숨기고 블록 배경을 바꾼다. 지원 배경 프리셋은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`이며, 옵션이 없으면 기존 pink 계열 기본 인용 스타일을 쓴다.
|
||||
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 `<strong>`·`<em>` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하며, 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). 콜아웃 옵션은 첫 줄 `:::callout emoji=💡 bg=blue`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple|pink)로 지정하며, 라이브 모드에서는 아이콘 클릭으로 모달에서 편집한다(이모지 7종 프리셋·배경색 스와치, 직접 입력 없음). 코드 블록은 ` ```언어`·`nolinenos`(줄 번호 숨김)를 지원한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
|
||||
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `` 형식으로 삽입한다.
|
||||
@@ -616,7 +617,7 @@ components/content/
|
||||
- 라이브/스타일 모드에서 제목 블록 Enter는 현재 제목 내용을 저장한 뒤 바로 아래 빈 문단을 추가하고, 원문 마크다운 편집 상태로 전환하지 않는다.
|
||||
- 게시물 작성 화면 상단 제목 입력 후 Enter는 현재 에디터 모드를 유지한 채 본문 첫 줄(마크다운 첫 줄이 제목이면 그 다음 줄)로 포커스를 옮긴다.
|
||||
- 소스 모드 라인 번호는 논리 줄 수를 표시하되, 긴 문장 자동 줄바꿈으로 textarea의 한 줄 높이가 늘어나면 라인 번호 칸도 같은 높이로 맞춘다.
|
||||
- 소스 모드에서 라이브 모드로 전환하면 현재 textarea 커서 줄과 줄 안 오프셋을 기준으로 대응하는 라이브 편집 블록에 포커스를 둔다. 이때 현재 화면 위치를 불필요하게 맨 위나 맨 아래로 이동하지 않는다.
|
||||
- 소스 모드에서 라이브 모드로 전환하면 현재 textarea 커서 줄과 줄 안 오프셋을 기준으로 대응하는 라이브 편집 블록에 포커스를 두고, 대상 줄이 화면 중앙에 가깝게 보이도록 스크롤한다.
|
||||
- 라이브 모드에서 소스 모드로 전환하면 현재 포커스된 블록 또는 화면 상단에 가까운 원본 줄을 기준으로 textarea 커서와 스크롤 위치를 복원한다.
|
||||
- YouTube 임베드 URL은 공개 화면에서 본문 폭 기준 16:9 iframe으로 렌더링한다.
|
||||
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다. X 공식 iframe의 내부 최대 폭 때문에 공개 화면에서는 카드 폭을 좁혀 중앙 정렬한다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.4.7
|
||||
|
||||
- 관리자 글쓰기 라이브 모드: 문단 이동(방향키) 시 굵게·기울임 등 인라인 마크다운이 사라지던 문제 수정.
|
||||
- 콘텐츠 렌더러: 인용(`>`) 블록에 `> [!bg=색상]` 옵션 줄을 추가해 콜아웃과 같은 배경 프리셋 지정 지원.
|
||||
- 관리자 글쓰기: 소스 모드에서 라이브 모드로 전환할 때 현재 커서 줄을 라이브 화면 중앙에 가깝게 스크롤하도록 보정.
|
||||
|
||||
## v1.4.6
|
||||
|
||||
- 관리자 설정: 로고 업로드가 저장 버튼 없이 즉시 DB에 반영되던 흐름을 파일 업로드 후 저장 버튼으로 반영하도록 수정.
|
||||
|
||||
@@ -256,6 +256,30 @@ function* iterateEditableTextUnits(root) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* contenteditable 루트의 단일 자식 노드를 마크다운 인라인 문자열로 직렬화한다.
|
||||
* @param {Node} node - 자식 노드
|
||||
* @returns {string} 마크다운 조각
|
||||
*/
|
||||
const readEditableChildNodeToMarkdown = (node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || ''
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const element = /** @type {HTMLElement} */ (node)
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
|
||||
if (tagName === 'br') {
|
||||
return '\n'
|
||||
}
|
||||
|
||||
return convertHtmlInlineNodeToMarkdown(element)
|
||||
}
|
||||
|
||||
/**
|
||||
* contenteditable 루트에서 텍스트를 읽는다.
|
||||
* @param {HTMLElement} root - contenteditable 루트
|
||||
@@ -268,13 +292,14 @@ export const readEditableTextFromElement = (root) => {
|
||||
|
||||
const parts = []
|
||||
|
||||
for (const unit of iterateEditableTextUnits(root)) {
|
||||
if (unit.kind === 'text') {
|
||||
parts.push(unit.node?.textContent || '')
|
||||
continue
|
||||
for (let index = 0; index < root.childNodes.length; index += 1) {
|
||||
const node = root.childNodes[index]
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE && isEditableBlockBreak(/** @type {HTMLElement} */ (node), root) && index > 0) {
|
||||
parts.push('\n')
|
||||
}
|
||||
|
||||
parts.push('\n')
|
||||
parts.push(readEditableChildNodeToMarkdown(node))
|
||||
}
|
||||
|
||||
return parts.join('').replace(/\u00a0/g, ' ').trimEnd()
|
||||
|
||||
Reference in New Issue
Block a user