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

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

const editorBlocks = ref([])
const blockRefs = ref([])
const activeBlockId = ref('')
const selectedBlockId = ref('')
const draggingBlockId = ref('')
const dragTargetIndex = ref(-1)
const dragTargetPosition = 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 selectedGalleryMediaUrls = ref([])
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
const isComposingText = ref(false)
const isNormalizingTrailingBlock = ref(false)
const pendingSoftLineBreakIndex = ref(-1)
const pendingSlashCommandIndex = ref(-1)
const draggingGalleryImage = ref(null)
const galleryDragTarget = ref(null)
const isKeyboardPriorityMode = ref(false)
const calloutEmojiPickerBlockId = ref('')
const calloutColorPopoverBlockId = ref('')
const calloutEmojiComposingBlockId = ref('')
const editorFlashMessage = ref('')
const blockRangeSelection = ref(null)
const isBlockRangeDragging = ref(false)
const blockEditorRootRef = ref(null)
let blockIdSeed = 0
let editorFlashTimer = null

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

const calloutBackgroundOptions = [
  { value: 'gray', label: '회색' },
  { value: 'blue', label: '파랑' },
  { value: 'green', label: '초록' },
  { value: 'yellow', label: '노랑' },
  { value: 'red', label: '빨강' },
  { value: 'purple', label: '보라' },
  { value: 'pink', 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: 'callout',
    label: '콜아웃',
    description: '강조 안내 블록',
    keywords: ['callout', 'notice', 'info', '콜아웃', '안내']
  },
  {
    type: 'toggle',
    label: '토글',
    description: '접고 펼치는 본문 블록',
    keywords: ['toggle', 'details', '토글', '접기']
  },
  {
    type: 'embed',
    label: '임베드',
    description: 'YouTube 등 외부 링크 삽입',
    keywords: ['embed', 'youtube', 'link', '임베드', '유튜브']
  },
  {
    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', '구분선']
  }
]

const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'

/**
 * 에디터 블록 생성
 * @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 || '',
  title: options.title || '',
  width: options.width || 'regular',
  images: options.images || [],
  calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
  calloutEmoji: options.calloutEmoji || '💡',
  calloutBackground: options.calloutBackground || 'blue'
})

/**
 * 콜아웃 선언부 옵션을 파싱
 * @param {string} line - 콜아웃 선언 라인
 * @returns {{calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string}} 콜아웃 옵션
 */
const parseCalloutOptions = (line) => {
  const options = {
    calloutEmojiEnabled: true,
    calloutEmoji: '💡',
    calloutBackground: 'blue'
  }
  const tokens = line.trim().split(/\s+/).slice(1)

  tokens.forEach((token) => {
    const [rawKey, ...rawValueParts] = token.split('=')
    if (!rawKey || !rawValueParts.length) {
      return
    }
    const key = rawKey.toLowerCase()
    const value = rawValueParts.join('=').trim()

    if (key === 'emoji') {
      if (!value || value === 'none') {
        options.calloutEmojiEnabled = false
        options.calloutEmoji = '💡'
      } else {
        options.calloutEmojiEnabled = true
        options.calloutEmoji = value
      }
      return
    }

    if (key === 'bg' && calloutBackgroundOptions.some((item) => item.value === value)) {
      options.calloutBackground = value
    }
  })

  return options
}

/**
 * 이미지 마크다운 행을 블록 옵션으로 변환
 * @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 {Array<string>} lines - 전체 마크다운 행
 * @param {number} startIndex - 본문 시작 인덱스
 * @returns {{contentLines: Array<string>, nextIndex: number}} 블록 본문과 다음 인덱스
 */
const collectFencedLines = (lines, startIndex) => {
  const contentLines = []
  let index = startIndex

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

  return {
    contentLines,
    nextIndex: index + 1
  }
}

/**
 * 저장된 마크다운 문자열을 에디터 블록으로 변환
 * @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 === BLANK_PARAGRAPH_MARKER) {
      blocks.push(createEditorBlock('paragraph', '', null, `editor-block-${blocks.length}`))
      index += 1
      continue
    }

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

    if (trimmedLine === ':::gallery') {
      const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
      const images = []

      contentLines.forEach((contentLine) => {
        const image = parseImageLine(contentLine)
        if (image) {
          images.push(image)
        }
      })

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

    if (trimmedLine.startsWith(':::callout')) {
      const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
      blocks.push(createEditorBlock('callout', contentLines.join('\n'), null, `editor-block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
      index = nextIndex
      continue
    }

    if (trimmedLine.startsWith(':::toggle')) {
      const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
      const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
      blocks.push(createEditorBlock('toggle', contentLines.join('\n'), null, `editor-block-${blocks.length}`, { title }))
      index = nextIndex
      continue
    }

    if (trimmedLine === ':::embed') {
      const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
      blocks.push(createEditorBlock('embed', '', null, `editor-block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
      index = nextIndex
      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}`
}

/**
 * 주어진 블록 배열을 저장용 마크다운으로 변환한다.
 * @param {Array<Object>} blocks - 직렬화할 블록 목록
 * @returns {string} 마크다운 문자열
 */
