<script setup>
const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:modelValue'])

const editorBlocks = ref([])
const blockRefs = ref([])
const activeBlockId = ref('')
const slashQuery = ref('')
const slashMenuDirection = ref('down')
const highlightedCommandIndex = ref(0)
const isApplyingExternalValue = ref(false)
const uploadingBlockIds = ref([])
const mediaItems = ref([])
const mediaPickerTarget = ref(null)
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
let blockIdSeed = 0

const imageWidthOptions = [
  { value: 'regular', label: '기본' },
  { value: 'wide', label: '와이드' },
  { value: 'full', label: '풀사이즈' }
]

const blockCommands = [
  {
    type: 'paragraph',
    label: '문단',
    description: '기본 본문 블록',
    keywords: ['p', 'text', 'paragraph', '문단']
  },
  {
    type: 'heading',
    level: 2,
    label: '제목 2',
    description: '섹션 제목',
    keywords: ['h2', 'heading', 'title', '제목']
  },
  {
    type: 'heading',
    level: 3,
    label: '제목 3',
    description: '작은 섹션 제목',
    keywords: ['h3', 'heading', 'subtitle', '제목']
  },
  {
    type: 'image',
    label: '이미지',
    description: '단일 이미지 업로드',
    keywords: ['image', 'photo', '이미지', '사진']
  },
  {
    type: 'gallery',
    label: '갤러리',
    description: '여러 이미지 업로드',
    keywords: ['gallery', 'images', '갤러리', '사진']
  },
  {
    type: 'quote',
    label: '인용',
    description: '강조 인용문',
    keywords: ['quote', 'blockquote', '인용']
  },
  {
    type: 'list',
    label: '목록',
    description: '불릿 목록',
    keywords: ['list', 'bullet', '목록']
  },
  {
    type: 'code',
    label: '코드',
    description: '코드 블록',
    keywords: ['code', 'pre', '코드']
  },
  {
    type: 'divider',
    label: '구분선',
    description: '본문 구간 분리',
    keywords: ['divider', 'hr', 'line', '구분선']
  }
]

/**
 * 에디터 블록 생성
 * @param {string} type - 블록 타입
 * @param {string} text - 블록 텍스트
 * @param {number|null} level - 제목 레벨
 * @param {string} id - 블록 ID
 * @param {Object} options - 추가 블록 옵션
 * @returns {Object} 에디터 블록
 */
const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
  id: id || `editor-block-new-${blockIdSeed += 1}`,
  type,
  text,
  level,
  url: options.url || '',
  alt: options.alt || '',
  width: options.width || 'regular',
  images: options.images || []
})

/**
 * 이미지 마크다운 행을 블록 옵션으로 변환
 * @param {string} line - 마크다운 행
 * @returns {Object|null} 이미지 블록 옵션
 */
const parseImageLine = (line) => {
  const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/)

  if (!match) {
    return null
  }

  return {
    alt: match[1],
    url: match[2],
    width: match[3] || 'regular'
  }
}

/**
 * 저장된 마크다운 문자열을 에디터 블록으로 변환
 * @param {string} markdown - 마크다운 문자열
 * @returns {Array<Object>} 에디터 블록 목록
 */
