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