const serializeBlockArray = (blocks) => {
  const lines = blocks
    .map((block, index) => {
      const rawText = block.text || ''
      const text = rawText.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 (block.type === 'callout') {
        const emoji = block.calloutEmojiEnabled
          ? (block.calloutEmoji || '💡')
          : 'none'
        const bg = calloutBackgroundOptions.some((item) => item.value === block.calloutBackground)
          ? block.calloutBackground
          : 'blue'
        return text
          ? { type: block.type, value: `:::callout emoji=${emoji} bg=${bg}\n${text}\n:::` }
          : null
      }

      if (block.type === 'toggle') {
        const title = block.title.trim()
        return title || text
          ? { type: block.type, value: `:::toggle ${title || '더 보기'}\n${text}\n:::` }
          : null
      }

      if (block.type === 'embed') {
        return block.url.trim()
          ? { type: block.type, value: `:::embed\n${block.url.trim()}\n:::` }
          : null
      }

      if (!text && block.type === 'paragraph') {
        if (index === blocks.length - 1) {
          return null
        }

        return { type: block.type, value: BLANK_PARAGRAPH_MARKER }
      }

      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}`
  }, '')
}

/**
 * 에디터 블록 인덱스 구간을 마크다운으로 직렬화한다.
 * @param {number} startIdx - 시작 인덱스
 * @param {number} endIdx - 끝 인덱스(포함)
 * @returns {string} 마크다운 문자열
 */
const serializeBlockIndexSlice = (startIdx, endIdx) => {
  const lo = Math.max(0, Math.min(startIdx, endIdx))
  const hi = Math.min(editorBlocks.value.length - 1, Math.max(startIdx, endIdx))

  if (lo > hi) {
    return ''
  }

  return serializeBlockArray(editorBlocks.value.slice(lo, hi + 1))
}

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

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

/**
 * 키보드 입력 우선 모드 활성화
 * @returns {void}
 */
const enableKeyboardPriorityMode = () => {
  isKeyboardPriorityMode.value = true
}

/**
 * 마우스 상호작용 시 hover 복귀
 * @param {MouseEvent} event - 마우스 이동 이벤트
 * @returns {void}
 */
const handleEditorMouseMove = (event) => {
  if (event.buttons !== 0) {
    return
  }

  isKeyboardPriorityMode.value = false
}

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

/**
 * 갤러리 열 개수 반환
 * @param {Object} block - 갤러리 블록
 * @returns {number} 열 개수
 */
const getGalleryColumnCount = (block) => Math.min(Math.max(block.images.length, 1), 3)

/**
 * 갤러리 미디어 선택 여부 확인
 * @param {Object} mediaItem - 미디어 항목
 * @returns {boolean} 선택 여부
 */
const isGalleryMediaSelected = (mediaItem) => selectedGalleryMediaUrls.value.includes(mediaItem.url)

/**
 * 비어 있는 문단 블록 여부 반환
 * @param {Object|undefined} block - 에디터 블록
 * @returns {boolean} 비어 있는 문단 블록 여부
 */
const isBlankParagraphBlock = (block) => block?.type === 'paragraph' && !block.text

/**
 * 마지막 클릭 가능 문단 블록 유지
 * @returns {void}
 */
const normalizeTrailingTextBlock = () => {
  if (isNormalizingTrailingBlock.value) {
    return
  }

  isNormalizingTrailingBlock.value = true

  if (!isBlankParagraphBlock(editorBlocks.value.at(-1))) {
    editorBlocks.value.push(createEditorBlock('paragraph'))
  }

  nextTick(() => {
    isNormalizingTrailingBlock.value = false
  })
}

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

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

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

    if (!element) {
      return
    }

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

/**
 * 현재 커서가 블록 시작/끝 경계에 있는지 확인
 * @param {Element} element - 블록 요소
 * @param {'start'|'end'} boundary - 경계 방향
 * @returns {boolean} 경계 위치 여부
 */
const isCaretOnBoundary = (element, boundary) => {
  const selection = window.getSelection()
  if (!selection?.rangeCount) {
    return false
  }

  const range = selection.getRangeAt(0)
  if (!range.collapsed || !element.contains(range.commonAncestorContainer)) {
    return false
  }

  const probeRange = range.cloneRange()
  probeRange.selectNodeContents(element)

  if (boundary === 'start') {
    probeRange.setEnd(range.startContainer, range.startOffset)
    return probeRange.toString().length === 0
  }

  probeRange.setStart(range.startContainer, range.startOffset)
  return probeRange.toString().length === 0
}

/**
 * 구조형 블록의 첫 입력 필드로 커서 이동
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const focusStructuredBlock = (index) => {
  nextTick(() => {
    const blockId = editorBlocks.value[index]?.id
    const row = document.querySelector(`[data-editor-block-id="${blockId}"]`)
    const field = row?.querySelector('input, textarea, button')

    if (field) {
      field.focus()
    }
  })
}

/**
 * 슬래시 메뉴 표시 방향 갱신
 * @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'
  })
}

/**
 * 텍스트 블록 DOM의 현재 텍스트 반환
 * @param {number} index - 블록 인덱스
 * @returns {string} 현재 텍스트
 */
const getTextBlockDomText = (index) => {
  const element = blockRefs.value[index]

  return element?.textContent || ''
}

/**
 * 현재 선택 영역에 문단 내부 줄바꿈을 삽입
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const insertSoftLineBreak = (index) => {
  const block = editorBlocks.value[index]
  const element = blockRefs.value[index]

  if (!block || !element) {
    return
  }

  element.focus()
  const selection = window.getSelection()

  if (!selection) {
    return
  }

  const range = selection?.rangeCount ? selection.getRangeAt(0) : document.createRange()

  if (!selection.rangeCount || !element.contains(range.commonAncestorContainer)) {
    range.selectNodeContents(element)
    range.collapse(false)
  }

  range.deleteContents()
  const lineBreak = document.createTextNode('\n')
  range.insertNode(lineBreak)
  range.setStartAfter(lineBreak)
  range.collapse(true)
  selection.removeAllRanges()
  selection.addRange(range)

  block.text = getTextBlockDomText(index)
  activeBlockId.value = block.id
  slashQuery.value = ''
  updateSlashMenuDirection(index)
  emitContent()
}

/**
 * 텍스트 블록 DOM 값을 상태에 즉시 반영
 * @param {number} index - 블록 인덱스
 * @returns {Object|undefined} 갱신한 블록
 */
const syncTextBlockFromDom = (index) => {
  const block = editorBlocks.value[index]

  if (!block || !isTextBlock(block)) {
    return block
  }

  block.text = getTextBlockDomText(index)
  activeBlockId.value = block.id
  updateSlashQuery(block)
  updateSlashMenuDirection(index)

  return block
}

/**
 * 에디터 상단에 잠깐 안내 문구를 표시한다.
 * @param {string} message - 표시할 문구
 * @returns {void}
 */
const flashEditorMessage = (message) => {
  editorFlashMessage.value = message

  if (import.meta.client && editorFlashTimer) {
    window.clearTimeout(editorFlashTimer)
  }

  if (import.meta.client) {
    editorFlashTimer = window.setTimeout(() => {
      editorFlashMessage.value = ''
      editorFlashTimer = null
    }, 2600)
  }
}

/**
 * 클립보드 텍스트를 여러 블록으로 나누어 붙일지 판별한다.
 * @param {string} text - 순수 텍스트
 * @param {FileList|undefined} files - 첨부 파일
 * @returns {boolean} 구조화 붙여넣기 여부
 */
const shouldParseClipboardAsBlocks = (text, files) => {
  if (files && files.length > 0) {
    return false
  }

  if (!text || !String(text).trim()) {
    return false
  }

  if (text.includes('\n')) {
    return true
  }

  const head = text.trimStart().slice(0, 120)

  if (/^#{1,3}\s/m.test(head) || /^>\s/m.test(head) || /^- /m.test(head) || /^```/m.test(head) || /^:::+/m.test(head)) {
    return true
  }

  if (/\[[^\]]+\]\([^)]+\)/.test(head)) {
    return true
  }

  return false
}

/**
 * contenteditable 요소 안에서 선택 구간의 순수 텍스트 기준 오프셋을 계산한다.
 * @param {HTMLElement} element - 편집 루트 요소
 * @returns {{ start: number, end: number }} 시작·끝 오프셋
 */
const getPlainTextOffsetsWithinElement = (element) => {
  const selection = window.getSelection()

  if (!selection?.rangeCount || !element.contains(selection.anchorNode)) {
    const len = element.textContent?.length || 0

    return { start: len, end: len }
  }

  const range = selection.getRangeAt(0)
  const startRange = document.createRange()

  startRange.selectNodeContents(element)
  startRange.setEnd(range.startContainer, range.startOffset)
  const start = startRange.toString().length
  const endRange = document.createRange()

  endRange.selectNodeContents(element)
  endRange.setEnd(range.endContainer, range.endOffset)

  return {
    start,
    end: endRange.toString().length
  }
}

/**
 * 붙여넣기용 블록 목록에 새 id를 부여한다.
 * @param {Array<Object>} blocks - 파싱된 블록
 * @returns {Array<Object>} id가 갱신된 블록
 */
const cloneBlocksWithNewIds = (blocks) => blocks.map((block) => ({
  ...block,
  id: `editor-block-${(blockIdSeed += 1)}`
}))

/**
 * 텍스트를 앞뒤 잘라낸 뒤 붙인 블록으로 현재 블록을 대체한다.
 * @param {number} index - 대상 블록 인덱스
 * @param {string} clipboardText - 클립보드 순수 텍스트
 * @param {string} before - 커서 앞에 남길 텍스트
 * @param {string} after - 커서 뒤에 남길 텍스트
 * @returns {void}
 */
const applyStructuredPaste = (index, clipboardText, before, after) => {
  let inserted = parseMarkdownToBlocks(clipboardText)

  inserted = cloneBlocksWithNewIds(inserted)

  if (!inserted.length) {
    return
  }

  const mergeableTypes = ['paragraph', 'heading', 'quote', 'list', 'code', 'callout']
  const first = inserted[0]

  if (before) {
    if (mergeableTypes.includes(first.type)) {
      first.text = `${before}${first.text || ''}`
    } else {
      inserted = [createEditorBlock('paragraph', before), ...inserted]
    }
  }

  const last = inserted[inserted.length - 1]

  if (after) {
    if (mergeableTypes.includes(last.type)) {
      last.text = `${last.text || ''}${after}`
    } else {
      inserted = [...inserted, createEditorBlock('paragraph', after)]
    }
  }

  clearBlockRangeSelection()
  editorBlocks.value.splice(index, 1, ...inserted)
  normalizeTrailingTextBlock()
  emitContent()
  nextTick(() => focusBlock(index + inserted.length - 1, 'end'))
}

/**
 * 여러 줄·마크다운 붙여넣기를 블록 단위로 반영한다.
 * @param {ClipboardEvent} event - 붙여넣기 이벤트
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const handleTextBlockPaste = (event, index) => {
  const block = editorBlocks.value[index]

  if (!block || !isTextBlock(block) || isComposingText.value) {
    return
  }

  const data = event.clipboardData

  if (!data || data.files?.length) {
    return
  }

  const clip = data.getData('text/plain') || ''

  if (!shouldParseClipboardAsBlocks(clip, data.files)) {
    return
  }

  event.preventDefault()
  const element = blockRefs.value[index]

  if (!element) {
    return
  }

  const { start, end } = getPlainTextOffsetsWithinElement(element)
  const domText = getTextBlockDomText(index)
  const before = domText.slice(0, start)
  const after = domText.slice(end)

  applyStructuredPaste(index, clip, before, after)
}

/**
 * 블록마다 분리된 contenteditable에서는 문서 전체 선택이 불가하므로, Cmd/Ctrl+A 시 마크다운을 클립보드에 담는다. 블록 범위 선택 중이면 해당 구간만 복사한다.
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @param {number} index - 포커스 블록 인덱스
 * @returns {Promise<void>}
 */