const parseMarkdownToBlocks = (markdown) => {
  const lines = markdown.split('\n')
  const blocks = []
  let index = 0

  while (index < lines.length) {
    const line = lines[index]
    const trimmedLine = line.trim()

    if (!trimmedLine) {
      index += 1
      continue
    }

    if (trimmedLine === ':::gallery') {
      const images = []
      index += 1

      while (index < lines.length && lines[index].trim() !== ':::') {
        const image = parseImageLine(lines[index])

        if (image) {
          images.push(image)
        }

        index += 1
      }

      blocks.push(createEditorBlock('gallery', '', null, `editor-block-${blocks.length}`, { images }))
      index += 1
      continue
    }

    const image = parseImageLine(trimmedLine)

    if (image) {
      blocks.push(createEditorBlock('image', '', null, `editor-block-${blocks.length}`, image))
      index += 1
      continue
    }

    if (trimmedLine.startsWith('```')) {
      const codeLines = []
      index += 1

      while (index < lines.length && !lines[index].trim().startsWith('```')) {
        codeLines.push(lines[index])
        index += 1
      }

      blocks.push(createEditorBlock('code', codeLines.join('\n'), null, `editor-block-${blocks.length}`))
      index += 1
      continue
    }

    if (trimmedLine === '---') {
      blocks.push(createEditorBlock('divider', '', null, `editor-block-${blocks.length}`))
      index += 1
      continue
    }

    const headingMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/)

    if (headingMatch) {
      blocks.push(createEditorBlock('heading', headingMatch[2], headingMatch[1].length, `editor-block-${blocks.length}`))
      index += 1
      continue
    }

    if (trimmedLine.startsWith('> ')) {
      blocks.push(createEditorBlock('quote', trimmedLine.replace(/^>\s?/, ''), null, `editor-block-${blocks.length}`))
      index += 1
      continue
    }

    if (/^- /.test(trimmedLine)) {
      blocks.push(createEditorBlock('list', trimmedLine.replace(/^- /, ''), null, `editor-block-${blocks.length}`))
      index += 1
      continue
    }

    blocks.push(createEditorBlock('paragraph', trimmedLine, null, `editor-block-${blocks.length}`))
    index += 1
  }

  return blocks.length ? blocks : [createEditorBlock('paragraph', '', null, 'editor-block-0')]
}

/**
 * 이미지 블록을 마크다운 문자열로 변환
 * @param {Object} image - 이미지 데이터
 * @returns {string} 이미지 마크다운
 */
const serializeImage = (image) => {
  const width = image.width && image.width !== 'regular'
    ? `{width=${image.width}}`
    : ''

  return `![${image.alt || ''}](${image.url})${width}`
}

/**
 * 에디터 블록 목록을 저장용 마크다운으로 변환
 * @returns {string} 마크다운 문자열
 */
const serializeBlocks = () => {
  const lines = editorBlocks.value
    .map((block) => {
      const text = block.text.trim()

      if (block.type === 'divider') {
        return { type: block.type, value: '---' }
      }

      if (block.type === 'image') {
        return block.url
          ? { type: block.type, value: serializeImage(block) }
          : null
      }

      if (block.type === 'gallery') {
        const images = block.images.filter((image) => image.url)

        if (!images.length) {
          return null
        }

        return {
          type: block.type,
          value: [':::gallery', ...images.map((image) => serializeImage(image)), ':::'].join('\n')
        }
      }

      if (!text) {
        return null
      }

      if (block.type === 'heading') {
        return { type: block.type, value: `${'#'.repeat(block.level || 2)} ${text}` }
      }

      if (block.type === 'quote') {
        return { type: block.type, value: `> ${text}` }
      }

      if (block.type === 'list') {
        return { type: block.type, value: `- ${text}` }
      }

      if (block.type === 'code') {
        return { type: block.type, value: `\`\`\`\n${block.text}\n\`\`\`` }
      }

      return { type: block.type, value: text }
    })
    .filter(Boolean)

  return lines.reduce((markdown, line, index) => {
    if (index === 0) {
      return line.value
    }

    const previousLine = lines[index - 1]
    const joiner = previousLine.type === 'list' && line.type === 'list'
      ? '\n'
      : '\n\n'

    return `${markdown}${joiner}${line.value}`
  }, '')
}

/**
 * 부모 폼으로 콘텐츠 변경 전달
 * @returns {void}
 */
const emitContent = () => {
  emit('update:modelValue', serializeBlocks())
}

/**
 * 텍스트 입력 블록 여부 반환
 * @param {Object} block - 에디터 블록
 * @returns {boolean} 텍스트 입력 블록 여부
 */
