v1.4.2: 라이브 이미지·갤러리 편집 UX와 공개 화면 색상 정리
라이브 모드 이미지·갤러리 드래그 병합·분리, 갤러리 개별 편집, 블록 패널 유지, 다크모드 인용·사이드바·리스트 마커 색상을 보정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
getImageAltAttribute,
|
||||
getImageDisplayCaption,
|
||||
isImageUrl,
|
||||
parseImageMarkdownLine
|
||||
} from '../../lib/markdown-image.js'
|
||||
import { parseInlineSegments, readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
|
||||
@@ -49,24 +50,39 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'gallery-reorder',
|
||||
'merge-images-to-gallery',
|
||||
'insert-image-to-gallery',
|
||||
'extract-gallery-image',
|
||||
'remove-gallery-image',
|
||||
'block-content-change',
|
||||
'append-paragraph',
|
||||
'insert-after-line',
|
||||
'delete-line',
|
||||
'merge-with-previous-line',
|
||||
'edit-image',
|
||||
'slash-update',
|
||||
'slash-end',
|
||||
'slash-apply'
|
||||
])
|
||||
|
||||
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||
/** @type {string} 라이브 이미지·갤러리 드래그 payload MIME */
|
||||
const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image'
|
||||
/** @type {string} 본문 리스트 마커 색상 */
|
||||
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
|
||||
|
||||
const activeLightboxImages = ref([])
|
||||
const activeLightboxIndex = ref(0)
|
||||
/** @type {import('vue').Ref<{ blockId: string, imageIndex: number }|null>} */
|
||||
const galleryDragState = ref(null)
|
||||
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
|
||||
const galleryImageAspectRatios = ref({})
|
||||
/** @type {import('vue').Ref<{ kind: 'image'|'gallery', sourceLine?: number, startLine?: number, endLine?: number, blockId: string, imageIndex?: number, image?: Object }|null>} */
|
||||
const liveImageDragState = ref(null)
|
||||
/** @type {import('vue').Ref<{ blockId: string, targetIndex: number }|null>} */
|
||||
const galleryDropTarget = ref(null)
|
||||
/** @type {import('vue').Ref<{ blockId: string }|null>} */
|
||||
const imageBlockDropTarget = ref(null)
|
||||
/** @type {import('vue').Ref<{ insertBeforeLine: number }|null>} */
|
||||
const blockInsertDropTarget = ref(null)
|
||||
/** @type {import('vue').Ref<number|null>} */
|
||||
const pendingFocusLine = ref(null)
|
||||
/** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */
|
||||
@@ -198,6 +214,13 @@ const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
|
||||
*/
|
||||
const isStandaloneUrlLine = (line) => /^https?:\/\/\S+$/i.test(String(line || '').trim())
|
||||
|
||||
/**
|
||||
* 단독 이미지 URL 행인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean} 이미지 URL 여부
|
||||
*/
|
||||
const isStandaloneImageUrlLine = (line) => isStandaloneUrlLine(line) && isImageUrl(line)
|
||||
|
||||
/**
|
||||
* 빈 줄 공백 블록 높이를 반환한다.
|
||||
* @param {Object} block - 렌더링 블록
|
||||
@@ -526,6 +549,16 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isStandaloneImageUrlLine(trimmedLine)) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(createBlock('image', '', null, `block-${blocks.length}`, {
|
||||
url: trimmedLine,
|
||||
width: 'regular'
|
||||
}), startLine, startLine))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (isStandaloneUrlLine(trimmedLine)) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(
|
||||
@@ -651,8 +684,82 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
}
|
||||
|
||||
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
|
||||
/** @type {import('vue').ComputedRef<number>} 문서 맨 아래 단일 이미지 삽입 줄 */
|
||||
const tailInsertBeforeLine = computed(() => {
|
||||
const lastBlock = blocks.value[blocks.value.length - 1]
|
||||
|
||||
if (typeof lastBlock?.meta?.endLine === 'number') {
|
||||
return lastBlock.meta.endLine + 1
|
||||
}
|
||||
|
||||
return (props.content || '').split('\n').length
|
||||
})
|
||||
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
||||
|
||||
/**
|
||||
* 갤러리 이미지를 행 단위로 최대 3개씩 나눈다.
|
||||
* @param {Array<Object>} images - 갤러리 이미지 목록
|
||||
* @returns {Array<{ startIndex: number, images: Array<Object> }>} 행 목록
|
||||
*/
|
||||
const getGalleryRows = (images) => {
|
||||
const rows = []
|
||||
|
||||
for (let index = 0; index < images.length; index += 3) {
|
||||
rows.push({
|
||||
startIndex: index,
|
||||
images: images.slice(index, index + 3)
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 이미지 비율 저장 키를 만든다.
|
||||
* @param {string} blockId - 블록 ID
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {string} url - 이미지 URL
|
||||
* @returns {string} 키
|
||||
*/
|
||||
const getGalleryImageAspectKey = (blockId, imageIndex, url) => `${blockId}:${imageIndex}:${url}`
|
||||
|
||||
/**
|
||||
* 갤러리 이미지 로드 후 자연 비율을 저장한다.
|
||||
* @param {Event} event - 이미지 로드 이벤트
|
||||
* @param {string} blockId - 블록 ID
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {string} url - 이미지 URL
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryImageLoad = (event, blockId, imageIndex, url) => {
|
||||
const image = event.target
|
||||
|
||||
if (!(image instanceof HTMLImageElement) || !image.naturalWidth || !image.naturalHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
galleryImageAspectRatios.value = {
|
||||
...galleryImageAspectRatios.value,
|
||||
[getGalleryImageAspectKey(blockId, imageIndex, url)]: image.naturalWidth / image.naturalHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 이미지 셀의 flex 비율 스타일을 만든다.
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @param {Object} image - 이미지 데이터
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @returns {Record<string, string>} 스타일
|
||||
*/
|
||||
const getGalleryItemStyle = (block, image, imageIndex) => {
|
||||
const aspect = galleryImageAspectRatios.value[getGalleryImageAspectKey(block.id, imageIndex, image.url)] || 1
|
||||
|
||||
return {
|
||||
flex: `${Math.max(0.45, Math.min(aspect, 3))} 1 0`
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.content, () => {
|
||||
if (pendingFocusLine.value === null) {
|
||||
return
|
||||
@@ -1225,6 +1332,79 @@ const deletePreviewCardBlock = (block) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 이미지 설정 패널을 요청한다.
|
||||
* @param {Object} block - 이미지 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const editImageBlock = (block) => {
|
||||
if (!props.interactive || typeof block.meta?.startLine !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
emit('edit-image', block.meta.startLine)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 갤러리의 특정 이미지 설정 패널을 요청한다.
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const editGalleryImage = (block, imageIndex) => {
|
||||
if (!props.interactive || typeof block.meta?.startLine !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
emit('edit-image', block.meta.startLine + imageIndex + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 갤러리에서 특정 이미지만 삭제한다.
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeGalleryImage = (block, imageIndex) => {
|
||||
if (!props.interactive || typeof block.meta?.startLine !== 'number' || typeof block.meta?.endLine !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
emit('remove-gallery-image', {
|
||||
startLine: block.meta.startLine,
|
||||
endLine: block.meta.endLine,
|
||||
imageIndex
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 카드 키보드 이동을 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryBlockKeydown = (event, block) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
moveFromPreviewCardBlock(block, 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
moveFromPreviewCardBlock(block, -1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onInsertBelowBlock(block)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택형 프리뷰 카드 블록 키보드 조작을 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
@@ -1559,20 +1739,324 @@ const showNextImage = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 드래그 시작
|
||||
* 블록 앞 삽입 위치(0-based 줄)를 반환한다.
|
||||
* @param {Object} block - 콘텐츠 블록
|
||||
* @returns {number|null} 삽입 줄 번호
|
||||
*/
|
||||
const getBlockInsertBeforeLine = (block) => {
|
||||
const line = block?.meta?.startLine
|
||||
|
||||
return typeof line === 'number' ? line : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 블록을 드래그 payload용 객체로 변환한다.
|
||||
* @param {Object} block - 이미지 블록
|
||||
* @returns {Object} 이미지 데이터
|
||||
*/
|
||||
const toImageDragPayload = (block) => ({
|
||||
url: block.url,
|
||||
caption: block.caption,
|
||||
width: block.width,
|
||||
useAlt: block.useAlt,
|
||||
legacyBracketLabel: block.legacyBracketLabel
|
||||
})
|
||||
|
||||
/**
|
||||
* 라이브 이미지 드래그 payload를 dataTransfer에 기록한다.
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {string} blockId - 블록 ID
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} payload - 드래그 payload
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryDragStart = (event, blockId, imageIndex) => {
|
||||
const writeLiveImageDragData = (event, payload) => {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData(LIVE_IMAGE_DRAG_MIME, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 payload를 읽는다.
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @returns {Object|null} payload
|
||||
*/
|
||||
const readLiveImageDragData = (event) => {
|
||||
try {
|
||||
const raw = event.dataTransfer.getData(LIVE_IMAGE_DRAG_MIME)
|
||||
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 이미지·갤러리 드래그 상태를 초기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearLiveImageDragUi = () => {
|
||||
liveImageDragState.value = null
|
||||
galleryDropTarget.value = null
|
||||
imageBlockDropTarget.value = null
|
||||
blockInsertDropTarget.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 단일 이미지 블록 드래그 시작
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {Object} block - 이미지 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const onImageBlockDragStart = (event, block) => {
|
||||
if (!props.interactive) {
|
||||
return
|
||||
}
|
||||
|
||||
galleryDragState.value = { blockId, imageIndex }
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', String(imageIndex))
|
||||
if (event.target instanceof Element && event.target.closest('button')) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
kind: 'image',
|
||||
sourceLine: block.meta.startLine,
|
||||
blockId: block.id,
|
||||
image: toImageDragPayload(block)
|
||||
}
|
||||
|
||||
liveImageDragState.value = payload
|
||||
writeLiveImageDragData(event, payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 이미지 블록 위 드래그 오버(갤러리 병합 대상)
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {Object} block - 대상 이미지 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const onImageBlockDragOver = (event, block) => {
|
||||
if (!props.interactive || liveImageDragState.value?.kind !== 'image') {
|
||||
return
|
||||
}
|
||||
|
||||
if (liveImageDragState.value.sourceLine === block.meta.startLine) {
|
||||
imageBlockDropTarget.value = null
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
imageBlockDropTarget.value = { blockId: block.id }
|
||||
blockInsertDropTarget.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 이미지 블록 드래그 이탈
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {Object} block - 이미지 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const onImageBlockDragLeave = (event, block) => {
|
||||
const related = event.relatedTarget
|
||||
|
||||
if (related && event.currentTarget.contains(related)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (imageBlockDropTarget.value?.blockId === block.id) {
|
||||
imageBlockDropTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 이미지 두 블록 병합 드롭
|
||||
* @param {DragEvent} event - 드롭 이벤트
|
||||
* @param {Object} block - 대상 이미지 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const onImageBlockDrop = (event, block) => {
|
||||
if (!props.interactive) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const payload = readLiveImageDragData(event) || liveImageDragState.value
|
||||
|
||||
if (payload?.kind !== 'image' || typeof payload.sourceLine !== 'number') {
|
||||
clearLiveImageDragUi()
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.sourceLine === block.meta.startLine) {
|
||||
clearLiveImageDragUi()
|
||||
return
|
||||
}
|
||||
|
||||
emit('merge-images-to-gallery', {
|
||||
sourceLine: payload.sourceLine,
|
||||
targetLine: block.meta.startLine
|
||||
})
|
||||
clearLiveImageDragUi()
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 이미지 블록 드래그 종료
|
||||
* @returns {void}
|
||||
*/
|
||||
const onImageBlockDragEnd = () => {
|
||||
imageBlockDropTarget.value = null
|
||||
|
||||
if (liveImageDragState.value?.kind === 'image') {
|
||||
liveImageDragState.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 사이 삽입선 드래그 오버(갤러리 이미지 분리)
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {Object} block - 기준 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockInsertDragOver = (event, block) => {
|
||||
const insertBeforeLine = getBlockInsertBeforeLine(block)
|
||||
|
||||
if (!props.interactive || insertBeforeLine === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const dragKind = liveImageDragState.value?.kind || readLiveImageDragData(event)?.kind
|
||||
|
||||
if (dragKind !== 'gallery') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
blockInsertDropTarget.value = { insertBeforeLine }
|
||||
galleryDropTarget.value = null
|
||||
imageBlockDropTarget.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 사이 삽입선 드래그 이탈
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockInsertDragLeave = (event) => {
|
||||
const related = event.relatedTarget
|
||||
|
||||
if (related && event.currentTarget.contains(related)) {
|
||||
return
|
||||
}
|
||||
|
||||
blockInsertDropTarget.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 사이에 갤러리 이미지를 단일 블록으로 분리해 삽입
|
||||
* @param {DragEvent} event - 드롭 이벤트
|
||||
* @param {Object} block - 기준 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockInsertDrop = (event, block) => {
|
||||
const insertBeforeLine = getBlockInsertBeforeLine(block)
|
||||
|
||||
if (!props.interactive || insertBeforeLine === null) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const payload = readLiveImageDragData(event) || liveImageDragState.value
|
||||
|
||||
if (payload?.kind !== 'gallery'
|
||||
|| typeof payload.startLine !== 'number'
|
||||
|| typeof payload.endLine !== 'number'
|
||||
|| typeof payload.imageIndex !== 'number') {
|
||||
clearLiveImageDragUi()
|
||||
return
|
||||
}
|
||||
|
||||
emit('extract-gallery-image', {
|
||||
startLine: payload.startLine,
|
||||
endLine: payload.endLine,
|
||||
imageIndex: payload.imageIndex,
|
||||
insertBeforeLine,
|
||||
image: payload.image
|
||||
})
|
||||
clearLiveImageDragUi()
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 맨 아래 삽입선 드래그 오버
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onTailInsertDragOver = (event) => {
|
||||
if (!props.interactive || liveImageDragState.value?.kind !== 'gallery') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
blockInsertDropTarget.value = { insertBeforeLine: tailInsertBeforeLine.value }
|
||||
galleryDropTarget.value = null
|
||||
imageBlockDropTarget.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 맨 아래에 갤러리 이미지 분리 삽입
|
||||
* @param {DragEvent} event - 드롭 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onTailInsertDrop = (event) => {
|
||||
if (!props.interactive) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const payload = readLiveImageDragData(event) || liveImageDragState.value
|
||||
const insertBeforeLine = tailInsertBeforeLine.value
|
||||
|
||||
if (payload?.kind !== 'gallery'
|
||||
|| typeof payload.startLine !== 'number'
|
||||
|| typeof payload.endLine !== 'number'
|
||||
|| typeof payload.imageIndex !== 'number') {
|
||||
clearLiveImageDragUi()
|
||||
return
|
||||
}
|
||||
|
||||
emit('extract-gallery-image', {
|
||||
startLine: payload.startLine,
|
||||
endLine: payload.endLine,
|
||||
imageIndex: payload.imageIndex,
|
||||
insertBeforeLine,
|
||||
image: payload.image
|
||||
})
|
||||
clearLiveImageDragUi()
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 드래그 시작
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryDragStart = (event, block, imageIndex) => {
|
||||
if (!props.interactive) {
|
||||
return
|
||||
}
|
||||
|
||||
const image = block.images[imageIndex]
|
||||
const payload = {
|
||||
kind: 'gallery',
|
||||
startLine: block.meta.startLine,
|
||||
endLine: block.meta.endLine,
|
||||
blockId: block.id,
|
||||
imageIndex,
|
||||
image
|
||||
}
|
||||
|
||||
liveImageDragState.value = payload
|
||||
writeLiveImageDragData(event, payload)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1580,8 +2064,7 @@ const onGalleryDragStart = (event, blockId, imageIndex) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryDragEnd = () => {
|
||||
galleryDragState.value = null
|
||||
galleryDropTarget.value = null
|
||||
clearLiveImageDragUi()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1592,17 +2075,25 @@ const onGalleryDragEnd = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryDragOverItem = (event, block, imageIndex) => {
|
||||
if (!props.interactive || !galleryDragState.value) {
|
||||
if (!props.interactive || !['image', 'gallery'].includes(liveImageDragState.value?.kind)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
blockInsertDropTarget.value = null
|
||||
|
||||
if (galleryDragState.value.blockId !== block.id) {
|
||||
if (liveImageDragState.value.kind === 'image') {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
galleryDropTarget.value = { blockId: block.id, targetIndex: imageIndex }
|
||||
imageBlockDropTarget.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (galleryDragState.value.imageIndex === imageIndex) {
|
||||
if (liveImageDragState.value.blockId !== block.id) {
|
||||
return
|
||||
}
|
||||
|
||||
if (liveImageDragState.value.imageIndex === imageIndex) {
|
||||
galleryDropTarget.value = null
|
||||
return
|
||||
}
|
||||
@@ -1636,16 +2127,28 @@ const onGalleryDragLeaveGallery = (event, block) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGalleryDrop = (event, block, targetIndex) => {
|
||||
if (!props.interactive || !galleryDragState.value) {
|
||||
if (!props.interactive || !['image', 'gallery'].includes(liveImageDragState.value?.kind)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const { blockId, imageIndex: fromIndex } = galleryDragState.value
|
||||
if (liveImageDragState.value.kind === 'image') {
|
||||
emit('insert-image-to-gallery', {
|
||||
sourceLine: liveImageDragState.value.sourceLine,
|
||||
startLine: block.meta?.startLine,
|
||||
endLine: block.meta?.endLine,
|
||||
targetIndex: targetIndex + 1,
|
||||
image: liveImageDragState.value.image
|
||||
})
|
||||
clearLiveImageDragUi()
|
||||
return
|
||||
}
|
||||
|
||||
const { blockId, imageIndex: fromIndex } = liveImageDragState.value
|
||||
|
||||
if (blockId !== block.id || fromIndex === targetIndex) {
|
||||
galleryDragState.value = null
|
||||
clearLiveImageDragUi()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1659,8 +2162,7 @@ const onGalleryDrop = (event, block, targetIndex) => {
|
||||
images
|
||||
})
|
||||
|
||||
galleryDragState.value = null
|
||||
galleryDropTarget.value = null
|
||||
clearLiveImageDragUi()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1717,6 +2219,14 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<div ref="rendererRootRef" class="content-markdown-renderer">
|
||||
<template v-for="block in blocks" :key="block.id">
|
||||
<div
|
||||
v-if="interactive && getBlockInsertBeforeLine(block) !== null"
|
||||
class="content-markdown-renderer__block-insert"
|
||||
:class="{ 'content-markdown-renderer__block-insert--active': blockInsertDropTarget?.insertBeforeLine === getBlockInsertBeforeLine(block) }"
|
||||
@dragover="onBlockInsertDragOver($event, block)"
|
||||
@dragleave="onBlockInsertDragLeave"
|
||||
@drop="onBlockInsertDrop($event, block)"
|
||||
/>
|
||||
<ContentMarkdownEditableInline
|
||||
v-if="block.type === 'spacer' && interactive"
|
||||
tag="p"
|
||||
@@ -1844,6 +2354,52 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</li>
|
||||
</ProseList>
|
||||
<section
|
||||
v-else-if="block.type === 'image' && interactive"
|
||||
class="content-markdown-renderer__image-live group relative rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
|
||||
:class="{
|
||||
'content-markdown-renderer__image-live--dragging': liveImageDragState?.kind === 'image' && liveImageDragState?.blockId === block.id,
|
||||
'content-markdown-renderer__image-live--drop-target': imageBlockDropTarget?.blockId === block.id
|
||||
}"
|
||||
:data-source-line="block.meta.startLine"
|
||||
data-preview-card-block="true"
|
||||
tabindex="0"
|
||||
role="group"
|
||||
aria-label="이미지 블록"
|
||||
draggable="true"
|
||||
@dragstart="onImageBlockDragStart($event, block)"
|
||||
@dragend="onImageBlockDragEnd"
|
||||
@dragover="onImageBlockDragOver($event, block)"
|
||||
@dragleave="onImageBlockDragLeave($event, block)"
|
||||
@drop="onImageBlockDrop($event, block)"
|
||||
@mousedown.capture="focusPreviewCardBlock"
|
||||
@keydown="onPreviewCardKeydown($event, block)"
|
||||
>
|
||||
<div class="content-markdown-renderer__image-actions absolute right-2 top-2 z-10 flex gap-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
<button
|
||||
class="content-markdown-renderer__image-edit content-markdown-renderer__preview-card-action rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white shadow transition-colors hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)]"
|
||||
type="button"
|
||||
aria-label="이미지 편집"
|
||||
@click.stop="editImageBlock(block)"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
<button
|
||||
class="content-markdown-renderer__image-delete content-markdown-renderer__preview-card-action rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white shadow transition-colors hover:bg-red-600 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)]"
|
||||
type="button"
|
||||
aria-label="이미지 삭제"
|
||||
@click.stop="deletePreviewCardBlock(block)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
<ProseImage
|
||||
:src="block.url"
|
||||
:alt="getImageAltAttribute(block)"
|
||||
:caption="getImageDisplayCaption(block)"
|
||||
:variant="block.width"
|
||||
/>
|
||||
</section>
|
||||
<ProseImage
|
||||
v-else-if="block.type === 'image'"
|
||||
:src="block.url"
|
||||
@@ -1992,42 +2548,79 @@ onBeforeUnmount(() => {
|
||||
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
|
||||
<div
|
||||
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 group relative my-8 flex flex-col gap-2 rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
|
||||
:data-source-line="block.meta.startLine"
|
||||
:data-preview-card-block="interactive ? 'true' : undefined"
|
||||
:tabindex="interactive ? 0 : undefined"
|
||||
role="group"
|
||||
aria-label="갤러리 블록"
|
||||
@mousedown.capture="interactive ? focusPreviewCardBlock($event) : undefined"
|
||||
@keydown="interactive ? onGalleryBlockKeydown($event, block) : undefined"
|
||||
@dragleave="onGalleryDragLeaveGallery($event, block)"
|
||||
>
|
||||
<figure
|
||||
v-for="(image, imageIndex) in block.images"
|
||||
:key="`${block.id}-${imageIndex}-${image.url}`"
|
||||
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="onGalleryDragOverItem($event, block, imageIndex)"
|
||||
@drop="onGalleryDrop($event, block, imageIndex)"
|
||||
<div
|
||||
v-for="row in getGalleryRows(block.images)"
|
||||
:key="`${block.id}-row-${row.startIndex}`"
|
||||
class="content-markdown-renderer__gallery-row flex w-full gap-2"
|
||||
>
|
||||
<button
|
||||
class="content-markdown-renderer__gallery-button w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
||||
type="button"
|
||||
@click="openLightbox(block.images, imageIndex)"
|
||||
<figure
|
||||
v-for="(image, rowImageIndex) in row.images"
|
||||
:key="`${block.id}-${row.startIndex + rowImageIndex}-${image.url}`"
|
||||
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 && liveImageDragState?.kind === 'gallery' && liveImageDragState?.blockId === block.id && liveImageDragState?.imageIndex === row.startIndex + rowImageIndex,
|
||||
'content-markdown-renderer__gallery-item--drop-target': interactive && galleryDropTarget?.blockId === block.id && galleryDropTarget?.targetIndex === row.startIndex + rowImageIndex,
|
||||
'content-markdown-renderer__gallery-item--add-target': interactive && liveImageDragState?.kind === 'image' && galleryDropTarget?.blockId === block.id && galleryDropTarget?.targetIndex === row.startIndex + rowImageIndex
|
||||
}"
|
||||
:style="getGalleryItemStyle(block, image, row.startIndex + rowImageIndex)"
|
||||
:draggable="interactive"
|
||||
@dragstart="onGalleryDragStart($event, block, row.startIndex + rowImageIndex)"
|
||||
@dragend="onGalleryDragEnd"
|
||||
@dragover="onGalleryDragOverItem($event, block, row.startIndex + rowImageIndex)"
|
||||
@drop="onGalleryDrop($event, block, row.startIndex + rowImageIndex)"
|
||||
>
|
||||
<img
|
||||
class="content-markdown-renderer__gallery-image aspect-[4/3] w-full object-cover transition-transform hover:scale-[1.02]"
|
||||
:src="image.url"
|
||||
:alt="getImageAltAttribute(image)"
|
||||
<div
|
||||
v-if="interactive"
|
||||
class="content-markdown-renderer__gallery-item-actions absolute right-2 top-2 z-10 flex gap-1.5"
|
||||
>
|
||||
</button>
|
||||
<figcaption
|
||||
v-if="getImageDisplayCaption(image)"
|
||||
class="content-markdown-renderer__gallery-caption mt-1.5 text-center text-xs text-[var(--site-muted)]"
|
||||
>
|
||||
{{ getImageDisplayCaption(image) }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
<button
|
||||
class="content-markdown-renderer__gallery-image-edit content-markdown-renderer__preview-card-action rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white shadow transition-colors hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)]"
|
||||
type="button"
|
||||
aria-label="갤러리 이미지 편집"
|
||||
@click.stop="editGalleryImage(block, row.startIndex + rowImageIndex)"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
<button
|
||||
class="content-markdown-renderer__gallery-image-delete content-markdown-renderer__preview-card-action rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white shadow transition-colors hover:bg-red-600 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)]"
|
||||
type="button"
|
||||
aria-label="갤러리 이미지 삭제"
|
||||
@click.stop="removeGalleryImage(block, row.startIndex + rowImageIndex)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="content-markdown-renderer__gallery-button w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
||||
type="button"
|
||||
@click="openLightbox(block.images, row.startIndex + rowImageIndex)"
|
||||
>
|
||||
<img
|
||||
class="content-markdown-renderer__gallery-image h-full w-full object-cover transition-transform hover:scale-[1.02]"
|
||||
:src="image.url"
|
||||
:alt="getImageAltAttribute(image)"
|
||||
@load="onGalleryImageLoad($event, block.id, row.startIndex + rowImageIndex, image.url)"
|
||||
>
|
||||
</button>
|
||||
<figcaption
|
||||
v-if="getImageDisplayCaption(image)"
|
||||
class="content-markdown-renderer__gallery-caption mt-1.5 text-center text-xs text-[var(--site-muted)]"
|
||||
>
|
||||
{{ getImageDisplayCaption(image) }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
<ContentMarkdownCodeBlockEditor
|
||||
v-else-if="block.type === 'code' && interactive"
|
||||
@@ -2081,6 +2674,15 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="interactive"
|
||||
class="content-markdown-renderer__block-insert content-markdown-renderer__block-insert--tail"
|
||||
:class="{ 'content-markdown-renderer__block-insert--active': blockInsertDropTarget?.insertBeforeLine === tailInsertBeforeLine }"
|
||||
@dragover="onTailInsertDragOver"
|
||||
@dragleave="onBlockInsertDragLeave"
|
||||
@drop="onTailInsertDrop"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="interactive"
|
||||
class="content-markdown-renderer__live-tail mt-1 min-h-[120px] flex-1 cursor-text"
|
||||
@@ -2151,7 +2753,7 @@ onBeforeUnmount(() => {
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--site-accent);
|
||||
color: v-bind(CONTENT_LIST_MARKER_COLOR);
|
||||
}
|
||||
|
||||
.content-markdown-renderer__list-marker--bullet {
|
||||
@@ -2164,7 +2766,7 @@ onBeforeUnmount(() => {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--site-accent);
|
||||
background-color: v-bind(CONTENT_LIST_MARKER_COLOR);
|
||||
content: '';
|
||||
}
|
||||
|
||||
@@ -2184,6 +2786,29 @@ onBeforeUnmount(() => {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-row {
|
||||
min-height: clamp(180px, 28vw, 360px);
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-item-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-item:hover .content-markdown-renderer__gallery-item-actions,
|
||||
.content-markdown-renderer__gallery-item:focus-within .content-markdown-renderer__gallery-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-item--interactive:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -2214,4 +2839,84 @@ onBeforeUnmount(() => {
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.content-markdown-renderer__gallery-item--add-target::after {
|
||||
content: '갤러리에 추가';
|
||||
}
|
||||
|
||||
.content-markdown-renderer__block-insert {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
margin: -2px 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__block-insert--tail {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__block-insert--active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: #ff7a00;
|
||||
box-shadow: 0 0 0 1px rgba(255, 122, 0, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__block-insert--active::after {
|
||||
content: '단일 이미지로 분리';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 122, 0, 0.92);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
padding: 4px 8px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__image-live--dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__image-live[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__image-live--drop-target {
|
||||
outline: 3px solid #ff7a00;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.content-markdown-renderer__image-live--drop-target::after {
|
||||
content: '갤러리로 합치기';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
z-index: 5;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 122, 0, 0.92);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
padding: 6px 10px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,10 +9,10 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<blockquote
|
||||
class="prose-blockquote mb-2.5 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
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'
|
||||
: 'rounded-[10px] border-l-2 border-[#FF1A75] bg-[color-mix(in_srgb,#FF1A75_10%,#ffffff)] px-5 py-4 font-medium'"
|
||||
? '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]'"
|
||||
>
|
||||
<span class="whitespace-pre-line">
|
||||
<slot />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
default: ''
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
@@ -18,6 +20,42 @@ defineProps({
|
||||
default: 'regular'
|
||||
}
|
||||
})
|
||||
|
||||
const loadFailed = ref(false)
|
||||
|
||||
const hasRenderableSrc = computed(() => String(props.src || '').trim().length > 0)
|
||||
|
||||
const errorLabel = computed(() => {
|
||||
const trimmed = String(props.src || '').trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return '이미지 URL이 비어 있습니다'
|
||||
}
|
||||
|
||||
const filename = getImageDefaultAltLabel(trimmed)
|
||||
|
||||
return filename ? `이미지를 불러올 수 없습니다 · ${filename}` : '이미지를 불러올 수 없습니다'
|
||||
})
|
||||
|
||||
watch(() => props.src, () => {
|
||||
loadFailed.value = false
|
||||
})
|
||||
|
||||
/**
|
||||
* 이미지 로드 실패 시 placeholder를 표시한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onImageError = () => {
|
||||
loadFailed.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 로드 성공 시 오류 상태를 해제한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onImageLoad = () => {
|
||||
loadFailed.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,8 +66,32 @@ defineProps({
|
||||
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'
|
||||
}"
|
||||
>
|
||||
<div class="overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<img class="prose-image__media w-full object-cover" :src="src" :alt="alt">
|
||||
<div
|
||||
class="prose-image__frame overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
||||
:class="{
|
||||
'prose-image__frame--empty': !hasRenderableSrc || loadFailed,
|
||||
'prose-image__frame--broken': loadFailed
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="hasRenderableSrc && !loadFailed"
|
||||
class="prose-image__media w-full object-cover"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="prose-image__placeholder flex min-h-[180px] flex-col items-center justify-center gap-2 px-4 py-6 text-center"
|
||||
role="img"
|
||||
:aria-label="errorLabel"
|
||||
>
|
||||
<span class="prose-image__placeholder-icon text-2xl text-[var(--site-muted)]" aria-hidden="true">!</span>
|
||||
<p class="prose-image__placeholder-text max-w-full break-all text-sm text-[var(--site-muted)]">
|
||||
{{ errorLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption
|
||||
v-if="caption"
|
||||
@@ -39,3 +101,26 @@ defineProps({
|
||||
</figcaption>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prose-image__frame--empty,
|
||||
.prose-image__frame--broken {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.prose-image__frame:not(.prose-image__frame--empty):not(.prose-image__frame--broken) {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.prose-image__placeholder-icon {
|
||||
display: inline-flex;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
border: 1px dashed var(--site-line);
|
||||
background: color-mix(in srgb, var(--site-panel) 88%, #fff 12%);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user