const handleTextBlockSelectAll = async (event, index) => {
  if (!(event.metaKey || event.ctrlKey) || event.shiftKey || String(event.key).toLowerCase() !== 'a') {
    return
  }

  event.preventDefault()
  const rangeSel = blockRangeSelection.value
  const md = rangeSel
    ? serializeBlockIndexSlice(rangeSel.anchor, rangeSel.focus)
    : serializeBlocks()

  try {
    if (navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(md)

      if (rangeSel) {
        flashEditorMessage('선택한 블록 구간(마크다운)을 클립보드에 복사했습니다.')
      } else {
        flashEditorMessage('전체 본문(마크다운)을 클립보드에 복사했습니다. 다른 편집기에 Cmd+V로 붙여넣을 수 있습니다.')
      }
    } else {
      flashEditorMessage('이 브라우저에서는 클립보드 API를 사용할 수 없습니다.')
    }
  } catch {
    flashEditorMessage('클립보드 복사에 실패했습니다. 주소가 https인지·사이트 권한을 확인해 주세요.')
  }

  nextTick(() => focusBlock(index, 'end'))
}

/**
 * 텍스트 블록 공통 키다운(전체 복사 단축키 등)
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @param {number} index - 블록 인덱스
 * @returns {Promise<void>}
 */
const handleTextBlockKeydownRoot = async (event, index) => {
  await handleTextBlockSelectAll(event, index)
}

/**
 * 블록 타입에 맞는 태그명 반환
 * @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 whitespace-pre-wrap text-[17px] leading-8': block.type === 'paragraph',
    'admin-block-editor__heading 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 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 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) => {
  enableKeyboardPriorityMode()

  const block = editorBlocks.value[index]
  const text = getTextBlockDomText(index)

  block.text = text
  activeBlockId.value = block.id

  if (isComposingText.value || event.isComposing) {
    return
  }

  applyMarkdownShortcut(block, index)
  updateSlashQuery(block)
  updateSlashMenuDirection(index)
  emitContent()
}

/**
 * 한글 등 조합형 텍스트 입력 시작 처리
 * @returns {void}
 */
const startTextComposition = () => {
  isComposingText.value = true
}

/**
 * 한글 등 조합형 텍스트 입력 종료 처리
 * @param {CompositionEvent} event - 조합 종료 이벤트
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const finishTextComposition = (event, index) => {
  isComposingText.value = false

  const syncAfterComposition = () => {
    const block = syncTextBlockFromDom(index)

    if (!block) {
      pendingSoftLineBreakIndex.value = -1
      pendingSlashCommandIndex.value = -1
      return
    }

    applyMarkdownShortcut(block, index)

    const trimmedText = (block.text || '').trim()
    const canApplyPendingSlashCommand = pendingSlashCommandIndex.value === index
      && block.text.startsWith('/')
      && visibleCommands.value.length > 0
      && trimmedText !== '/'
      && trimmedText !== '//'

    if (canApplyPendingSlashCommand) {
      pendingSlashCommandIndex.value = -1
      const command = highlightedCommand.value || visibleCommands.value[0]

      if (command) {
        applyCommand(command)
        return
      }
    }

    if (pendingSoftLineBreakIndex.value === index && isTextBlock(block) && block.type !== 'code') {
      pendingSoftLineBreakIndex.value = -1
      pendingSlashCommandIndex.value = -1
      insertSoftLineBreak(index)
      return
    }

    pendingSoftLineBreakIndex.value = -1
    pendingSlashCommandIndex.value = -1
    emitContent()
  }

  nextTick(() => {
    syncAfterComposition()
    window.setTimeout(syncAfterComposition, 0)
  })
}

/**
 * 마크다운 입력 단축 문법 적용
 * @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', exact: true },
    { marker: '``` ', type: 'code' }
  ].sort((a, b) => b.marker.length - a.marker.length)

  const shortcut = shortcutMap.find((item) => item.exact
    ? block.text === item.marker
    : 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.textContent = block.text
      focusBlock(index)
    }
  })
}

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

  if (hasQueryChanged) {
    highlightedCommandIndex.value = 0
    return
  }

  if (!visibleCommands.value.length) {
    highlightedCommandIndex.value = 0
    return
  }

  if (highlightedCommandIndex.value >= visibleCommands.value.length) {
    highlightedCommandIndex.value = visibleCommands.value.length - 1
  }
}

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])
const activeCalloutBlock = computed(() => {
  const activeBlock = editorBlocks.value.find((block) => block.id === activeBlockId.value)

  if (activeBlock?.type === 'callout') {
    return activeBlock
  }

  const selectedBlock = editorBlocks.value.find((block) => block.id === selectedBlockId.value)

  if (selectedBlock?.type === 'callout') {
    return selectedBlock
  }

  return null
})

/**
 * 슬래시 메뉴 명령 적용
 * @param {Object} command - 블록 명령
 * @returns {void}
 */
const applyCommand = (command) => {
  clearBlockRangeSelection()
  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.title = ''
  block.width = 'regular'
  block.images = []
  const element = blockRefs.value[index]

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

  slashQuery.value = ''

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

  normalizeTrailingTextBlock()
  emitContent()

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

  if (['toggle', 'embed'].includes(block.type)) {
    focusStructuredBlock(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
  }
  selectedGalleryMediaUrls.value = block.type === 'gallery'
    ? block.images.map((image) => image.url).filter(Boolean)
    : []
  isMediaPickerOpen.value = true
  await fetchMediaItems()
}

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

/**
 * 갤러리 미디어 선택 상태 전환
 * @param {Object} mediaItem - 미디어 항목
 * @returns {void}
 */
const toggleGalleryMediaSelection = (mediaItem) => {
  if (selectedGalleryMediaUrls.value.includes(mediaItem.url)) {
    selectedGalleryMediaUrls.value = selectedGalleryMediaUrls.value.filter((url) => url !== mediaItem.url)
    return
  }

  selectedGalleryMediaUrls.value = [...selectedGalleryMediaUrls.value, mediaItem.url]
}

/**
 * 갤러리 미디어 선택을 블록에 적용
 * @returns {void}
 */
const applyGalleryMediaSelection = () => {
  const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId)

  if (!block || mediaPickerTarget.value?.type !== 'gallery') {
    return
  }

  const existingImages = new Map(block.images.map((image) => [image.url, image]))
  block.images = selectedGalleryMediaUrls.value.map((url) => existingImages.get(url) || {
    url,
    alt: '',
    width: 'regular'
  })

  normalizeTrailingTextBlock()
  emitContent()
  closeMediaPicker()
}

/**
 * 선택한 미디어를 블록에 적용
 * @param {Object} mediaItem - 미디어 항목
 * @returns {void}
 */
const selectMediaItem = (mediaItem) => {
  if (mediaPickerTarget.value?.type === 'gallery') {
    toggleGalleryMediaSelection(mediaItem)
    return
  }

  const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId)

  if (!block) {
    return
  }

  block.url = mediaItem.url
  block.alt = ''
  normalizeTrailingTextBlock()
  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 = ''
      normalizeTrailingTextBlock()
      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: '',
        width: 'regular'
      }))
    ]
    normalizeTrailingTextBlock()
    emitContent()
  } finally {
    event.target.value = ''
    setUploading(block.id, false)
  }
}

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

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

/**
 * 갤러리 이미지 드래그 시작
 * @param {DragEvent} event - 드래그 이벤트
 * @param {Object} block - 갤러리 블록
 * @param {number} imageIndex - 이미지 인덱스
 * @returns {void}
 */
const startGalleryImageDrag = (event, block, imageIndex) => {
  draggingGalleryImage.value = {
    blockId: block.id,
    imageIndex
  }
  event.dataTransfer.effectAllowed = 'move'
  event.dataTransfer.setData('text/plain', `${block.id}:${imageIndex}`)
}

/**
 * 갤러리 이미지 드래그 종료
 * @returns {void}
 */
const finishGalleryImageDrag = () => {
  draggingGalleryImage.value = null
  galleryDragTarget.value = null
}

/**
 * 갤러리 이미지 삽입 위치 표시
 * @param {DragEvent} event - 드래그 이벤트
 * @param {Object} block - 갤러리 블록
 * @param {number} imageIndex - 이미지 인덱스
 * @returns {void}
 */