const isTextBlock = (block) => !['divider', 'image', 'gallery'].includes(block.type)

/**
 * 블록 DOM 요소를 저장
 * @param {Element|null} element - 블록 DOM 요소
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const setBlockRef = (element, index) => {
  if (element) {
    blockRefs.value[index] = element

    if (isTextBlock(editorBlocks.value[index]) && element.innerText !== editorBlocks.value[index]?.text) {
      element.innerText = editorBlocks.value[index]?.text || ''
    }
  }
}

/**
 * 지정 블록으로 커서 이동
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const focusBlock = (index) => {
  nextTick(() => {
    const element = blockRefs.value[index]

    if (!element) {
      return
    }

    element.focus()
    const selection = window.getSelection()
    const range = document.createRange()
    range.selectNodeContents(element)
    range.collapse(false)
    selection.removeAllRanges()
    selection.addRange(range)
  })
}

/**
 * 슬래시 메뉴 표시 방향 갱신
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const updateSlashMenuDirection = (index) => {
  nextTick(() => {
    const element = blockRefs.value[index]

    if (!element) {
      slashMenuDirection.value = 'down'
      return
    }

    const rect = element.getBoundingClientRect()
    const menuHeight = 280
    slashMenuDirection.value = window.innerHeight - rect.bottom < menuHeight && rect.top > menuHeight
      ? 'up'
      : 'down'
  })
}

/**
 * 블록 타입에 맞는 태그명 반환
 * @param {Object} block - 에디터 블록
 * @returns {string} HTML 태그명
 */
const getBlockTag = (block) => {
  if (block.type === 'heading') {
    return `h${block.level || 2}`
  }

  if (block.type === 'quote') {
    return 'blockquote'
  }

  if (block.type === 'code') {
    return 'pre'
  }

  return 'div'
}

/**
 * 블록 타입에 맞는 클래스 반환
 * @param {Object} block - 에디터 블록
 * @returns {Array<string|Object>} 클래스 목록
 */
const getBlockClass = (block) => [
  'admin-block-editor__block outline-none transition-colors',
  {
    'admin-block-editor__paragraph min-h-8 text-[17px] leading-8': block.type === 'paragraph',
    'admin-block-editor__heading mt-8 min-h-10 font-semibold leading-tight': block.type === 'heading',
    'admin-block-editor__heading--h1 text-5xl': block.type === 'heading' && block.level === 1,
    'admin-block-editor__heading--h2 text-4xl': block.type === 'heading' && block.level === 2,
    'admin-block-editor__heading--h3 text-3xl': block.type === 'heading' && block.level === 3,
    'admin-block-editor__quote my-5 border-l-4 border-ink bg-surface px-5 py-3 text-xl font-medium leading-8': block.type === 'quote',
    'admin-block-editor__list relative min-h-8 pl-7 text-[17px] leading-8 before:absolute before:left-2 before:top-3 before:h-2 before:w-2 before:rounded-full before:bg-current': block.type === 'list',
    'admin-block-editor__code my-5 min-h-14 whitespace-pre-wrap rounded bg-[#15171a] px-4 py-3 font-mono text-sm leading-6 text-white': block.type === 'code'
  }
]

/**
 * 이미지 너비 클래스 반환
 * @param {string} width - 이미지 너비 옵션
 * @returns {string} 클래스 문자열
 */
const getImageWidthClass = (width) => {
  if (width === 'full') {
    return 'admin-block-editor__image--full lg:-mx-20'
  }

  if (width === 'wide') {
    return 'admin-block-editor__image--wide lg:-mx-10'
  }

  return 'admin-block-editor__image--regular'
}

