v1.2.5: 갤러리 드롭 위치 표시 및 파일명 캡션 토글 정리
미리보기 갤러리 드래그 시 드롭 대상 셀을 시각적으로 표시하고, 파일명 토글을 캡션(figcaption) 표시로 맞춤. 미리보기 클릭→작성 모드 전환은 제거. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -158,17 +158,17 @@ const onPanelFocusOut = (event) => {
|
||||
:checked="image.useAlt"
|
||||
@change="emit('set-media-use-alt', imageIndex, $event.target.checked)"
|
||||
>
|
||||
파일명을 대체 텍스트로 사용
|
||||
파일명을 캡션으로 사용
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
v-if="image.useAlt"
|
||||
class="text-[11px] font-normal text-[#8e9cac]"
|
||||
>
|
||||
대체 텍스트: {{ getImageDefaultAltLabel(image.url) || '(파일명 없음)' }} (미리보기 화면에는 보이지 않음)
|
||||
이미지 아래에 「{{ getImageDefaultAltLabel(image.url) || '파일명 없음' }}」을 표시합니다.
|
||||
</p>
|
||||
<p class="text-[11px] font-normal text-[#8e9cac]">
|
||||
캡션은 이미지 아래에만 표시됩니다.
|
||||
<p v-else class="text-[11px] font-normal text-[#8e9cac]">
|
||||
캡션을 비우면 이미지 아래에 아무 것도 표시하지 않습니다.
|
||||
</p>
|
||||
<label class="grid gap-1 text-xs font-semibold text-[#394047]">
|
||||
이미지 URL
|
||||
|
||||
@@ -623,9 +623,9 @@ const updateActiveMediaImage = (imageIndex, patch) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 미디어 이미지의 파일명 대체 텍스트 사용 여부를 바꾼다.
|
||||
* 현재 미디어 이미지의 파일명 캡션 사용 여부를 바꾼다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {boolean} enabled - 파일명 사용 여부
|
||||
* @param {boolean} enabled - 파일명 캡션 사용 여부
|
||||
* @returns {void}
|
||||
*/
|
||||
const setActiveMediaUseAlt = (imageIndex, enabled) => {
|
||||
@@ -635,15 +635,17 @@ const setActiveMediaUseAlt = (imageIndex, enabled) => {
|
||||
return
|
||||
}
|
||||
|
||||
const filename = getImageDefaultAltLabel(image.url)
|
||||
const patch = { useAlt: enabled }
|
||||
|
||||
if (enabled && !String(image.caption || '').trim()) {
|
||||
const legacy = String(image.legacyBracketLabel || '').trim()
|
||||
const filename = getImageDefaultAltLabel(image.url)
|
||||
if (enabled) {
|
||||
if (!String(image.caption || '').trim()) {
|
||||
const legacy = String(image.legacyBracketLabel || '').trim()
|
||||
|
||||
if (legacy && legacy !== filename) {
|
||||
patch.caption = legacy
|
||||
patch.caption = legacy && legacy !== filename ? legacy : filename
|
||||
}
|
||||
} else if (String(image.caption || '').trim() === filename) {
|
||||
patch.caption = ''
|
||||
}
|
||||
|
||||
updateActiveMediaImage(imageIndex, patch)
|
||||
|
||||
@@ -25,6 +25,8 @@ const activeLightboxImages = ref([])
|
||||
const activeLightboxIndex = ref(0)
|
||||
/** @type {import('vue').Ref<{ blockId: string, imageIndex: number }|null>} */
|
||||
const galleryDragState = ref(null)
|
||||
/** @type {import('vue').Ref<{ blockId: string, targetIndex: number }|null>} */
|
||||
const galleryDropTarget = ref(null)
|
||||
|
||||
/**
|
||||
* 마크다운 블록을 생성
|
||||
@@ -35,6 +37,23 @@ const galleryDragState = ref(null)
|
||||
* @param {Object} options - 추가 블록 옵션
|
||||
* @returns {Object} 블록
|
||||
*/
|
||||
/**
|
||||
* 블록에 원본 마크다운 줄 범위를 붙인다.
|
||||
* @param {Object} block - 블록
|
||||
* @param {number} startLine - 시작 줄(0-based)
|
||||
* @param {number} endLine - 끝 줄(0-based)
|
||||
* @returns {Object} 블록
|
||||
*/
|
||||
const attachSourceRange = (block, startLine, endLine) => {
|
||||
block.meta = {
|
||||
...(block.meta && typeof block.meta === 'object' ? block.meta : {}),
|
||||
startLine,
|
||||
endLine: endLine ?? startLine
|
||||
}
|
||||
|
||||
return block
|
||||
}
|
||||
|
||||
const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
||||
id,
|
||||
type,
|
||||
@@ -276,18 +295,21 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
|
||||
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }))
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(createBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }), startLine, startLine))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!trimmedLine) {
|
||||
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(createBlock('spacer', '', null, `block-${blocks.length}`), startLine, startLine))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === '>>>') {
|
||||
const startLine = index
|
||||
const contentLines = []
|
||||
index += 1
|
||||
|
||||
@@ -296,17 +318,26 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('quote', contentLines.join('\n').trim(), null, `block-${blocks.length}`, { variant: 'alt' }))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('quote', contentLines.join('\n').trim(), null, `block-${blocks.length}`, { variant: 'alt' }),
|
||||
startLine,
|
||||
index - 1
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::bookmark') {
|
||||
const startLine = index
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const bookmarkMeta = parseBookmarkMeta(contentLines.join('\n'))
|
||||
|
||||
if (bookmarkMeta.url) {
|
||||
blocks.push(createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }),
|
||||
startLine,
|
||||
nextIndex
|
||||
))
|
||||
}
|
||||
|
||||
index = nextIndex
|
||||
@@ -314,14 +345,20 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::signup') {
|
||||
const startLine = index
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const signupMeta = parseSignupMeta(contentLines.join('\n'))
|
||||
blocks.push(createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }),
|
||||
startLine,
|
||||
nextIndex
|
||||
))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::gallery') {
|
||||
const startLine = index
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const images = []
|
||||
|
||||
@@ -332,35 +369,46 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
}
|
||||
})
|
||||
|
||||
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, {
|
||||
images,
|
||||
meta: {
|
||||
startLine: index,
|
||||
endLine: nextIndex
|
||||
}
|
||||
}))
|
||||
blocks.push(attachSourceRange(createBlock('gallery', '', null, `block-${blocks.length}`, {
|
||||
images
|
||||
}), startLine, nextIndex))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith(':::callout')) {
|
||||
const startLine = index
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)),
|
||||
startLine,
|
||||
nextIndex
|
||||
))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith(':::toggle')) {
|
||||
const startLine = index
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
|
||||
blocks.push(createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, { title }))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, { title }),
|
||||
startLine,
|
||||
nextIndex
|
||||
))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::embed') {
|
||||
const startLine = index
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }),
|
||||
startLine,
|
||||
nextIndex
|
||||
))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
@@ -368,12 +416,14 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
const image = parseImageLine(trimmedLine)
|
||||
|
||||
if (image) {
|
||||
blocks.push(createBlock('image', '', null, `block-${blocks.length}`, image))
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(createBlock('image', '', null, `block-${blocks.length}`, image), startLine, startLine))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
const startLine = index
|
||||
const codeLines = []
|
||||
index += 1
|
||||
|
||||
@@ -382,13 +432,18 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`),
|
||||
startLine,
|
||||
index
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === '---') {
|
||||
blocks.push(createBlock('divider', '', null, `block-${blocks.length}`))
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(createBlock('divider', '', null, `block-${blocks.length}`), startLine, startLine))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
@@ -396,12 +451,18 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
const headingMatch = trimmedLine.match(/^(#{1,6})\s+(.+)$/)
|
||||
|
||||
if (headingMatch) {
|
||||
blocks.push(createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`))
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`),
|
||||
startLine,
|
||||
startLine
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('> ')) {
|
||||
const startLine = index
|
||||
const quoteLines = []
|
||||
|
||||
while (index < lines.length && lines[index].trim().startsWith('>')) {
|
||||
@@ -409,11 +470,16 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('quote', quoteLines.join('\n').trim(), null, `block-${blocks.length}`))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('quote', quoteLines.join('\n').trim(), null, `block-${blocks.length}`),
|
||||
startLine,
|
||||
index - 1
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^- /.test(trimmedLine)) {
|
||||
const startLine = index
|
||||
const items = []
|
||||
|
||||
while (index < lines.length && /^- /.test(lines[index].trim())) {
|
||||
@@ -421,11 +487,12 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('list', items, null, `block-${blocks.length}`))
|
||||
blocks.push(attachSourceRange(createBlock('list', items, null, `block-${blocks.length}`), startLine, index - 1))
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmedLine)) {
|
||||
const startLine = index
|
||||
const items = []
|
||||
|
||||
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
|
||||
@@ -433,10 +500,15 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('list', items, null, `block-${blocks.length}`, { ordered: true }))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('list', items, null, `block-${blocks.length}`, { ordered: true }),
|
||||
startLine,
|
||||
index - 1
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
const paragraphStartLine = index
|
||||
const paragraphLines = [cleanParagraphLine(line)]
|
||||
let shouldJoinNextLine = hasMarkdownHardBreak(line)
|
||||
index += 1
|
||||
@@ -454,7 +526,11 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`),
|
||||
paragraphStartLine,
|
||||
index - 1
|
||||
))
|
||||
}
|
||||
|
||||
return blocks
|
||||
@@ -590,6 +666,51 @@ const onGalleryDragStart = (event, blockId, imageIndex) => {
|
||||
*/
|
||||
const onGalleryDragEnd = () => {
|
||||
galleryDragState.value = null
|
||||
galleryDropTarget.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 드래그 중 드롭 대상 셀 표시
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @param {number} imageIndex - 호버 중인 이미지 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryDragOverItem = (event, block, imageIndex) => {
|
||||
if (!props.interactive || !galleryDragState.value) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (galleryDragState.value.blockId !== block.id) {
|
||||
return
|
||||
}
|
||||
|
||||
if (galleryDragState.value.imageIndex === imageIndex) {
|
||||
galleryDropTarget.value = null
|
||||
return
|
||||
}
|
||||
|
||||
galleryDropTarget.value = { blockId: block.id, targetIndex: imageIndex }
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 영역을 벗어나면 드롭 대상 표시를 해제한다.
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryDragLeaveGallery = (event, block) => {
|
||||
const related = event.relatedTarget
|
||||
|
||||
if (related && event.currentTarget.contains(related)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (galleryDropTarget.value?.blockId === block.id) {
|
||||
galleryDropTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -624,6 +745,7 @@ const onGalleryDrop = (event, block, targetIndex) => {
|
||||
})
|
||||
|
||||
galleryDragState.value = null
|
||||
galleryDropTarget.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -758,16 +880,21 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
v-else-if="block.type === 'gallery'"
|
||||
class="content-markdown-renderer__gallery my-8 grid grid-cols-2 gap-2 md:grid-cols-3"
|
||||
@dragleave="onGalleryDragLeaveGallery($event, block)"
|
||||
>
|
||||
<figure
|
||||
v-for="(image, imageIndex) in block.images"
|
||||
:key="`${block.id}-${imageIndex}-${image.url}`"
|
||||
class="content-markdown-renderer__gallery-item min-w-0"
|
||||
:class="interactive ? 'content-markdown-renderer__gallery-item--interactive' : ''"
|
||||
class="content-markdown-renderer__gallery-item relative min-w-0"
|
||||
:class="{
|
||||
'content-markdown-renderer__gallery-item--interactive': interactive,
|
||||
'content-markdown-renderer__gallery-item--dragging': interactive && galleryDragState?.blockId === block.id && galleryDragState?.imageIndex === imageIndex,
|
||||
'content-markdown-renderer__gallery-item--drop-target': interactive && galleryDropTarget?.blockId === block.id && galleryDropTarget?.targetIndex === imageIndex
|
||||
}"
|
||||
:draggable="interactive"
|
||||
@dragstart="onGalleryDragStart($event, block.id, imageIndex)"
|
||||
@dragend="onGalleryDragEnd"
|
||||
@dragover.prevent
|
||||
@dragover="onGalleryDragOverItem($event, block, imageIndex)"
|
||||
@drop="onGalleryDrop($event, block, imageIndex)"
|
||||
>
|
||||
<button
|
||||
@@ -853,4 +980,31 @@ onBeforeUnmount(() => {
|
||||
.content-markdown-renderer__gallery-item--interactive:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-item--dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-item--drop-target {
|
||||
outline: 3px solid #ff7a00;
|
||||
outline-offset: 3px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-item--drop-target::after {
|
||||
content: '여기로 이동';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 122, 0, 0.22);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
|
||||
12
docs/spec.md
12
docs/spec.md
@@ -176,8 +176,9 @@ components/content/
|
||||
- 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록
|
||||
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`)
|
||||
- 이미지
|
||||
- 기본: `` — 대체 텍스트 없음. **파일명 사용** 토글 시 ``로 저장·렌더
|
||||
- 캡션(표시용): `` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시, 파일명은 기본 노출하지 않음
|
||||
- 기본: `` — 이미지 아래 캡션 없음
|
||||
- 캡션(표시용): `` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시
|
||||
- **파일명을 캡션으로 사용** 토글: URL 파일명을 캡션으로 저장·표시(``). 레거시 ``도 동일하게 해석
|
||||
- 와이드/풀: `{width=wide|full}` 또는 캡션·width 조합
|
||||
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경)
|
||||
- 이미지 갤러리
|
||||
@@ -478,12 +479,13 @@ components/content/
|
||||
- `Cmd/Ctrl+B`, `Cmd/Ctrl+I`는 현재 선택 텍스트에 각각 굵게, 기울임 마크다운을 적용한다.
|
||||
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
|
||||
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
|
||||
- 관리자 미리보기에서 `ContentMarkdownRenderer`에 `interactive`를 켠다. 갤러리 이미지는 드래그로 순서를 바꿀 수 있으며, 드래그 중 다른 셀 위에 올리면 해당 셀에 주황 테두리와 「여기로 이동」 표시로 드롭 위치를 보여 준 뒤 `gallery-reorder`로 마크다운을 갱신한다.
|
||||
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `` 형식으로 삽입한다.
|
||||
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
|
||||
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, `:::embed` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문 포커스가 완전히 이탈하면 슬라이드 아웃한다.
|
||||
- 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 대체 텍스트로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL). `AdminMarkdownEditor`는 `block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다.
|
||||
- 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 캡션으로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL). `AdminMarkdownEditor`는 `block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다.
|
||||
- 미디어 라이브러리 갤러리 다중 선택 시 선택 항목은 **주황(`#ff7a00`) 굵은 테두리**로 표시한다.
|
||||
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
|
||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||
@@ -528,8 +530,8 @@ components/content/
|
||||
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `` 또는 파일명 토글 시 `` 형식으로 저장한다.
|
||||
- 이미지/갤러리 삽입 시 대체 텍스트는 기본 비우며, 블록 설정 패널에서 **파일명을 대체 텍스트로 사용** 토글로만 켠다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `` 또는 파일명 캡션 토글 시 `` 형식으로 저장한다.
|
||||
- 이미지/갤러리 삽입 시 캡션은 기본 비우며, 블록 설정 패널에서 **파일명을 캡션으로 사용** 토글로 이미지 아래에 URL 파일명을 표시한다.
|
||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||
- 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.2.5
|
||||
|
||||
- 관리자 미리보기 갤러리: 드래그 중 드롭 대상 셀에 주황 테두리·「여기로 이동」 오버레이 표시, 드래그 중 원본 셀 반투명 처리.
|
||||
- 이미지 **파일명을 캡션으로 사용** 토글: 화면에는 이미지 아래 figcaption으로 표시, 저장은 `` 형식(레거시 `` 호환).
|
||||
- 미리보기 문단 클릭→작성 모드 전환은 제거(요청과 다름). 미리보기 그대로 편집(WYSIWYG)은 후속 작업.
|
||||
- 패키지 버전 `1.2.5`로 갱신.
|
||||
|
||||
## v1.2.4
|
||||
|
||||
- 이미지 캡션: `ProseImage` caption prop, 갤러리 figcaption, 파일명 alt와 별도 표시. useAlt 파일명 비교 URI 디코딩 정규화.
|
||||
|
||||
@@ -71,14 +71,17 @@ export const parseImageMarkdownLine = (line) => {
|
||||
const altBracket = (match[1] || '').trim()
|
||||
const quotedCaption = unescapeImageCaption(match[3])
|
||||
const filenameAlt = getImageDefaultAltLabel(url)
|
||||
/** 대괄호 안 문자열이 URL 파일명과 같을 때만 파일명 대체 텍스트 모드 */
|
||||
const useAlt = altBracket !== ''
|
||||
&& normalizeFilenameLabel(altBracket) === normalizeFilenameLabel(filenameAlt)
|
||||
/** 레거시 `` 또는 따옴표 캡션이 URL 파일명과 같을 때 파일명 캡션 모드 */
|
||||
const useAlt = (altBracket !== ''
|
||||
&& normalizeFilenameLabel(altBracket) === normalizeFilenameLabel(filenameAlt))
|
||||
|| (quotedCaption !== ''
|
||||
&& altBracket === ''
|
||||
&& normalizeFilenameLabel(quotedCaption) === normalizeFilenameLabel(filenameAlt))
|
||||
|
||||
return {
|
||||
url,
|
||||
/** `` 따옴표 캡션만 편집 필드에 반영 */
|
||||
caption: quotedCaption,
|
||||
/** `` — 파일명 캡션 모드면 편집 필드에 파일명 반영 */
|
||||
caption: quotedCaption || (useAlt ? filenameAlt : ''),
|
||||
/** 레거시 `` — 캡션 따옴표 없을 때 미리보기용 */
|
||||
legacyBracketLabel: !quotedCaption && altBracket && !useAlt ? altBracket : '',
|
||||
width: match[4] || 'regular',
|
||||
@@ -98,12 +101,17 @@ export const serializeImageMarkdown = (image) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
const alt = image.useAlt === true ? getImageDefaultAltLabel(url) : ''
|
||||
const caption = String(image.caption ?? '').trim()
|
||||
const filename = getImageDefaultAltLabel(url)
|
||||
let caption = String(image.caption ?? '').trim()
|
||||
|
||||
if (image.useAlt === true && !caption) {
|
||||
caption = filename
|
||||
}
|
||||
|
||||
const titlePart = caption ? ` "${escapeImageCaption(caption)}"` : ''
|
||||
const width = image.width && image.width !== 'regular' ? `{width=${image.width}}` : ''
|
||||
|
||||
return `${width}`
|
||||
return `${width}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,13 +119,7 @@ export const serializeImageMarkdown = (image) => {
|
||||
* @param {{ url?: string, useAlt?: boolean }} image - 이미지 정보
|
||||
* @returns {string} alt 속성값
|
||||
*/
|
||||
export const getImageAltAttribute = (image) => {
|
||||
if (!image?.useAlt) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return getImageDefaultAltLabel(image.url)
|
||||
}
|
||||
export const getImageAltAttribute = () => ''
|
||||
|
||||
/**
|
||||
* 공개 렌더용 캡션(표시용 figcaption)
|
||||
@@ -132,7 +134,7 @@ export const getImageCaption = (image) => {
|
||||
}
|
||||
|
||||
if (image?.useAlt) {
|
||||
return ''
|
||||
return getImageDefaultAltLabel(image.url)
|
||||
}
|
||||
|
||||
return String(image?.legacyBracketLabel || '').trim()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user