const updateGalleryImageDropTarget = (event, block, imageIndex) => {
  const source = draggingGalleryImage.value

  if (!source || source.blockId !== block.id) {
    return
  }

  const rect = event.currentTarget.getBoundingClientRect()
  galleryDragTarget.value = {
    blockId: block.id,
    imageIndex,
    position: event.clientX < rect.left + rect.width / 2 ? 'before' : 'after'
  }
}

/**
 * 갤러리 이미지 순서 변경
 * @param {DragEvent} event - 드롭 이벤트
 * @param {Object} block - 갤러리 블록
 * @param {number} targetIndex - 대상 인덱스
 * @returns {void}
 */
const dropGalleryImage = (event, block, targetIndex) => {
  const source = draggingGalleryImage.value
  const target = galleryDragTarget.value

  if (!source || source.blockId !== block.id || source.imageIndex === targetIndex) {
    return
  }

  let nextTargetIndex = target?.blockId === block.id && target.position === 'after'
    ? targetIndex + 1
    : targetIndex

  if (source.imageIndex < nextTargetIndex) {
    nextTargetIndex -= 1
  }

  if (source.imageIndex === nextTargetIndex) {
    finishGalleryImageDrag()
    return
  }

  const [image] = block.images.splice(source.imageIndex, 1)
  block.images.splice(nextTargetIndex, 0, image)
  finishGalleryImageDrag()
  normalizeTrailingTextBlock()
  emitContent()
}

/**
 * 슬래시 메뉴 선택을 아래로 이동
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @returns {void}
 */
const highlightNextCommand = (event) => {
  const block = editorBlocks.value[activeBlockIndex.value]

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

  syncTextBlockFromDom(activeBlockIndex.value)

  if (!visibleCommands.value.length) {
    return
  }

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

/**
 * 슬래시 메뉴 선택을 위로 이동
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @returns {void}
 */
const highlightPreviousCommand = (event) => {
  const block = editorBlocks.value[activeBlockIndex.value]

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

  syncTextBlockFromDom(activeBlockIndex.value)

  if (!visibleCommands.value.length) {
    return
  }

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

/**
 * 현재 하이라이트된 슬래시 메뉴 항목을 스크롤 영역에 맞춘다.
 * @returns {void}
 */
const scrollHighlightedCommandIntoView = () => {
  nextTick(() => {
    if (!activeBlockId.value || !visibleCommands.value.length) {
      return
    }

    const row = document.querySelector(`[data-editor-block-id="${activeBlockId.value}"]`)
    const menu = row?.querySelector('.admin-block-editor__slash-menu')
    const highlightedItem = row?.querySelector('.admin-block-editor__slash-item--active')

    if (!menu || !highlightedItem) {
      return
    }

    highlightedItem.scrollIntoView({
      block: 'nearest'
    })
  })
}

/**
 * 블록 단위 범위 선택을 해제한다.
 * @returns {void}
 */
const clearBlockRangeSelection = () => {
  blockRangeSelection.value = null
  isBlockRangeDragging.value = false
}

/**
 * 블록 인덱스가 범위 선택에 포함되는지 여부
 * @param {number} index - 블록 인덱스
 * @returns {boolean} 포함 여부
 */
const isBlockRangeRowSelected = (index) => {
  const sel = blockRangeSelection.value

  if (!sel) {
    return false
  }

  const lo = Math.min(sel.anchor, sel.focus)
  const hi = Math.max(sel.anchor, sel.focus)

  return index >= lo && index <= hi
}

/**
 * 포인터 좌표에 해당하는 행 블록 인덱스를 찾는다.
 * elementFromPoint가 행 밖 여백(블록 간 margin 등)을 가리키면 세로 거리로 가장 가까운 행을 고른다.
 * @param {number} clientX - 뷰포트 X
 * @param {number} clientY - 뷰포트 Y
 * @returns {number} 인덱스, 없으면 -1
 */
const resolveBlockIndexFromPointer = (clientX, clientY) => {
  const root = blockEditorRootRef.value

  if (!root || typeof document === 'undefined') {
    return -1
  }

  const topEl = document.elementFromPoint(clientX, clientY)
  const directRow = topEl?.closest?.('[data-editor-block-id]')

  if (directRow && root.contains(directRow)) {
    const id = directRow.getAttribute('data-editor-block-id')
    const idx = editorBlocks.value.findIndex((b) => b.id === id)

    if (idx >= 0) {
      return idx
    }
  }

  const idToIndex = new Map(editorBlocks.value.map((b, i) => [b.id, i]))
  let bestIdx = -1
  let bestDelta = Infinity

  root.querySelectorAll('[data-editor-block-id]').forEach((row) => {
    const id = row.getAttribute('data-editor-block-id')
    const i = id == null ? -1 : idToIndex.get(id)

    if (i === undefined || i < 0) {
      return
    }

    const r = row.getBoundingClientRect()
    let delta = 0

    if (clientY < r.top) {
      delta = r.top - clientY
    } else if (clientY > r.bottom) {
      delta = clientY - r.bottom
    }

    if (delta < bestDelta) {
      bestDelta = delta
      bestIdx = i
    }
  })

  const snapPx = 72

  if (bestIdx >= 0 && bestDelta <= snapPx) {
    return bestIdx
  }

  return -1
}

/**
 * 범위 선택 레인에서 포인터로 블록 범위 드래그를 시작한다.
 * @param {PointerEvent} event - 포인터 이벤트
 * @param {number} index - 블록 인덱스
 * @returns {void}
 */
const onBlockRangeLanePointerDown = (event, index) => {
  if (event.button !== 0) {
    return
  }

  event.preventDefault()
  event.stopPropagation()

  if (event.shiftKey) {
    if (blockRangeSelection.value) {
      blockRangeSelection.value = {
        anchor: blockRangeSelection.value.anchor,
        focus: index
      }
    } else {
      blockRangeSelection.value = { anchor: index, focus: index }
    }

    return
  }

  const anchorIndex = index

  isBlockRangeDragging.value = true
  blockRangeSelection.value = { anchor: anchorIndex, focus: anchorIndex }

  /**
   * 드래그 중 포인터 위치의 블록으로 범위 끝을 갱신한다.
   * @param {PointerEvent} ev - 포인터 이벤트
   * @returns {void}
   */
  const onMove = (ev) => {
    if (!isBlockRangeDragging.value) {
      return
    }

    const idx = resolveBlockIndexFromPointer(ev.clientX, ev.clientY)

    if (idx < 0) {
      return
    }

    blockRangeSelection.value = {
      anchor: anchorIndex,
      focus: idx
    }
  }

  /**
   * 드래그 종료 시 문서 리스너를 제거한다.
   * @returns {void}
   */
  const onUp = () => {
    isBlockRangeDragging.value = false
    document.removeEventListener('pointermove', onMove)
    document.removeEventListener('pointerup', onUp)
    document.removeEventListener('pointercancel', onUp)
  }

  document.addEventListener('pointermove', onMove, { passive: true })
  document.addEventListener('pointerup', onUp, { passive: true })
  document.addEventListener('pointercancel', onUp, { passive: true })
}

/**
 * 블록 범위 선택이 있어도 브라우저 기본 복사를 우선해야 하는지 판별한다.
 * contenteditable 안의 비접힘 선택 또는 textarea/input의 선택 구간이 있으면 true.
 * @returns {boolean} 기본 복사를 그대로 두면 true
 */
const shouldDeferBlockRangeCopyToNative = () => {
  if (typeof document === 'undefined') {
    return false
  }

  const activeEl = document.activeElement

  if (activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLInputElement) {
    const { selectionStart, selectionEnd } = activeEl

    if (selectionStart != null && selectionEnd != null && selectionStart !== selectionEnd) {
      return true
    }
  }

  if (typeof window === 'undefined') {
    return false
  }

  const sel = window.getSelection()

  if (!sel || sel.isCollapsed || !sel.rangeCount) {
    return false
  }

  const range = sel.getRangeAt(0)
  let ancestor = range.commonAncestorContainer

  if (ancestor.nodeType === Node.TEXT_NODE) {
    ancestor = ancestor.parentElement
  }

  if (!ancestor || typeof ancestor.closest !== 'function') {
    return false
  }

  const host = ancestor.closest('[contenteditable="true"]')

  if (!host) {
    return false
  }

  return blockRefs.value.some((el) => el === host)
}

/**
 * 에디터 루트에서 범위 선택이 있을 때 복사 시 마크다운만 클립보드에 넣는다.
 * @param {ClipboardEvent} event - 복사 이벤트
 * @returns {void}
 */
const handleEditorRootCopy = (event) => {
  if (!blockRangeSelection.value) {
    return
  }

  if (shouldDeferBlockRangeCopyToNative()) {
    return
  }

  const { anchor, focus } = blockRangeSelection.value
  const md = serializeBlockIndexSlice(anchor, focus)

  if (!md) {
    return
  }

  event.preventDefault()
  event.clipboardData?.setData('text/plain', md)
}

/**
 * 에디터 루트 키다운(Escape로 범위 해제 등)
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @returns {void}
 */
const handleEditorRootKeydown = (event) => {
  if (event.key === 'Escape' && blockRangeSelection.value) {
    event.preventDefault()
    clearBlockRangeSelection()
  }
}

/**
 * 일반 본문 블록 방향키 이동 처리
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @param {number} index - 현재 블록 인덱스
 * @param {'up'|'down'} direction - 이동 방향
 * @returns {void}
 */
const navigateAcrossBlocks = (event, index, direction) => {
  const currentBlock = editorBlocks.value[index]

  if (!currentBlock || currentBlock.text.startsWith('/')) {
    return
  }

  const currentElement = blockRefs.value[index]

  if (!currentElement) {
    return
  }

  if (event.shiftKey && blockRangeSelection.value) {
    event.preventDefault()
    const { anchor, focus } = blockRangeSelection.value
    const nextFocus = direction === 'down'
      ? Math.min(editorBlocks.value.length - 1, focus + 1)
      : Math.max(0, focus - 1)

    blockRangeSelection.value = { anchor, focus: nextFocus }
    return
  }

  const isBoundary = direction === 'up'
    ? isCaretOnBoundary(currentElement, 'start')
    : isCaretOnBoundary(currentElement, 'end')

  if (!isBoundary) {
    return
  }

  const nextIndex = direction === 'up' ? index - 1 : index + 1

  if (nextIndex < 0 || nextIndex >= editorBlocks.value.length) {
    return
  }

  if (event.shiftKey) {
    event.preventDefault()
    blockRangeSelection.value = { anchor: index, focus: nextIndex }
    return
  }

  clearBlockRangeSelection()
  event.preventDefault()
  const targetBlock = editorBlocks.value[nextIndex]

  if (isTextBlock(targetBlock)) {
    focusBlock(nextIndex, direction === 'up' ? 'end' : 'start')
    return
  }

  focusStructuredBlock(nextIndex)
}

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

  const currentBlock = syncTextBlockFromDom(index)

  if (isComposingText.value || event.isComposing || event.keyCode === 229) {
    event.preventDefault()

    if (event.shiftKey && isTextBlock(currentBlock) && currentBlock.type !== 'code') {
      pendingSoftLineBreakIndex.value = index
    }

    if (!event.shiftKey && (currentBlock.text || '').startsWith('/')) {
      pendingSlashCommandIndex.value = index
    } else {
      pendingSlashCommandIndex.value = -1
    }

    return
  }

  if (currentBlock.type === 'code') {
    return
  }

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

  const trimmedText = (currentBlock.text || '').trim()
  const canApplySlashCommand = currentBlock.text.startsWith('/')
    && visibleCommands.value.length > 0
    && trimmedText !== '/'
    && trimmedText !== '//'

  if (canApplySlashCommand) {
    event.preventDefault()
    const command = highlightedCommand.value || visibleCommands.value[0]

    if (command) {
      applyCommand(command)
    }

    return
  }

  event.preventDefault()

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

  if (!currentBlock.text.trim() && !['paragraph', 'callout'].includes(currentBlock.type)) {
    currentBlock.type = 'paragraph'
    currentBlock.level = null
    normalizeTrailingTextBlock()
    emitContent()
    focusBlock(index)
    return
  }

  const nextType = currentBlock.type === 'list' ? 'list' : 'paragraph'
  editorBlocks.value.splice(index + 1, 0, createEditorBlock(nextType))
  normalizeTrailingTextBlock()
  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 || block.title || block.text)) {
    return
  }

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