/**
 * 블록 텍스트 입력 처리
 * @param {InputEvent} event - 입력 이벤트
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const updateBlockText = (event, index) => {
  const block = editorBlocks.value[index]
  const text = event.target.innerText.replace(/\n$/, '')

  block.text = text
  activeBlockId.value = block.id
  applyMarkdownShortcut(block, index)
  updateSlashQuery(block)
  updateSlashMenuDirection(index)
  emitContent()
}

/**
 * 마크다운 입력 단축 문법 적용
 * @param {Object} block - 에디터 블록
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const applyMarkdownShortcut = (block, index) => {
  const shortcutMap = [
    { marker: '# ', type: 'heading', level: 1 },
    { marker: '## ', type: 'heading', level: 2 },
    { marker: '### ', type: 'heading', level: 3 },
    { marker: '> ', type: 'quote' },
    { marker: '- ', type: 'list' },
    { marker: '``` ', type: 'code' }
  ].sort((a, b) => b.marker.length - a.marker.length)

  const shortcut = shortcutMap.find((item) => block.text.startsWith(item.marker))

  if (!shortcut) {
    return
  }

  block.type = shortcut.type
  block.level = shortcut.level || null
  block.text = block.text.slice(shortcut.marker.length)
  slashQuery.value = ''

  nextTick(() => {
    const element = blockRefs.value[index]

    if (element) {
      element.innerText = block.text
      focusBlock(index)
    }
  })
}

/**
 * 슬래시 메뉴 검색어 갱신
 * @param {Object} block - 에디터 블록
 * @returns {void}
 */
const updateSlashQuery = (block) => {
  slashQuery.value = block.text.startsWith('/')
    ? block.text.slice(1).trim().toLowerCase()
    : ''

  highlightedCommandIndex.value = 0
}

const activeBlockIndex = computed(() => editorBlocks.value.findIndex((block) => block.id === activeBlockId.value))

const visibleCommands = computed(() => {
  if (activeBlockIndex.value < 0) {
    return []
  }

  const block = editorBlocks.value[activeBlockIndex.value]

  if (!block?.text.startsWith('/')) {
    return []
  }

  return blockCommands.filter((command) => [
    command.label,
    command.description,
    ...command.keywords
  ].some((keyword) => keyword.toLowerCase().includes(slashQuery.value)))
})

const highlightedCommand = computed(() => visibleCommands.value[highlightedCommandIndex.value])

/**
 * 슬래시 메뉴 명령 적용
 * @param {Object} command - 블록 명령
 * @returns {void}
 */
const applyCommand = (command) => {
  const index = activeBlockIndex.value

  if (index < 0) {
    return
  }

  const block = editorBlocks.value[index]
  block.type = command.type
  block.level = command.level || null
  block.text = ''
  block.url = ''
  block.alt = ''
  block.width = 'regular'
  block.images = []
  const element = blockRefs.value[index]

  if (element) {
    element.innerText = ''
  }

  slashQuery.value = ''

  if (command.type === 'divider') {
    editorBlocks.value.splice(index + 1, 0, createEditorBlock())
    emitContent()
    focusBlock(index + 1)
    return
  }

  emitContent()

  if (isTextBlock(block)) {
    focusBlock(index)
  }
}

/**
 * 블록 업로드 진행 상태 설정
 * @param {string} blockId - 블록 ID
 * @param {boolean} isUploading - 업로드 진행 여부
 * @returns {void}
 */
const setUploading = (blockId, isUploading) => {
  uploadingBlockIds.value = isUploading
    ? [...new Set([...uploadingBlockIds.value, blockId])]
    : uploadingBlockIds.value.filter((id) => id !== blockId)
}

/**
 * 블록 업로드 진행 상태 반환
 * @param {string} blockId - 블록 ID
 * @returns {boolean} 업로드 진행 여부
 */
const isUploading = (blockId) => uploadingBlockIds.value.includes(blockId)

/**
 * 이미지 파일 업로드
 * @param {FileList|Array<File>} files - 업로드 파일 목록
 * @returns {Promise<Array<Object>>} 업로드된 파일 목록
 */