/**
 * 블록 인덱스 반환
 * @param {string} blockId - 블록 ID
 * @returns {number} 블록 인덱스
 */
const getBlockIndex = (blockId) => editorBlocks.value.findIndex((block) => block.id === blockId)

/**
 * 포커스 이탈 시 자동 정리할 구조형 블록인지 확인
 * @param {Object|undefined} block - 에디터 블록
 * @returns {boolean} 자동 정리 대상 여부
 */
const isDiscardableStructuredBlock = (block) => {
  if (!block) {
    return false
  }

  if (block.type === 'image') {
    return !block.url
  }

  if (block.type === 'gallery') {
    return !block.images.length
  }

  return false
}

/**
 * 다른 블록으로 이동할 때 미사용 구조형 블록을 정리
 * @param {string} nextBlockId - 다음 활성 블록 ID
 * @returns {void}
 */
const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => {
  const previousActiveBlockId = activeBlockId.value

  if (!previousActiveBlockId || previousActiveBlockId === nextBlockId) {
    return
  }

  const previousBlockIndex = getBlockIndex(previousActiveBlockId)
  const previousBlock = editorBlocks.value[previousBlockIndex]

  if (!isDiscardableStructuredBlock(previousBlock) || editorBlocks.value.length <= 1) {
    return
  }

  clearBlockRangeSelection()
  editorBlocks.value.splice(previousBlockIndex, 1)
  normalizeTrailingTextBlock()
  emitContent()
}

/**
 * 블록 선택 상태 적용
 * @param {Object} block - 선택할 블록
 * @returns {void}
 */
const selectBlock = (block) => {
  clearBlockRangeSelection()
  selectedBlockId.value = block.id
  activeBlockId.value = block.id
  slashQuery.value = ''
}

/**
 * 지정 블록 삭제
 * @param {number} index - 삭제할 블록 인덱스
 * @returns {void}
 */
const deleteBlock = (index) => {
  if (index < 0) {
    return
  }

  if (editorBlocks.value.length <= 1) {
    editorBlocks.value.splice(0, 1, createEditorBlock())
    selectedBlockId.value = ''
    activeBlockId.value = editorBlocks.value[0].id
    clearBlockRangeSelection()
    emitContent()
    focusBlock(0)
    return
  }

  editorBlocks.value.splice(index, 1)
  selectedBlockId.value = ''
  clearBlockRangeSelection()
  normalizeTrailingTextBlock()
  emitContent()
  focusBlock(Math.min(index, editorBlocks.value.length - 1))
}

/**
 * 선택한 블록 삭제 키 처리
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @param {number} index - 삭제할 블록 인덱스
 * @returns {void}
 */
const deleteSelectedBlock = (event, index) => {
  event.preventDefault()
  deleteBlock(index)
}

/**
 * 블록 드래그 시작 처리
 * @param {DragEvent} event - 드래그 이벤트
 * @param {Object} block - 드래그할 블록
 * @returns {void}
 */
const startBlockDrag = (event, block) => {
  clearBlockRangeSelection()
  draggingBlockId.value = block.id
  selectedBlockId.value = block.id
  dragTargetIndex.value = -1
  dragTargetPosition.value = ''
  event.dataTransfer.effectAllowed = 'move'
  event.dataTransfer.setData('text/plain', block.id)
}

/**
 * 블록 드래그 중 드롭 위치 표시 갱신
 * @param {DragEvent} event - 드래그 이벤트
 * @param {number} targetIndex - 드래그 중인 대상 인덱스
 * @returns {void}
 */
const updateBlockDropTarget = (event, targetIndex) => {
  if (!draggingBlockId.value) {
    return
  }

  event.preventDefault()
  event.dataTransfer.dropEffect = 'move'
  const rect = event.currentTarget.getBoundingClientRect()
  dragTargetIndex.value = targetIndex
  dragTargetPosition.value = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}

/**
 * 블록 드래그 상태 초기화
 * @returns {void}
 */
const clearBlockDragState = () => {
  draggingBlockId.value = ''
  dragTargetIndex.value = -1
  dragTargetPosition.value = ''
}

/**
 * 현재 드롭 위치 기준으로 블록 이동
 * @param {string} draggedId - 이동할 블록 ID
 * @param {number} targetIndex - 이동 대상 인덱스
 * @param {string} targetPosition - 이동 위치
 * @returns {boolean} 이동 여부
 */
const moveDraggedBlock = (draggedId, targetIndex, targetPosition) => {
  const sourceIndex = getBlockIndex(draggedId)
  const insertionIndex = targetPosition === 'after' ? targetIndex + 1 : targetIndex

  if (sourceIndex < 0 || targetIndex < 0) {
    return false
  }

  const nextTargetIndex = sourceIndex < insertionIndex ? insertionIndex - 1 : insertionIndex

  if (sourceIndex === nextTargetIndex) {
    return false
  }

  clearBlockRangeSelection()
  const [draggedBlock] = editorBlocks.value.splice(sourceIndex, 1)
  editorBlocks.value.splice(nextTargetIndex, 0, draggedBlock)
  selectedBlockId.value = draggedBlock.id
  normalizeTrailingTextBlock()
  emitContent()

  return true
}

/**
 * 블록 드롭 이동 처리
 * @param {DragEvent} event - 드롭 이벤트
 * @param {number} targetIndex - 이동 대상 인덱스
 * @returns {void}
 */
const dropBlock = (event, targetIndex) => {
  event.preventDefault()
  const draggedId = event.dataTransfer.getData('text/plain') || draggingBlockId.value
  const targetPosition = dragTargetIndex.value === targetIndex ? dragTargetPosition.value : 'after'

  moveDraggedBlock(draggedId, targetIndex, targetPosition)
  clearBlockDragState()
}

/**
 * 블록 드래그 종료 처리
 * @returns {void}
 */
const finishBlockDrag = () => {
  if (draggingBlockId.value && dragTargetIndex.value >= 0 && dragTargetPosition.value) {
    moveDraggedBlock(draggingBlockId.value, dragTargetIndex.value, dragTargetPosition.value)
  }

  clearBlockDragState()
}

/**
 * 현재 블록 활성화
 * @param {Object} block - 에디터 블록
 * @returns {void}
 */
const activateBlock = (block) => {
  cleanupUnusedStructuredBlockOnActivate(block.id)

  const index = editorBlocks.value.findIndex((item) => item.id === block.id)
  activeBlockId.value = block.id
  selectedBlockId.value = ''
  clearBlockRangeSelection()
  updateSlashQuery(block)
  updateSlashMenuDirection(index)
}

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

/**
 * 텍스트 필드 변경 내용을 저장용 콘텐츠에 반영
 * @returns {void}
 */
const updateStructuredBlock = () => {
  normalizeTrailingTextBlock()
  emitContent()
}

/**
 * 콜아웃 이모지 표시 상태를 전환
 * @param {Object} block - 콜아웃 블록
 * @returns {void}
 */
const toggleCalloutEmoji = (block) => {
  block.calloutEmojiEnabled = !block.calloutEmojiEnabled
  if (!block.calloutEmojiEnabled) {
    calloutEmojiPickerBlockId.value = ''
  }
  updateStructuredBlock()
}

/**
 * 콜아웃 이모지를 변경
 * @param {Object} block - 콜아웃 블록
 * @param {string} emoji - 이모지 문자열
 * @returns {void}
 */
const updateCalloutEmoji = (block, emoji) => {
  block.calloutEmoji = emoji || ''
  block.calloutEmojiEnabled = true
  updateStructuredBlock()
}

/**
 * 콜아웃 배경 프리셋을 변경
 * @param {Object} block - 콜아웃 블록
 * @param {string} background - 배경 프리셋 값
 * @returns {void}
 */
const updateCalloutBackground = (block, background) => {
  block.calloutBackground = background
  calloutColorPopoverBlockId.value = ''
  updateStructuredBlock()
}

/**
 * 콜아웃 이모지 팝업 토글
 * @param {Object} block - 콜아웃 블록
 * @returns {void}
 */
const toggleCalloutEmojiPicker = (block) => {
  calloutEmojiPickerBlockId.value = calloutEmojiPickerBlockId.value === block.id
    ? ''
    : block.id
}

/**
 * 콜아웃 배경색 팝오버 토글
 * @param {Object} block - 콜아웃 블록
 * @returns {void}
 */
const toggleCalloutColorPopover = (block) => {
  calloutColorPopoverBlockId.value = calloutColorPopoverBlockId.value === block.id
    ? ''
    : block.id
}

/**
 * 콜아웃 이모지 입력값 적용
 * @param {Object} block - 콜아웃 블록
 * @param {Event} event - 입력 이벤트
 * @returns {void}
 */
const updateCalloutEmojiFromInput = (block, event) => {
  if (calloutEmojiComposingBlockId.value === block.id || event?.isComposing) {
    return
  }

  const rawValue = String(event.target?.value || event.target?.textContent || '')
  block.calloutEmoji = rawValue
  block.calloutEmojiEnabled = true
  updateStructuredBlock()
}

/**
 * 콜아웃 이모지 입력 조합 시작 처리
 * @param {Object} block - 콜아웃 블록
 * @returns {void}
 */
const startCalloutEmojiComposition = (block) => {
  calloutEmojiComposingBlockId.value = block.id
}

/**
 * 콜아웃 이모지 입력 조합 종료 처리
 * @param {Object} block - 콜아웃 블록
 * @param {CompositionEvent} event - 조합 종료 이벤트
 * @returns {void}
 */
const finishCalloutEmojiComposition = (block, event) => {
  calloutEmojiComposingBlockId.value = ''
  updateCalloutEmojiFromInput(block, event)
}

/**
 * 콜아웃 이모지 입력을 1문자 슬롯으로 정규화
 * @param {Object} block - 콜아웃 블록
 * @param {FocusEvent} event - blur 이벤트
 * @returns {void}
 */
const normalizeCalloutEmojiInput = (block, event) => {
  const rawValue = String(block.calloutEmoji || '').trim()
  const graphemes = typeof Intl !== 'undefined' && Intl.Segmenter
    ? Array.from(new Intl.Segmenter('ko', { granularity: 'grapheme' }).segment(rawValue), (segment) => segment.segment)
    : Array.from(rawValue)
  const nextValue = graphemes.length ? graphemes[graphemes.length - 1] : ''

  block.calloutEmoji = nextValue
  updateStructuredBlock()

  if (event?.target && 'value' in event.target) {
    event.target.value = nextValue
  }
}

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

  const currentValue = serializeBlocks()

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

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

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

watch(
  [highlightedCommandIndex, () => visibleCommands.value.length, activeBlockId],
  () => {
    scrollHighlightedCommandIntoView()
  }
)

watch(activeCalloutBlock, (nextBlock) => {
  if (!nextBlock) {
    calloutEmojiPickerBlockId.value = ''
    calloutColorPopoverBlockId.value = ''
  }
})

onBeforeUnmount(() => {
  if (import.meta.client && editorFlashTimer) {
    window.clearTimeout(editorFlashTimer)
    editorFlashTimer = null
  }
})

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