const uploadImages = async (files) => {
  const formData = new FormData()

  Array.from(files).forEach((file) => {
    formData.append('files', file)
  })

  const result = await $fetch('/admin/api/uploads', {
    method: 'POST',
    body: formData
  })

  return result.files || []
}

/**
 * 미디어 라이브러리 목록 조회
 * @returns {Promise<void>}
 */
const fetchMediaItems = async () => {
  isLoadingMedia.value = true

  try {
    mediaItems.value = await $fetch('/admin/api/media')
  } finally {
    isLoadingMedia.value = false
  }
}

/**
 * 미디어 선택 창 열기
 * @param {Object} block - 대상 블록
 * @returns {Promise<void>}
 */
const openMediaPicker = async (block) => {
  mediaPickerTarget.value = {
    blockId: block.id,
    type: block.type
  }
  isMediaPickerOpen.value = true
  await fetchMediaItems()
}

/**
 * 미디어 선택 창 닫기
 * @returns {void}
 */
const closeMediaPicker = () => {
  isMediaPickerOpen.value = false
  mediaPickerTarget.value = null
}

/**
 * 선택한 미디어를 블록에 적용
 * @param {Object} mediaItem - 미디어 항목
 * @returns {void}
 */
const selectMediaItem = (mediaItem) => {
  const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId)

  if (!block) {
    return
  }

  if (mediaPickerTarget.value.type === 'gallery') {
    block.images = [
      ...block.images,
      {
        url: mediaItem.url,
        alt: mediaItem.title,
        width: 'regular'
      }
    ]
  } else {
    block.url = mediaItem.url
    block.alt = mediaItem.title
  }

  emitContent()
  closeMediaPicker()
}

/**
 * 단일 이미지 파일 선택 처리
 * @param {Event} event - 파일 입력 이벤트
 * @param {Object} block - 이미지 블록
 * @returns {Promise<void>}
 */
const handleImageUpload = async (event, block) => {
  const files = event.target.files

  if (!files?.length) {
    return
  }

  setUploading(block.id, true)

  try {
    const [file] = await uploadImages(files)

    if (file) {
      block.url = file.url
      block.alt = block.alt || file.name.replace(/\.[^.]+$/g, '')
      emitContent()
    }
  } finally {
    event.target.value = ''
    setUploading(block.id, false)
  }
}

/**
 * 갤러리 이미지 파일 선택 처리
 * @param {Event} event - 파일 입력 이벤트
 * @param {Object} block - 갤러리 블록
 * @returns {Promise<void>}
 */
const handleGalleryUpload = async (event, block) => {
  const files = event.target.files

  if (!files?.length) {
    return
  }

  setUploading(block.id, true)

  try {
    const uploadedFiles = await uploadImages(files)
    block.images = [
      ...block.images,
      ...uploadedFiles.map((file) => ({
        url: file.url,
        alt: file.name.replace(/\.[^.]+$/g, ''),
        width: 'regular'
      }))
    ]
    emitContent()
  } finally {
    event.target.value = ''
    setUploading(block.id, false)
  }
}

/**
 * 이미지 너비 옵션 변경
 * @param {Object} block - 이미지 블록
 * @param {string} width - 너비 옵션
 * @returns {void}
 */
const updateImageWidth = (block, width) => {
  block.width = width
  emitContent()
}

/**
 * 갤러리 이미지 삭제
 * @param {Object} block - 갤러리 블록
 * @param {number} imageIndex - 이미지 인덱스
 * @returns {void}
 */
const removeGalleryImage = (block, imageIndex) => {
  block.images.splice(imageIndex, 1)
  emitContent()
}

/**
 * 슬래시 메뉴 선택을 아래로 이동
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @returns {void}
 */
const highlightNextCommand = (event) => {
  if (!visibleCommands.value.length) {
    return
  }

  event.preventDefault()
  highlightedCommandIndex.value = (highlightedCommandIndex.value + 1) % visibleCommands.value.length
}