<template>
  <div
    ref="blockEditorRootRef"
    class="admin-block-editor bg-transparent py-4 text-ink"
    :class="{ 'admin-block-editor--keyboard-priority': isKeyboardPriorityMode }"
    @mousemove="handleEditorMouseMove"
    @keydown="handleEditorRootKeydown"
    @copy.capture="handleEditorRootCopy"
  >
    <p
      v-if="editorFlashMessage"
      class="admin-block-editor__flash-message mb-3 rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-900"
      role="status"
    >
      {{ editorFlashMessage }}
    </p>
    <div class="admin-block-editor__surface post-prose">
      <div
        v-for="(block, index) in editorBlocks"
        :key="block.id"
        class="admin-block-editor__row group/block relative isolate rounded"
        :class="{
          'admin-block-editor__row--selected': selectedBlockId === block.id,
          'admin-block-editor__row--range-selected': isBlockRangeRowSelected(index),
          'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id,
          'admin-block-editor__row--drop-before': dragTargetIndex === index && dragTargetPosition === 'before',
          'admin-block-editor__row--drop-after': dragTargetIndex === index && dragTargetPosition === 'after',
          'admin-block-editor__row--callout': block.type === 'callout',
          'admin-block-editor__row--menu-open z-30': visibleCommands.length && activeBlockId === block.id,
          'admin-block-editor__row--text': isTextBlock(block),
          'admin-block-editor__row--structure': !isTextBlock(block)
        }"
        :data-editor-block-id="block.id"
        :data-block-index="index"
        @dragover="updateBlockDropTarget($event, index)"
        @drop="dropBlock($event, index)"
      >
        <button
          class="admin-block-editor__handle absolute -left-9 bottom-0 top-0 z-10 flex w-5 cursor-grab items-stretch justify-center rounded opacity-0 outline-none transition-opacity duration-150 focus:opacity-100 active:cursor-grabbing"
          type="button"
          draggable="true"
          aria-label="블록 이동 및 선택"
          @click.stop="selectBlock(block)"
          @keydown.delete="deleteSelectedBlock($event, index)"
          @keydown.backspace="deleteSelectedBlock($event, index)"
          @dragstart="startBlockDrag($event, block)"
          @dragend="finishBlockDrag"
        >
          <span class="admin-block-editor__handle-container" aria-hidden="true">
            <span class="admin-block-editor__handle-grabber"></span>
          </span>
        </button>

        <span
          v-if="block.type !== 'divider'"
          class="admin-block-editor__range-lane absolute bottom-0 left-[-1.375rem] top-0 z-[8] w-[18px] touch-none select-none"
          role="button"
          tabindex="-1"
          aria-label="블록 범위 선택: 드래그 또는 Shift+클릭"
          title="블록 범위 선택: 드래그 또는 Shift+클릭"
          @pointerdown="onBlockRangeLanePointerDown($event, index)"
        />

        <hr v-if="block.type === 'divider'" class="admin-block-editor__divider border-line">

        <figure
          v-else-if="block.type === 'image'"
          class="admin-block-editor__media group"
          :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 hidden w-full border-0 bg-transparent text-center text-sm text-muted outline-none placeholder:text-soft group-hover:block group-focus-within:block"
            type="text"
            placeholder="이미지 설명"
            @input="emitContent"
          >
        </figure>

        <figure
          v-else-if="block.type === 'gallery'"
          class="admin-block-editor__gallery group"
          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 gap-2"
            :style="{ gridTemplateColumns: `repeat(${getGalleryColumnCount(block)}, minmax(0, 1fr))` }"
          >
            <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"
              :class="{
                'opacity-50': draggingGalleryImage?.blockId === block.id && draggingGalleryImage?.imageIndex === imageIndex,
                'admin-block-editor__gallery-item--drop-before': galleryDragTarget?.blockId === block.id && galleryDragTarget?.imageIndex === imageIndex && galleryDragTarget?.position === 'before',
                'admin-block-editor__gallery-item--drop-after': galleryDragTarget?.blockId === block.id && galleryDragTarget?.imageIndex === imageIndex && galleryDragTarget?.position === 'after'
              }"
              draggable="true"
              @dragstart.stop="startGalleryImageDrag($event, block, imageIndex)"
              @dragover.prevent.stop="updateGalleryImageDropTarget($event, block, imageIndex)"
              @drop.prevent.stop="dropGalleryImage($event, block, imageIndex)"
              @dragend="finishGalleryImageDrag"
            >
              <img class="admin-block-editor__gallery-image aspect-[4/3] w-full object-cover" :src="image.url" :alt="image.alt">
              <span class="admin-block-editor__gallery-drag-hint pointer-events-none absolute left-2 top-2 rounded bg-black/70 px-2 py-1 text-xs font-semibold text-white opacity-0 transition-opacity group-hover/item:opacity-100">
                드래그
              </span>
              <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>

        <section
          v-else-if="block.type === 'callout'"
          class="admin-block-editor__callout-editor relative"
          @focusin="activateBlock(block)"
          @click="activateBlock(block)"
          @keydown.enter="handleEnter($event, index)"
          @keydown.backspace="handleBackspace($event, index)"
        >
          <ProseCallout
            :emoji-enabled="false"
            :background="block.calloutBackground"
          >
            <div class="flex items-start gap-2">
              <button
                v-if="block.calloutEmojiEnabled"
                class="inline-flex size-9 shrink-0 items-center justify-center cursor-pointer rounded-md text-xl text-[#1f2328] hover:bg-[#7f8da1]/20"
                type="button"
                @click.stop="toggleCalloutEmojiPicker(block)"
              >
                {{ block.calloutEmoji || '💡' }}
              </button>
              <div
                :ref="(element) => setBlockRef(element, index)"
                class="admin-block-editor__callout-input w-full bg-transparent text-[15px] leading-8 text-[#1f2328] outline-none"
                contenteditable="true"
                spellcheck="true"
                data-placeholder="콜아웃 텍스트 입력은 이렇게"
                :data-show-placeholder="!block.text"
                @focus="activateBlock(block)"
                @paste="handleTextBlockPaste($event, index)"
                @keydown="handleTextBlockKeydownRoot($event, index)"
                @input="updateBlockText($event, index)"
                @compositionstart="startTextComposition"
                @compositionend="finishTextComposition($event, index)"
                @keydown.enter="handleEnter($event, index)"
                @keydown.down="block.text.startsWith('/') ? highlightNextCommand($event) : navigateAcrossBlocks($event, index, 'down')"
                @keydown.up="block.text.startsWith('/') ? highlightPreviousCommand($event) : navigateAcrossBlocks($event, index, 'up')"
                @keydown.backspace="handleBackspace($event, index)"
              />
            </div>
          </ProseCallout>

          <div
            v-if="activeCalloutBlock?.id === block.id"
            class="admin-block-editor__callout-settings absolute left-full top-0 z-[70] ml-4 flex w-[320px] flex-col gap-3 rounded-lg bg-white p-6 shadow-lg"
          >
            <label class="flex w-full items-center justify-between">
              <div class="text-sm font-medium text-ink">Emoji</div>
              <button
                class="relative inline-flex h-4 w-7 items-center rounded-full transition-colors"
                :class="block.calloutEmojiEnabled ? 'bg-black' : 'bg-[#d0d7de]'"
                type="button"
                @click="toggleCalloutEmoji(block)"
              >
                <span
                  class="absolute size-3 rounded-full bg-white transition-transform"
                  :class="block.calloutEmojiEnabled ? 'translate-x-[12px]' : 'translate-x-[2px]'"
                ></span>
              </button>
            </label>

            <div class="relative flex w-full items-center justify-between">
              <div class="text-sm font-medium text-ink">Background</div>
                <button
                class="relative size-6 shrink-0 cursor-pointer rounded-full p-[2px]"
                type="button"
                @click="toggleCalloutColorPopover(block)"
              >
                <span
                  class="absolute inset-0 rounded-full"
                  style="background: conic-gradient(rgb(255,0,0), rgb(255,0,191), rgb(128,0,255), rgb(0,64,255), rgb(0,255,255), rgb(0,255,64), rgb(128,255,0), rgb(255,191,0), rgb(255,0,0)); mask: linear-gradient(#fff 0 0) content-box exclude, linear-gradient(#fff 0 0); padding:3px;"
                ></span>
                <span
                  class="relative block size-full rounded-full border-2 border-white ring-1 ring-black/10"
                  :style="{
                    background: block.calloutBackground === 'gray' ? 'rgba(100,116,139,0.28)'
                      : block.calloutBackground === 'blue' ? 'rgba(59,130,246,0.3)'
                      : block.calloutBackground === 'green' ? 'rgba(34,197,94,0.3)'
                      : block.calloutBackground === 'yellow' ? 'rgba(245,158,11,0.34)'
                      : block.calloutBackground === 'red' ? 'rgba(239,68,68,0.3)'
                      : block.calloutBackground === 'purple' ? 'rgba(168,85,247,0.3)'
                      : 'rgba(236,72,153,0.3)'
                  }"
                ></span>
              </button>

              <div
                v-if="calloutColorPopoverBlockId === block.id"
                class="absolute -right-2 bottom-full mb-2 z-[90] rounded-lg bg-white px-3 py-2 shadow"
              >
                <ul class="flex items-center gap-1">
                  <li v-for="backgroundOption in calloutBackgroundOptions" :key="`color-pop-${block.id}-${backgroundOption.value}`">
                    <button
                      class="group relative flex size-6 cursor-pointer items-center justify-center rounded-full border-2"
                      :class="block.calloutBackground === backgroundOption.value ? 'border-[#22c55e]' : 'border-transparent'"
                      type="button"
                      :aria-label="backgroundOption.label"
                      @click="updateCalloutBackground(block, backgroundOption.value)"
                    >
                      <span
                        class="size-[1.4rem] rounded-full border border-black/10"
                        :style="{
                          background: backgroundOption.value === 'gray' ? 'rgba(100,116,139,0.28)'
                            : backgroundOption.value === 'blue' ? 'rgba(59,130,246,0.3)'
                            : backgroundOption.value === 'green' ? 'rgba(34,197,94,0.3)'
                            : backgroundOption.value === 'yellow' ? 'rgba(245,158,11,0.34)'
                            : backgroundOption.value === 'red' ? 'rgba(239,68,68,0.3)'
                            : backgroundOption.value === 'purple' ? 'rgba(168,85,247,0.3)'
                            : 'rgba(236,72,153,0.3)'
                        }"
                      ></span>
                    </button>
                  </li>
                </ul>
              </div>
            </div>

            <div v-if="block.calloutEmojiEnabled && calloutEmojiPickerBlockId === block.id" class="rounded-lg border border-line bg-white p-3">
              <p class="mb-2 text-xs text-muted">이모지를 붙여넣거나 시스템 이모지 입력을 사용하세요</p>
              <input
                class="w-16 rounded-lg border border-line px-2 py-2 text-center text-2xl text-[#1f2328] outline-none"
                type="text"
                :value="block.calloutEmoji || ''"
                maxlength="8"
                spellcheck="false"
                @input="updateCalloutEmojiFromInput(block, $event)"
                @compositionstart="startCalloutEmojiComposition(block)"
                @compositionend="finishCalloutEmojiComposition(block, $event)"
                @blur="normalizeCalloutEmojiInput(block, $event)"
              >
            </div>
          </div>
        </section>

        <section
          v-else-if="block.type === 'toggle'"
          class="admin-block-editor__toggle rounded border border-line bg-paper p-5"
          @focusin="activateBlock(block)"
          @click="activateBlock(block)"
          @keydown.enter="handleEnter($event, index)"
          @keydown.backspace="handleBackspace($event, index)"
        >
          <input
            v-model="block.title"
            class="admin-block-editor__toggle-title w-full border-0 bg-transparent text-base font-semibold text-ink outline-none placeholder:text-soft"
            type="text"
            placeholder="토글 제목"
            @input="updateStructuredBlock"
          >
          <textarea
            v-model="block.text"
            class="admin-block-editor__toggle-body mt-3 min-h-24 w-full resize-y border-0 bg-transparent text-sm leading-7 text-ink outline-none placeholder:text-soft"
            placeholder="펼쳤을 때 보일 내용을 입력하세요"
            @input="updateStructuredBlock"
            @keydown.enter.stop
          />
        </section>

        <section
          v-else-if="block.type === 'embed'"
          class="admin-block-editor__embed rounded border border-dashed border-line bg-surface p-5"
          @focusin="activateBlock(block)"
          @click="activateBlock(block)"
          @keydown.enter="handleEnter($event, index)"
          @keydown.backspace="handleBackspace($event, index)"
        >
          <input
            v-model="block.url"
            class="admin-block-editor__embed-url w-full border-0 bg-transparent text-base font-semibold text-ink outline-none placeholder:text-soft"
            type="url"
            placeholder="https://www.youtube.com/watch?v=..."
            @input="updateStructuredBlock"
          >
          <p class="admin-block-editor__embed-help mt-2 text-xs text-muted">
            YouTube 링크는 공개 화면에서 영상으로 표시됩니다.
          </p>
        </section>

        <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)"
          @paste="handleTextBlockPaste($event, index)"
          @keydown="handleTextBlockKeydownRoot($event, index)"
          @input="updateBlockText($event, index)"
          @compositionstart="startTextComposition"
          @compositionend="finishTextComposition($event, index)"
          @keydown.enter="handleEnter($event, index)"
          @keydown.down="block.text.startsWith('/') ? highlightNextCommand($event) : navigateAcrossBlocks($event, index, 'down')"
          @keydown.up="block.text.startsWith('/') ? highlightPreviousCommand($event) : navigateAcrossBlocks($event, index, 'up')"
          @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 max-h-[min(52vh,360px)] overflow-y-auto 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 ? 'admin-block-editor__slash-item--active 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 relative overflow-hidden border bg-white text-left transition-colors"
              :class="mediaPickerTarget?.type === 'gallery' && isGalleryMediaSelected(item) ? 'border-[#15171a] ring-2 ring-[#15171a]/20' : 'border-line hover:border-[#8e9cac]'"
              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
                v-if="mediaPickerTarget?.type === 'gallery' && isGalleryMediaSelected(item)"
                class="admin-block-editor__media-picker-selected absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[#15171a] text-xs font-bold text-white"
                aria-hidden="true"
              >
                ✓
              </span>
              <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>
        <div
          v-if="mediaPickerTarget?.type === 'gallery'"
          class="admin-block-editor__media-picker-footer flex items-center justify-between gap-3 border-t border-line px-5 py-4"
        >
          <p class="admin-block-editor__media-picker-count text-sm text-muted">
            {{ selectedGalleryMediaUrls.length }}개 선택됨
          </p>
          <div class="admin-block-editor__media-picker-actions flex gap-2">
            <button class="admin-block-editor__media-picker-cancel rounded border border-line px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeMediaPicker">
              취소
            </button>
            <button class="admin-block-editor__media-picker-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" type="button" @click="applyGalleryMediaSelection">
              갤러리에 적용
            </button>
          </div>
        </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;
}