/**
 * 슬래시 메뉴 선택을 위로 이동
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @returns {void}
 */
const highlightPreviousCommand = (event) => {
  if (!visibleCommands.value.length) {
    return
  }

  event.preventDefault()
  highlightedCommandIndex.value = highlightedCommandIndex.value === 0
    ? visibleCommands.value.length - 1
    : highlightedCommandIndex.value - 1
}

/**
 * 엔터 키로 다음 블록 생성
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const handleEnter = (event, index) => {
  const currentBlock = editorBlocks.value[index]

  if (visibleCommands.value.length && currentBlock.text.startsWith('/')) {
    event.preventDefault()
    applyCommand(highlightedCommand.value || visibleCommands.value[0])
    return
  }

  if (currentBlock.type === 'code' && !event.shiftKey) {
    return
  }

  event.preventDefault()

  if (['divider', 'image', 'gallery'].includes(currentBlock.type)) {
    editorBlocks.value.splice(index + 1, 0, createEditorBlock())
    emitContent()
    focusBlock(index + 1)
    return
  }

  if (!currentBlock.text.trim() && currentBlock.type !== 'paragraph') {
    currentBlock.type = 'paragraph'
    currentBlock.level = null
    emitContent()
    focusBlock(index)
    return
  }

  const nextType = currentBlock.type === 'list' ? 'list' : 'paragraph'
  editorBlocks.value.splice(index + 1, 0, createEditorBlock(nextType))
  emitContent()
  focusBlock(index + 1)
}

/**
 * 백스페이스 키로 빈 블록 삭제
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const handleBackspace = (event, index) => {
  const block = editorBlocks.value[index]

  if ((isTextBlock(block) && block.text) || editorBlocks.value.length <= 1) {
    return
  }

  if (!isTextBlock(block) && (block.url || block.images.length)) {
    return
  }

  event.preventDefault()
  editorBlocks.value.splice(index, 1)
  emitContent()
  focusBlock(Math.max(index - 1, 0))
}

/**
 * 현재 블록 활성화
 * @param {Object} block - 에디터 블록
 * @returns {void}
 */
const activateBlock = (block) => {
  const index = editorBlocks.value.findIndex((item) => item.id === block.id)
  activeBlockId.value = block.id
  updateSlashQuery(block)
  updateSlashMenuDirection(index)
}

/**
 * 블록 placeholder 표시 여부 반환
 * @param {Object} block - 에디터 블록
 * @param {number} index - 블록 인덱스
 * @returns {boolean} placeholder 표시 여부
 */
const shouldShowPlaceholder = (block, index) => !block.text && (
  activeBlockId.value === block.id ||
  (index === 0 && editorBlocks.value.length === 1)
)

watch(() => props.modelValue, (value) => {
  if (isApplyingExternalValue.value) {
    return
  }

  const currentValue = serializeBlocks()

  if (value === currentValue && editorBlocks.value.length) {
    return
  }

  editorBlocks.value = parseMarkdownToBlocks(value)
}, { immediate: true })

watch(editorBlocks, () => {
  isApplyingExternalValue.value = true
  nextTick(() => {
    isApplyingExternalValue.value = false
  })
}, { deep: true })

defineExpose({
  focusFirstBlock: () => focusBlock(0)
})
</script>