.admin-block-editor__row::before {
  position: absolute;
  inset: -4px -10px;
  z-index: -1;
  border-radius: 8px;
  background: transparent;
  content: "";
  opacity: 0;
  transform: scaleX(0.995);
  transition:
    background-color 160ms ease,
    opacity 160ms ease,
    transform 160ms ease;
}

.admin-block-editor__row::after {
  position: absolute;
  left: 0;
  right: 0;
  z-index: 20;
  height: 3px;
  border-radius: 999px;
  background: #2eb6ea;
  box-shadow: 0 0 0 1px rgba(46, 182, 234, 0.18);
  content: "";
  opacity: 0;
  pointer-events: none;
  transform: scaleX(0.98);
  transition:
    opacity 120ms ease,
    transform 120ms ease;
}

.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover::before,
.admin-block-editor__row--selected::before {
  background: #eff1f2;
  opacity: 1;
  transform: scaleX(1);
}

.admin-block-editor__row--callout:hover::before,
.admin-block-editor__row--callout.admin-block-editor__row--selected::before {
  opacity: 0;
}

.admin-block-editor__row--callout.admin-block-editor__row--range-selected::before {
  opacity: 1;
}

.admin-block-editor__row--range-selected::before {
  z-index: -1;
  border-radius: 8px;
  background: rgba(46, 182, 234, 0.14);
  opacity: 1;
  transform: scaleX(1);
}

.admin-block-editor__range-lane {
  cursor: crosshair;
}

.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__range-lane {
  border-radius: 4px;
  background: rgba(46, 182, 234, 0.08);
}

.admin-block-editor__row--drop-before::after {
  top: -18px;
  opacity: 1;
  transform: scaleX(1);
}

.admin-block-editor__row--drop-after::after {
  bottom: -18px;
  opacity: 1;
  transform: scaleX(1);
}

.admin-block-editor__handle {
  min-height: 32px;
}

.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__handle {
  opacity: 1;
}

.admin-block-editor__handle-container {
  display: flex;
  width: 16px;
  height: 100%;
  align-items: center;
  justify-content: center;
  padding: 6px 0;
}

.admin-block-editor__handle-grabber {
  display: block;
  width: 4px;
  height: 20px;
  border-radius: 999px;
  background: #8d969f;
  opacity: 0.72;
  transition:
    height 160ms ease,
    background-color 160ms ease,
    opacity 160ms ease;
}

.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__handle-grabber,
.admin-block-editor__row--selected .admin-block-editor__handle-grabber,
.admin-block-editor__handle:focus .admin-block-editor__handle-grabber {
  height: 100%;
  background: #5d6670;
  opacity: 1;
}

.admin-block-editor__row + .admin-block-editor__row--text {
  margin-top: 32px;
}

.admin-block-editor__row + .admin-block-editor__row--structure {
  margin-top: 32px;
}

.admin-block-editor__row--structure + .admin-block-editor__row--structure {
  margin-top: 32px;
}

.admin-block-editor__code {
  background: #15171a;
  color: #f8fafc;
  caret-color: #f8fafc;
}

.admin-block-editor__callout-input:empty[data-show-placeholder="true"]::before {
  color: var(--site-soft);
  content: attr(data-placeholder);
}

.admin-block-editor__gallery-item--drop-before::before,
.admin-block-editor__gallery-item--drop-after::before {
  position: absolute;
  top: 8px;
  bottom: 8px;
  z-index: 20;
  width: 4px;
  border-radius: 999px;
  background: #2eb6ea;
  box-shadow:
    0 0 0 3px rgba(46, 182, 234, 0.16),
    0 6px 18px rgba(46, 182, 234, 0.35);
  content: "";
  pointer-events: none;
}

.admin-block-editor__gallery-item--drop-before::before {
  left: 0;
  transform: translateX(-50%);
}

.admin-block-editor__gallery-item--drop-after::before {
  right: 0;
  transform: translateX(50%);
}
</style>