<template>
  <div class="admin-block-editor min-h-[32rem] bg-transparent py-4 text-ink">
    <div class="admin-block-editor__surface post-prose grid gap-1">
      <div
        v-for="(block, index) in editorBlocks"
        :key="block.id"
        class="admin-block-editor__row relative"
      >
        <hr v-if="block.type === 'divider'" class="admin-block-editor__divider my-6 border-line">

        <figure
          v-else-if="block.type === 'image'"
          class="admin-block-editor__media group my-6"
          :class="getImageWidthClass(block.width)"
          tabindex="0"
          @focus="activateBlock(block)"
          @click="activateBlock(block)"
          @keydown.enter="handleEnter($event, index)"
          @keydown.backspace="handleBackspace($event, index)"
        >
          <div v-if="block.url" class="admin-block-editor__image-frame relative overflow-hidden rounded bg-surface">
            <img class="admin-block-editor__image w-full object-cover" :src="block.url" :alt="block.alt">
            <div class="admin-block-editor__media-toolbar absolute inset-x-3 top-3 flex flex-wrap items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100 group-focus:opacity-100">
              <button
                class="admin-block-editor__media-option rounded bg-white/95 px-3 py-1 text-xs font-semibold text-ink shadow"
                type="button"
                @click="openMediaPicker(block)"
              >
                미디어 선택
              </button>
              <button
                v-for="option in imageWidthOptions"
                :key="option.value"
                class="admin-block-editor__media-option rounded bg-white/95 px-3 py-1 text-xs font-semibold shadow"
                :class="block.width === option.value ? 'text-ink ring-1 ring-ink' : 'text-muted'"
                type="button"
                @click="updateImageWidth(block, option.value)"
              >
                {{ option.label }}
              </button>
            </div>
          </div>
          <div v-else class="admin-block-editor__upload grid gap-3 rounded border border-dashed border-line bg-surface px-6 py-14 text-center text-sm font-semibold text-muted">
            <button class="admin-block-editor__media-select rounded border border-line bg-white px-3 py-2 text-ink" type="button" @click="openMediaPicker(block)">
              미디어 선택
            </button>
            <label class="admin-block-editor__upload-label cursor-pointer rounded bg-[#15171a] px-3 py-2 text-white">
              {{ isUploading(block.id) ? '업로드 중' : '새 이미지 업로드' }}
              <input class="sr-only" type="file" accept="image/*" @change="handleImageUpload($event, block)">
            </label>
          </div>
          <input
            v-if="block.url"
            v-model="block.alt"
            class="admin-block-editor__caption mt-3 w-full border-0 bg-transparent text-center text-sm text-muted outline-none placeholder:text-soft"
            type="text"
            placeholder="이미지 설명"
            @input="emitContent"
          >
        </figure>

        <figure
          v-else-if="block.type === 'gallery'"
          class="admin-block-editor__gallery group my-6"
          tabindex="0"
          @focus="activateBlock(block)"
          @click="activateBlock(block)"
          @keydown.enter="handleEnter($event, index)"
          @keydown.backspace="handleBackspace($event, index)"
        >
          <div v-if="block.images.length" class="admin-block-editor__gallery-grid grid grid-cols-2 gap-2 md:grid-cols-3">
            <div
              v-for="(image, imageIndex) in block.images"
              :key="`${block.id}-${image.url}`"
              class="admin-block-editor__gallery-item group/item relative overflow-hidden rounded bg-surface"
            >
              <img class="admin-block-editor__gallery-image aspect-[4/3] w-full object-cover" :src="image.url" :alt="image.alt">
              <button
                class="admin-block-editor__gallery-remove absolute right-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold text-ink opacity-0 shadow transition-opacity group-hover/item:opacity-100"
                type="button"
                @click="removeGalleryImage(block, imageIndex)"
              >
                삭제
              </button>
            </div>
          </div>
          <div class="admin-block-editor__gallery-actions mt-3 flex flex-wrap gap-2">
            <button class="admin-block-editor__gallery-select rounded border border-line bg-white px-3 py-2 text-sm font-semibold text-ink" type="button" @click="openMediaPicker(block)">
              미디어 선택
            </button>
            <label class="admin-block-editor__gallery-upload inline-flex cursor-pointer rounded bg-[#15171a] px-3 py-2 text-sm font-semibold text-white">
              {{ isUploading(block.id) ? '업로드 중' : block.images.length ? '새 이미지 추가' : '갤러리 이미지 업로드' }}
              <input class="sr-only" type="file" accept="image/*" multiple @change="handleGalleryUpload($event, block)">
            </label>
          </div>
        </figure>

        <component
          :is="getBlockTag(block)"
          v-else
          :ref="(element) => setBlockRef(element, index)"
          :class="getBlockClass(block)"
          contenteditable="true"
          spellcheck="true"
          :data-placeholder="index === 0 ? '본문을 입력하거나 / 를 눌러 블록을 선택하세요' : '/ 를 눌러 블록 선택'"
          :data-show-placeholder="shouldShowPlaceholder(block, index)"
          @focus="activateBlock(block)"
          @input="updateBlockText($event, index)"
          @keydown.enter="handleEnter($event, index)"
          @keydown.down="highlightNextCommand"
          @keydown.up="highlightPreviousCommand"
          @keydown.backspace="handleBackspace($event, index)"
        />

        <button
          v-if="block.type === 'divider'"
          class="admin-block-editor__divider-button w-full rounded py-2 text-left text-xs font-semibold text-muted"
          type="button"
          @click="activateBlock(block)"
          @keydown.enter="handleEnter($event, index)"
          @keydown.backspace="handleBackspace($event, index)"
        >
          구분선
        </button>

        <div
          v-if="visibleCommands.length && activeBlockId === block.id"
          class="admin-block-editor__slash-menu absolute left-0 z-20 w-72 overflow-hidden rounded border border-line bg-white shadow-lg"
          :class="slashMenuDirection === 'up' ? 'bottom-full mb-2' : 'top-full mt-2'"
        >
          <button
            v-for="(command, commandIndex) in visibleCommands"
            :key="`${command.type}-${command.level || 'default'}`"
            class="admin-block-editor__slash-item grid w-full gap-0.5 px-4 py-3 text-left hover:bg-surface"
            :class="commandIndex === highlightedCommandIndex ? 'bg-surface' : ''"
            type="button"
            @mousedown.prevent="applyCommand(command)"
          >
            <span class="admin-block-editor__slash-label text-sm font-semibold text-ink">{{ command.label }}</span>
            <span class="admin-block-editor__slash-description text-xs text-muted">{{ command.description }}</span>
          </button>
        </div>
      </div>
    </div>

    <div
      v-if="isMediaPickerOpen"
      class="admin-block-editor__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
      role="dialog"
      aria-modal="true"
      @click.self="closeMediaPicker"
    >
      <section class="admin-block-editor__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
        <div class="admin-block-editor__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
          <h2 class="admin-block-editor__media-picker-title text-lg font-semibold">
            미디어 선택
          </h2>
          <button class="admin-block-editor__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
            닫기
          </button>
        </div>
        <div class="admin-block-editor__media-picker-body max-h-[62vh] overflow-y-auto p-5">
          <p v-if="isLoadingMedia" class="admin-block-editor__media-picker-loading text-sm text-muted">
            미디어를 불러오는 중입니다.
          </p>
          <div v-else-if="mediaItems.length" class="admin-block-editor__media-picker-grid grid grid-cols-2 gap-3 md:grid-cols-4">
            <button
              v-for="item in mediaItems"
              :key="item.url"
              class="admin-block-editor__media-picker-item overflow-hidden border border-line bg-white text-left"
              type="button"
              @click="selectMediaItem(item)"
            >
              <img class="admin-block-editor__media-picker-image aspect-[4/3] w-full bg-surface object-cover" :src="item.url" :alt="item.title">
              <span class="admin-block-editor__media-picker-name block truncate px-3 py-2 text-xs font-semibold text-ink">{{ item.name }}</span>
            </button>
          </div>
          <p v-else class="admin-block-editor__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
            선택할 미디어가 없습니다.
          </p>
        </div>
      </section>
    </div>
  </div>
</template>

<style scoped>
.admin-block-editor__block:empty[data-show-placeholder="true"]::before {
  color: var(--site-soft);
  content: attr(data-placeholder);
}

.admin-block-editor__block {
  color: #1f2328;
}
</style>
