<template>
  <div class="editor-container" :style="editorTypographyStyle">
    <div ref="root" class="milkdown-editor"></div>

    <div class="history-buttons">
      <button
        type="button"
        class="history-btn"
        :disabled="!canUndo"
        :aria-label="undoLabel"
        :title="undoLabel"
        @click="handleUndo"
      >
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <path d="M9 14 4 9l5-5"/>
          <path d="M4 9h11a4 4 0 1 1 0 8h-1"/>
        </svg>
      </button>
      <button
        type="button"
        class="history-btn"
        :disabled="!canRedo"
        :aria-label="redoLabel"
        :title="redoLabel"
        @click="handleRedo"
      >
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <path d="m15 14 5-5-5-5"/>
          <path d="M20 9H9a4 4 0 1 0 0 8h1"/>
        </svg>
      </button>
    </div>

    <div
      class="more-actions"
      :class="{
        'is-open': moreActionsOpen,
        'is-closing': moreActionsClosing,
        'is-horizontal': moreActionsLayout === 'horizontal',
        'is-vertical': moreActionsLayout === 'vertical',
      }"
    >
      <div v-if="moreActionsOpen" class="more-actions__backdrop" @click="closeMoreActions"></div>
      <button
        type="button"
        class="more-actions__toggle"
        :class="{ 'is-open': moreActionsOpen }"
        :aria-label="moreActionsOpen ? t('close') || '关闭' : t('more') || '更多'"
        :title="moreActionsOpen ? t('close') || '关闭' : t('more') || '更多'"
        @click.stop="toggleMoreActions"
      >
        <span class="more-actions__dot"></span>
        <span class="more-actions__dot"></span>
        <span class="more-actions__dot"></span>
      </button>

      <div v-if="moreActionsOpen || moreActionsClosing" class="more-actions__panel-shell">
        <div class="more-actions__panel">
          <template v-for="(action, index) in moreActions" :key="action.id">
            <div
              v-if="action.id === 'exportMd'"
              class="more-actions__item export-btn-wrapper"
              :style="getMoreActionStyle(index)"
            >
              <button
                type="button"
                class="action-btn more-action-btn"
                :aria-label="t('exportMd')"
                :title="t('exportMd')"
                @click="handleMoreActionClick(action, $event)"
              >
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                  <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
                  <polyline points="7 10 12 15 17 10"/>
                  <line x1="12" y1="15" x2="12" y2="3"/>
                  <path d="m19 9-4 4-4-4"/>
                </svg>
              </button>
              <div v-if="showExportDropdown" class="export-dropdown more-actions__submenu">
                <button type="button" @click="() => { exportMarkdown(); closeMoreActions(); showExportDropdown = false; }">{{ t('exportMd') }}</button>
                <button type="button" @click="() => { exportDocx(); closeMoreActions(); showExportDropdown = false; }">{{ t('exportDocx') }}</button>
                <button type="button" @click="() => { exportPdf(); closeMoreActions(); showExportDropdown = false; }">{{ t('exportPdf') }}</button>
              </div>
            </div>

            <button
              v-else-if="action.id === 'upload'"
              type="button"
              class="action-btn more-action-btn"
              :class="{ 'force-disabled': isDocUploadDisabled }"
              :disabled="isDocUploadDisabled"
              :aria-label="t('upload')"
              :title="uploadButtonTitle"
              :tabindex="moreActionsOpen ? 0 : -1"
              :style="getMoreActionStyle(index)"
              @click="handleMoreActionClick(action, $event)"
            >
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
                <polyline points="14 2 14 8 20 8"/>
                <rect x="7" y="11" width="8" height="6" rx="1" stroke-width="1.5"/>
                <circle cx="9" cy="13" r="0.8" fill="currentColor"/>
                <path d="M7 16l2-2 2 2" stroke-width="1.5"/>
              </svg>
            </button>

            <button
              v-else-if="action.id === 'importMd'"
              type="button"
              class="action-btn more-action-btn"
              :aria-label="t('importMd')"
              :title="t('importMd')"
              :style="getMoreActionStyle(index)"
              @click="handleMoreActionClick(action, $event)"
            >
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
                <polyline points="17 8 12 3 7 8"/>
                <line x1="12" y1="3" x2="12" y2="15"/>
              </svg>
            </button>

            <button
              v-else-if="action.id === 'aiToggle'"
              type="button"
              class="action-btn more-action-btn ai-toggle"
              :class="{ 'ai-disabled': !aiEnabled, 'force-disabled': isOverLimit }"
              :disabled="isOverLimit"
              :aria-label="aiButtonLabel"
              :title="aiButtonLabel"
              :tabindex="moreActionsOpen ? 0 : -1"
              :style="getMoreActionStyle(index)"
              @click="handleMoreActionClick(action, $event)"
            >
              <svg v-if="aiEnabled && !isOverLimit" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M12 3l1.9 3.9L18 9l-4.1 2.1L12 15l-1.9-3.9L6 9l4.1-2.1L12 3z"/>
                <path d="M5 14l.9 1.8L8 16.7l-2.1.9L5 20l-.9-2.4L2 16.7l2.1-.9L5 14z"/>
                <path d="M19 14l.6 1.2 1.4.6-1.4.6L19 18l-.6-1.6-1.4-.6 1.4-.6L19 14z"/>
              </svg>
              <svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <circle cx="12" cy="12" r="9"/>
                <line x1="5" y1="5" x2="19" y2="19"/>
              </svg>
            </button>

            <div
              v-else-if="action.id === 'template'"
              class="more-actions__item template-btn-wrapper"
              :style="getMoreActionStyle(index)"
            >
              <button
                type="button"
                class="action-btn more-action-btn"
                :aria-label="t('template')"
                :title="t('template')"
                @click="handleMoreActionClick(action, $event)"
              >
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                  <path d="M4 5h7l2 2h7v12H4z" />
                  <path d="M7 11h10M7 15h10" />
                </svg>
              </button>
              <div v-if="showTemplateDropdown" class="template-dropdown more-actions__submenu">
                <div class="template-dropdown-section">
                  <div class="template-dropdown-title">{{ t('presetTemplates') }}</div>
                  <button
                    v-for="template in presetTemplates"
                    :key="template.id"
                    type="button"
                    class="template-dropdown-item"
                    @click="openTemplatePreview(template)"
                  >
                    <span class="template-dropdown-name">{{ template.name }}</span>
                  </button>
                </div>
                <div class="template-dropdown-section">
                  <div class="template-dropdown-title">{{ t('customTemplates') }}</div>
                  <template v-if="customTemplates.length > 0">
                    <button
                      v-for="template in customTemplates"
                      :key="template.id"
                      type="button"
                      class="template-dropdown-item"
                      @click="openTemplatePreview(template)"
                    >
                      <span class="template-dropdown-name">{{ template.name }}</span>
                    </button>
                  </template>
                  <div v-else class="template-dropdown-empty">
                    {{ t('noCustomTemplates') || '暂无自定义模板' }}
                  </div>
                </div>
                <div class="template-dropdown-footer">
                  <button type="button" class="template-dropdown-action" @click="openNewTemplateEditor">
                    {{ t('newTemplate') }}
                  </button>
                </div>
              </div>
            </div>

            <button
              v-else-if="action.id === 'clear'"
              type="button"
              class="action-btn more-action-btn"
              aria-label="清除"
              title="清除"
              :style="getMoreActionStyle(index)"
              @click="handleMoreActionClick(action, $event)"
            >
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
              </svg>
            </button>
          </template>
        </div>
      </div>
    </div>

    <input type="file" ref="uploadInputRef" @change="handleUpload" :accept="acceptAll" multiple style="display:none">
    <input type="file" ref="mdInputRef" @change="handleImportMd" accept=".md,text/markdown,text/x-markdown" style="display:none">

    <div
      class="size-indicator"
      :class="{ 'over-limit': isOverLimit }"
      @mouseenter="showSizeTooltip = true"
      @mouseleave="showSizeTooltip = false"
    >
      <svg
        class="warning-icon"
        :class="{ 'warning-icon--visible': isOverLimit }"
        width="12"
        height="12"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
      >
        <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
        <line x1="12" y1="9" x2="12" y2="13" />
        <line x1="12" y1="17" x2="12.01" y2="17" />
      </svg>
      {{ sizeInKB }} KB
      <Transition name="tooltip-fade">
        <div v-if="showSizeTooltip && isOverLimit" class="size-tooltip">
          <strong>文档超过32KB限制</strong>
          <span>AI补全功能已暂停，建议精简内容或分段处理</span>
        </div>
      </Transition>
    </div>
  </div>

  <div v-if="uploadProgress" class="upload-progress-overlay">
    <div class="upload-progress-dialog">
      <div class="spinner"></div>
      <p>{{ t('uploading') || '正在上传文件' }}</p>
      <p class="progress-text">
        {{ uploadProgress.current }} / {{ uploadProgress.total }}
      </p>
      <p class="filename">{{ uploadProgress.filename }}</p>
    </div>
  </div>

  <TTSMenu
    :visible="ttsMenuVisible"
    :x="ttsMenuX"
    :y="ttsMenuY"
    :loading="ttsLoading"
    @speak="handleTTSSpeak"
  />

  <TTSPlayer
    :visible="ttsPlayerVisible"
    :audio-base64="ttsAudioBase64"
    :format="ttsFormat"
    :duration-ms="ttsDuration"
    @close="closeTTSPlayer"
  />

  <div v-if="showTemplateModal" class="template-dialog-overlay" @click.self="closeTemplateModal">
    <div class="template-dialog">
      <div class="template-dialog-header">
        <div>
          <p class="template-dialog-kicker">
            {{ templateModalMode === 'editor' ? (templateEditingId ? t('editTemplate') : t('newTemplate')) : t('previewTemplate') }}
          </p>
          <h3>{{ selectedTemplate?.name || templateFormName || t('template') }}</h3>
        </div>
        <button type="button" class="template-dialog-close" :aria-label="t('close')" :title="t('close')" @click="closeTemplateModal">
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <line x1="18" y1="6" x2="6" y2="18" />
            <line x1="6" y1="6" x2="18" y2="18" />
          </svg>
        </button>
      </div>

      <template v-if="templateModalMode === 'preview'">
        <p class="template-dialog-meta">{{ isPresetTemplate(selectedTemplate) ? t('presetTemplates') : t('customTemplates') }}</p>
        <textarea class="template-preview-textarea" :value="selectedTemplate?.content || ''" readonly></textarea>
        <p v-if="templateFormError" class="template-dialog-error">{{ templateFormError }}</p>
        <div class="template-dialog-actions">
          <button type="button" class="dialog-btn" @click="closeTemplateModal">{{ t('cancel') }}</button>
          <button
            v-if="selectedTemplate && !isPresetTemplate(selectedTemplate)"
            type="button"
            class="dialog-btn"
            @click="openExistingTemplateEditor"
          >
            {{ t('editTemplate') }}
          </button>
          <button
            v-if="selectedTemplate && !isPresetTemplate(selectedTemplate)"
            type="button"
            class="dialog-btn danger"
            @click="deleteSelectedTemplate"
          >
            {{ t('delete') }}
          </button>
          <button
            v-if="selectedTemplate && isPresetTemplate(selectedTemplate)"
            type="button"
            class="dialog-btn"
            @click="openCopiedTemplateEditor"
          >
            {{ t('copyAsTemplate') }}
          </button>
          <button type="button" class="dialog-btn primary" @click="applySelectedTemplate">{{ t('applyTemplate') }}</button>
        </div>
      </template>

      <template v-else>
        <div class="template-form">
          <label class="template-form-label">{{ t('templateName') }}</label>
          <input v-model="templateFormName" class="template-form-input" type="text" :placeholder="t('templateNamePlaceholder')" />
          <label class="template-form-label">{{ t('templateContent') }}</label>
          <textarea v-model="templateFormContent" class="template-form-textarea" :placeholder="t('templateContentPlaceholder')"></textarea>
          <p v-if="templateFormError" class="template-dialog-error">{{ templateFormError }}</p>
        </div>
        <div class="template-dialog-actions">
          <button type="button" class="dialog-btn" @click="closeTemplateModal">{{ t('cancel') }}</button>
          <button type="button" class="dialog-btn primary" @click="saveTemplate">{{ t('saveTemplate') }}</button>
        </div>
      </template>
    </div>
  </div>
</template>

<script setup>
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
import { replaceAll, replaceRange } from '@milkdown/kit/utils'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { Selection } from '@milkdown/prose/state'
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { docBlockNode, docBlockRemark, docBlockView } from '../plugins/docBlockPlugin'
import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView } from '../plugins/hiddenTextPlugin'
import { proBlockConfigCtx, proBlockHighlightPlugin, proBlockInputPlugin, proBlockNode, proBlockRemark, proBlockView } from '../plugins/proBlockPlugin'
import { uploadBlockConfigCtx, uploadBlockInputPlugin, uploadBlockNode, uploadBlockRemark, uploadBlockView } from '../plugins/uploadBlockPlugin'
import { webSearchBlockConfigCtx, webSearchBlockInputPlugin, webSearchBlockNode, webSearchBlockRemark, webSearchBlockView } from '../plugins/webSearchBlockPlugin'
import { mermaidRenderPreview, refreshMermaidPreviews, codeBlockConfig } from '../plugins/mermaidPlugin'
import { fetchProSuggestionStream, fetchSuggestion, fetchTTS, fetchWebSearchStream } from '../utils/api.js'
import { useSettingsStore } from '../stores/settings'
import { useTemplatesStore } from '../stores/templates'
import { useTheme } from '../composables/useTheme.js'
import { OCR_URL } from '../utils/config.js'
import TTSMenu from './TTSMenu.vue'
import TTSPlayer from './TTSPlayer.vue'
import { convertFileToMarkdown, convertAudioToText, convertVideoToText } from '../utils/convert.js'
import { exportEditorToDocxBlob, exportEditorToPdf } from '../utils/richExport.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash, setOcrState, OcrStatus } from '../utils/ocrCache.js'
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
import { DOC_BLOCK_NODE_TYPE, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy, isAudioFile, isVideoFile } from '../utils/docBlock.js'
import { isUploadBlockTypeAllowed } from '../utils/uploadBlock.js'
import { WEB_SEARCH_NODE_TYPE } from '../utils/webSearch.js'

const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore()
const templateStore = useTemplatesStore()
const { isDark } = useTheme()
const t = (key) => settings.t[key]
const initialMarkdown = computed(() => settings.initialMarkdown)
const COPILOT_TOGGLE_EVENT = 'llm-in-text:copilot-toggle'

const root = ref(null)
const uploadInputRef = ref(null)
const mdInputRef = ref(null)
const aiEnabled = ref(true)
const contentSize = ref(0)
const showExportDropdown = ref(false)
const showTemplateDropdown = ref(false)
const showTemplateModal = ref(false)
const templateModalMode = ref('preview')
const selectedTemplate = ref(null)
const templateEditingId = ref('')
const templateFormName = ref('')
const templateFormContent = ref('')
const templateFormError = ref('')
const showSizeTooltip = ref(false)
const canUndo = ref(false)
const canRedo = ref(false)
const isDocUploadDisabled = ref(false)
const uploadProgress = ref(null)
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo')
const redoLabel = computed(() => t('redo') || 'Redo')
const ttsMenuVisible = ref(false)
const ttsMenuX = ref(0)
const ttsMenuY = ref(0)
const ttsLoading = ref(false)
const ttsPlayerVisible = ref(false)
const ttsAudioBase64 = ref('')
const ttsFormat = ref('wav')
const ttsDuration = ref(0)
const savedSelection = ref(null)
const selectedText = ref('')
let ttsMouseUpHandler = null
let ttsClickOutsideHandler = null
const aiButtonLabel = computed(() => {
  if (isOverLimit.value) return t('docTooLarge')
  return aiEnabled.value ? t('disableAI') : t('enableAI')
})
const uploadButtonTitle = computed(() => {
  if (isDocUploadDisabled.value) return t('uploadDocInBlockWarning') || '当前光标位置不能插入文件'
  return t('upload')
})
const moreActionsOpen = ref(false)
const moreActionsClosing = ref(false)
const viewportWidth = ref(typeof window === 'undefined' ? 1024 : window.innerWidth)
const MORE_ACTION_MIN_HORIZONTAL_WIDTH = 520
const MORE_ACTION_CLOSE_MS = 380
const moreActions = computed(() => [
  { id: 'upload', ariaLabel: t('upload'), title: uploadButtonTitle.value, disabled: isDocUploadDisabled.value, disabledClass: 'force-disabled' },
  { id: 'importMd', ariaLabel: t('importMd'), title: t('importMd') },
  { id: 'exportMd', ariaLabel: t('exportMd'), title: t('exportMd') },
  { id: 'aiToggle', ariaLabel: aiButtonLabel.value, title: aiButtonLabel.value, disabled: isOverLimit.value, disabledClass: 'ai-disabled force-disabled' },
  { id: 'template', ariaLabel: t('template'), title: t('template') },
  { id: 'clear', ariaLabel: '清除', title: '清除' },
])
const moreActionsLayout = computed(() => (
  viewportWidth.value >= MORE_ACTION_MIN_HORIZONTAL_WIDTH ? 'horizontal' : 'vertical'
))
const presetTemplates = computed(() => templateStore.presetTemplates || [])
const customTemplates = computed(() => templateStore.customTemplates || [])
const acceptAll = computed(() => {
  const types = [
    '.txt', '.json', '.toml', '.yaml', '.yml',
    '.docx', '.pptx', '.pdf',
    '.wav', '.mp3', '.m4a', '.ogg', '.flac',
    '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.heic', '.heif', '.avif',
    '.mp4', '.webm', '.mov', '.avi', '.mkv',
    'text/plain', 'application/json',
    'text/yaml', 'text/x-yaml', 'application/x-yaml',
    'application/pdf',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    'application/vnd.openxmlformats-officedocument.presentationml.presentation',
    'image/*', 'audio/*', 'video/*'
  ]
  return types.join(',')
})

let crepe = null
let markdownSyncTimer = null
let historyUpdateTimer = null
let editorCopyHandler = null
let documentClickHandler = null
let moreActionsCloseTimer = null
let resizeHandler = null
const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const MARKDOWN_EXT_RE = /\.md$/i
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
const VIDEO_EXT_RE = /\.(mp4|webm|mov|avi|mkv)$/i
const CONVERT_EXT_RE = /\.(docx|pptx|pdf)$/i
const TEXT_EXT_RE = /\.(txt|json|toml|ya?ml)$/i
const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json', 'text/yaml', 'text/x-yaml', 'application/x-yaml'])
const CONVERT_MIME_TYPES = new Set([
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  'application/pdf',
])
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska'])
const MAX_UPLOAD_BATCH = 10
const MAX_UPLOAD_FILE_SIZE = 100 * 1024 * 1024
let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value)

const normalizeTrailingWhitespace = (value) => (value || '').replace(/\s+$/, '')

const padTimePart = (value) => String(value).padStart(2, '0')

const createExportName = () => {
  const now = new Date()
  const datePart = `${now.getFullYear()}${padTimePart(now.getMonth() + 1)}${padTimePart(now.getDate())}`
  const timePart = `${padTimePart(now.getHours())}${padTimePart(now.getMinutes())}${padTimePart(now.getSeconds())}`
  return `save${datePart}${timePart}`
}

const clearEditor = () => {
  if (crepe && crepe.editor) {
    crepe.editor.action(replaceAll(''))
  }
}

const finishMoreActionsClosing = () => {
  if (moreActionsCloseTimer) {
    clearTimeout(moreActionsCloseTimer)
    moreActionsCloseTimer = null
  }
  moreActionsClosing.value = false
}

const startMoreActionsClosing = () => {
  finishMoreActionsClosing()
  moreActionsClosing.value = true
  moreActionsCloseTimer = window.setTimeout(() => {
    moreActionsClosing.value = false
    moreActionsCloseTimer = null
  }, MORE_ACTION_CLOSE_MS)
}

const toggleMoreActions = () => {
  const nextOpen = !moreActionsOpen.value
  if (nextOpen) {
    finishMoreActionsClosing()
  }
  moreActionsOpen.value = nextOpen
  showExportDropdown.value = false
  showTemplateDropdown.value = false

  if (!nextOpen) {
    startMoreActionsClosing()
  }
}

const closeMoreActions = () => {
  if (!moreActionsOpen.value && !showExportDropdown.value && !showTemplateDropdown.value) return
  moreActionsOpen.value = false
  showExportDropdown.value = false
  showTemplateDropdown.value = false
  startMoreActionsClosing()
}

const handleMoreActionClick = (action, event) => {
  if (!moreActionsOpen.value) return

  if (action.id === 'upload') {
    triggerUpload()
  } else if (action.id === 'importMd') {
    triggerImportMd()
  } else if (action.id === 'exportMd') {
    if (showExportDropdown.value) {
      closeMoreActions()
    } else {
      toggleExportDropdown()
    }
  } else if (action.id === 'aiToggle') {
    toggleAI()
  } else if (action.id === 'template') {
    if (showTemplateDropdown.value) {
      closeMoreActions()
    } else {
      toggleTemplateDropdown()
    }
  } else if (action.id === 'clear') {
    clearEditor()
  }

  if (!['exportMd', 'template'].includes(action.id)) {
    closeMoreActions()
  }

  event?.stopPropagation?.()
}

const getMoreActionStyle = (index) => {
  return {
    '--more-action-delay': `${index * 45}ms`,
  }
}

const isMoreActionTarget = (target) => {
  if (!(target instanceof Element)) return false
  return Boolean(
    target.closest('.more-actions') ||
    target.closest('.export-btn-wrapper') ||
    target.closest('.template-btn-wrapper') ||
    target.closest('.more-actions__toggle') ||
    target.closest('.size-indicator')
  )
}

const handleMoreActionKeydown = (event) => {
  if (event.key === 'Escape') {
    closeMoreActions()
  }
}

const isPresetTemplate = (template) => String(template?.id || '').startsWith('preset-')

const closeTemplateModal = () => {
  showTemplateModal.value = false
  templateModalMode.value = 'preview'
  selectedTemplate.value = null
  templateEditingId.value = ''
  templateFormName.value = ''
  templateFormContent.value = ''
  templateFormError.value = ''
}

const openTemplatePreview = (template) => {
  if (!template) return

  selectedTemplate.value = template
  templateModalMode.value = 'preview'
  templateFormError.value = ''
  showTemplateDropdown.value = false
  showExportDropdown.value = false
  closeMoreActions()
  showTemplateModal.value = true
}

const openTemplateEditor = ({ template = null, editingId = '' } = {}) => {
  selectedTemplate.value = template
  templateModalMode.value = 'editor'
  templateEditingId.value = editingId
  templateFormError.value = ''
  showTemplateDropdown.value = false
  showExportDropdown.value = false
  closeMoreActions()

  if (editingId) {
    templateFormName.value = template?.name || ''
    templateFormContent.value = template?.content || ''
  } else if (template) {
    templateFormName.value = templateStore.buildUniqueCustomTemplateName(template.name)
    templateFormContent.value = template.content || ''
  } else {
    templateFormName.value = templateStore.buildUniqueCustomTemplateName(t('newTemplate'))
    templateFormContent.value = ''
  }

  showTemplateModal.value = true
}

const openNewTemplateEditor = () => {
  openTemplateEditor()
}

const openExistingTemplateEditor = () => {
  if (!selectedTemplate.value) return
  openTemplateEditor({ template: selectedTemplate.value, editingId: selectedTemplate.value.id })
}

const openCopiedTemplateEditor = () => {
  if (!selectedTemplate.value) return

  const suffix = settings.uiLanguage === 'zh' ? ' 副本' : ' Copy'
  openTemplateEditor({
    template: {
      name: templateStore.buildUniqueCustomTemplateName(`${selectedTemplate.value.name}${suffix}`),
      content: selectedTemplate.value.content || '',
    },
  })
}

const applySelectedTemplate = () => {
  if (!selectedTemplate.value) return

  clearCurrentGhost()
  
  // 导入模板时清除现有内容并覆盖全部内容
  // 直接使用 replaceAll 将整个文档替换为模板内容，而非在光标位置插入
  if (crepe && crepe.editor) {
    crepe.editor.action((ctx) => {
      const view = ctx.get(editorViewCtx)
      replaceAll(selectedTemplate.value.content || '')(ctx)
      view.focus()
    })
  }
  
  closeTemplateModal()
}

const saveTemplate = () => {
  const nextName = templateFormName.value.trim()
  const nextContent = templateFormContent.value.replace(/\r\n?/g, '\n').replace(/\s+$/, '')

  if (!nextName) {
    templateFormError.value = t('templateNameRequired') || '请输入模板名称'
    return
  }

  if (!nextContent.trim()) {
    templateFormError.value = t('templateContentRequired') || '请输入模板内容'
    return
  }

  const result = templateEditingId.value
    ? templateStore.updateCustomTemplate(templateEditingId.value, { name: nextName, content: nextContent })
    : templateStore.createCustomTemplate({ name: nextName, content: nextContent })

  if (!result.ok) {
    templateFormError.value = result.error === 'duplicate'
      ? (t('templateNameDuplicate') || '模板名称已存在')
      : (settings.uiLanguage === 'zh' ? '模板保存失败' : 'Template save failed.')
    return
  }

  closeTemplateModal()
}

const deleteSelectedTemplate = () => {
  if (!selectedTemplate.value || isPresetTemplate(selectedTemplate.value)) return

  const confirmMessage = t('templateDeleteConfirm') || 'Delete this template?'
  if (!window.confirm(confirmMessage)) return

  const removed = templateStore.deleteCustomTemplate(selectedTemplate.value.id)
  if (!removed) {
    templateFormError.value = settings.uiLanguage === 'zh' ? '模板删除失败' : 'Template delete failed.'
    return
  }

  closeTemplateModal()
}

const toggleTemplateDropdown = () => {
  showTemplateDropdown.value = !showTemplateDropdown.value
  if (showTemplateDropdown.value) {
    showExportDropdown.value = false
  }
}

const downloadBlob = (blob, filename) => {
  const url = URL.createObjectURL(blob)
  const anchor = document.createElement('a')
  anchor.href = url
  anchor.download = filename
  anchor.style.display = 'none'
  document.body.appendChild(anchor)
  anchor.click()
  anchor.remove()
  setTimeout(() => URL.revokeObjectURL(url), 0)
}

const getExportMarkdown = async () => {
  if (!crepe) {
    throw new Error('编辑器未初始化，请稍后重试')
  }

  crepe.editor.action((ctx) => {
    const view = ctx.get(editorViewCtx)
    clearCurrentSuggestion(view)
  })

  const markdown = await crepe.getMarkdown()
  return transformLegacyDocBlocksForExport(markdown)
}

const syncInitialMarkdown = async (nextValue) => {
  if (!crepe) {
    lastInitialMarkdown = transformSpecialDocBlocksToLegacy(nextValue)
    return
  }
  const normalizedNextValue = transformSpecialDocBlocksToLegacy(nextValue)

  try {
    const current = await crepe.getMarkdown()
    const normalizedCurrent = normalizeTrailingWhitespace(current)
    const normalizedLast = normalizeTrailingWhitespace(lastInitialMarkdown)
    if (!normalizedCurrent || normalizedCurrent === normalizedLast) {
      crepe.editor.action(replaceAll(normalizedNextValue))
    }
  } catch {
    // Ignore sync errors
  } finally {
    lastInitialMarkdown = normalizedNextValue
  }
}

watch(
  () => settings.initialMarkdown,
  (nextValue) => {
    void syncInitialMarkdown(nextValue)
  }
)

watch(
  () => isDark.value,
  () => {
    refreshMermaidPreviews()
  }
)

const revokeObjectUrl = (url) => {
  if (!objectUrls.has(url)) return
  URL.revokeObjectURL(url)
  objectUrls.delete(url)
  clearOcrCache(url)
}

const collectImageObjectUrls = (doc) => {
  const activeUrls = new Set()
  doc.descendants((node) => {
    const src = typeof node.attrs?.src === 'string' ? node.attrs.src : ''
    if (
      IMAGE_NODE_TYPES.has(node.type?.name) &&
      src.startsWith('blob:')
    ) {
      activeUrls.add(src)
    }
  })
  return activeUrls
}

const syncObjectUrls = (doc) => {
  const activeUrls = collectImageObjectUrls(doc)
  for (const url of Array.from(objectUrls)) {
    if (!activeUrls.has(url)) {
      revokeObjectUrl(url)
    }
  }
}

const refreshSizeAndLimit = (ctx) => {
  const view = ctx.get(editorViewCtx)
  const { size, overLimit } = checkSizeLimit(view)
  contentSize.value = size

  if (overLimit && aiEnabled.value) {
    aiEnabled.value = false
    setCopilotEnabled(view, false)
  }
}

const scheduleMarkdownSync = () => {
  if (!crepe) return

  // Skip markdown sync when tab is hidden (energy saving on mobile/PC)
  if (!isDocumentVisible()) return

  if (markdownSyncTimer) {
    clearTimeout(markdownSyncTimer)
    markdownSyncTimer = null
  }

  const syncInterval = getRecommendedSyncInterval(120)

  markdownSyncTimer = setTimeout(async () => {
    markdownSyncTimer = null
    if (!crepe) return

    try {
      let hasGhostSuggestion = false
      crepe.editor.action((ctx) => {
        const view = ctx.get(editorViewCtx)
        const state = COPILOT_PLUGIN_KEY.getState(view.state)
        hasGhostSuggestion = Boolean(state?.suggestion && state.from < state.to)
      })

      if (hasGhostSuggestion) return

      const markdown = await crepe.getMarkdown()
      emit('update:markdown', markdown)
    } catch {
      // Ignore sync errors
    }
  }, 120)
}

const clearCurrentSuggestion = (view) => {
  clearGhostSuggestion(view)
}

const clearCurrentGhost = () => {
  if (!crepe) return
  crepe.editor.action((ctx) => {
    const view = ctx.get(editorViewCtx)
    clearGhostSuggestion(view)
  })
}

const broadcastCopilotEnabled = (enabled) => {
  if (typeof window === 'undefined') return
  window.__LLM_IN_TEXT_COPILOT_ENABLED__ = Boolean(enabled)
  window.dispatchEvent(new CustomEvent(COPILOT_TOGGLE_EVENT, { detail: { enabled: Boolean(enabled) } }))
}

const updateHistoryState = (view) => {
  canUndo.value = undoDepth(view.state) > 0
  canRedo.value = redoDepth(view.state) > 0
}

const serializeSelectionToMarkdown = (view, from, to) => {
  const state = view.state
  const slice = state.doc.slice(from, to)
  const doc = state.schema.topNodeType.createAndFill(undefined, slice.content)
  if (!doc) return state.doc.textBetween(from, to, '\n\n', '\n')
  return crepe?.editor?.action((ctx) => {
    const serializer = ctx.get(serializerCtx)
    return serializer(doc)
  }) || state.doc.textBetween(from, to, '\n\n', '\n')
}

const selectionIncludesDocBlock = (state) => {
  const { from, to } = state.selection
  let hasDocBlock = false
  state.doc.nodesBetween(from, to, (node) => {
    if (node.type?.name === DOC_BLOCK_NODE_TYPE) {
      hasDocBlock = true
      return false
    }
    return true
  })
  return hasDocBlock
}

const getCursorContext = (view) => {
  const { $from } = view.state.selection
  let inDocBlock = false
  let fenceLanguage = ''
  for (let depth = $from.depth; depth > 0; depth -= 1) {
    const node = $from.node(depth)
    const typeName = node.type?.name || ''
    if (typeName === DOC_BLOCK_NODE_TYPE || typeName === WEB_SEARCH_NODE_TYPE) {
      inDocBlock = true
      break
    }
    if (typeName === 'code_block' || typeName === 'codeBlock' || typeName === 'code_fence' || typeName === 'fence') {
      fenceLanguage = String(node.attrs?.language || node.attrs?.lang || node.attrs?.info || '').trim().toLowerCase()
      break
    }
  }
  const disabledByFence = fenceLanguage === 'mermaid' || fenceLanguage === 'tex' || fenceLanguage === 'latex' || fenceLanguage === 'katex'
  return {
    disabled: inDocBlock || disabledByFence,
    inDocBlock,
    fenceLanguage,
  }
}

const refreshDocUploadState = (view) => {
  isDocUploadDisabled.value = getCursorContext(view).disabled
}

const SKIP_TTS_TYPES = new Set([
  'code_block', 'codeBlock', 'code_fence', 'fence',
  'code_inline', 'codeInline',
  'math_inline', 'math_block', 'math_display', 'mathInline', 'mathBlock',
  'mermaid', 'mermaidBlock',
])

const extractSelectionText = (view, from, to) => {
  const { doc } = view.state
  const parts = []
  const pushBlockSeparator = () => {
    if (parts.length === 0) return
    const lastPart = parts[parts.length - 1]
    if (typeof lastPart === 'string' && lastPart.endsWith('\n')) return
    parts.push('\n')
  }

  doc.nodesBetween(from, to, (node, pos) => {
    if (SKIP_TTS_TYPES.has(node.type.name)) {
      return false
    }

    if (node.isTextblock && pos >= from) {
      pushBlockSeparator()
    }

    if (node.type.name === DOC_BLOCK_NODE_TYPE) {
      pushBlockSeparator()
      if (node.attrs.content) {
        parts.push(node.attrs.content)
      }
      return false
    }

    if (node.isText && node.text) {
      const nodeStart = pos
      const nodeEnd = pos + node.nodeSize
      const overlapStart = Math.max(nodeStart, from)
      const overlapEnd = Math.min(nodeEnd, to)

      if (overlapEnd > overlapStart) {
        const textStart = overlapStart - pos
        const textLen = overlapEnd - overlapStart
        parts.push(node.text.slice(textStart, textStart + textLen))
      }
    }

    return true
  })

  return parts.join('').trim()
}

const showTTSMenu = (event) => {
  if (!crepe) return

  const view = crepe.editor.action((ctx) => ctx.get(editorViewCtx))
  if (!view) return

  const { from, to } = view.state.selection

  if (from === to) {
    ttsMenuVisible.value = false
    return
  }

  if (isOverLimit.value) {
    ttsMenuVisible.value = false
    return
  }

  const text = extractSelectionText(view, from, to)
  if (!text) {
    ttsMenuVisible.value = false
    return
  }

  selectedText.value = text

  const domSelection = window.getSelection()
  if (domSelection.rangeCount > 0) {
    const range = domSelection.getRangeAt(0)
    const rect = range.getBoundingClientRect()

    ttsMenuX.value = rect.left + rect.width / 2
    ttsMenuY.value = rect.top - 8
    ttsMenuVisible.value = true
  }
}

const handleTTSSpeak = async () => {
  if (!selectedText.value || !crepe) return

  const view = crepe.editor.action((ctx) => ctx.get(editorViewCtx))
  const { from } = view.state.selection

  savedSelection.value = { from }

  const tr = view.state.tr.setSelection(Selection.near(view.state.doc.resolve(from)))
  view.dispatch(tr)

  ttsMenuVisible.value = false
  ttsLoading.value = true

  try {
    const response = await fetchTTS(selectedText.value, settings.ttsInstruct || '')
    ttsAudioBase64.value = response.audio_base64
    ttsFormat.value = response.format
    ttsDuration.value = response.duration_ms
    ttsPlayerVisible.value = true
  } catch (error) {
    console.error('TTS 失败:', error)
    alert('语音生成失败')
  } finally {
    ttsLoading.value = false
  }
}

const closeTTSPlayer = () => {
  ttsPlayerVisible.value = false
  ttsAudioBase64.value = ''
  ttsFormat.value = 'wav'
  ttsDuration.value = 0
  savedSelection.value = null
  selectedText.value = ''
}

const runHistoryCommand = (command) => {
  if (!crepe) return
  crepe.editor.action((ctx) => {
    const view = ctx.get(editorViewCtx)
    interruptCopilot(view)
    clearCurrentSuggestion(view)
    command(view.state, (tr) => view.dispatch(tr), view)
    updateHistoryState(view)
    view.focus()
  })
}

const handleUndo = () => {
  runHistoryCommand(undo)
}

const handleRedo = () => {
  runHistoryCommand(redo)
}

const isMarkdownFile = (file) => {
  if (!file) return false
  const name = (file.name || '').toLowerCase()
  const type = (file.type || '').toLowerCase()
  return MARKDOWN_EXT_RE.test(name) || type === 'text/markdown' || type === 'text/x-markdown'
}

const isImageFile = (file) => {
  if (!file) return false
  const name = (file.name || '').toLowerCase()
  const type = (file.type || '').toLowerCase()
  return type.startsWith('image/') || IMAGE_EXT_RE.test(name)
}

const isTextFile = (file) => {
  if (!file) return false
  const name = (file.name || '').toLowerCase()
  const type = (file.type || '').toLowerCase()
  return TEXT_EXT_RE.test(name) || TEXT_MIME_TYPES.has(type)
}

const isConvertibleFile = (file) => {
  if (!file) return false
  const name = (file.name || '').toLowerCase()
  const type = (file.type || '').toLowerCase()
  return CONVERT_EXT_RE.test(name) || CONVERT_MIME_TYPES.has(type)
}

const warnUnsupportedUploadType = () => {
  alert(t('uploadMdTypeWarning') || 'Only Markdown (.md) files and image files are supported.')
}

const warnUnsupportedInsertType = () => {
  alert(t('uploadFileTypeWarning') || '不支持的文件类型。支持：doc/docx/ppt/pptx/pdf/wav/mp3/m4a/ogg/flac, 图片, txt/json.')
}

const warnUploadError = (message = '') => {
  const base = t('uploadFileError') || 'File upload failed.'
  alert(message ? `${base}\n${message}` : base)
}

const warnConvertError = (message = '') => {
  const base = t('uploadConvertError') || 'File conversion failed.'
  alert(message ? `${base}\n${message}` : base)
}

const warnImageTooLarge = () => {
  const limitMB = Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)
  alert(t('imgTooLarge') || `Image too large. Max ${limitMB}MB.`)
}

const consumeSseResult = async (res) => {
  if (!res.ok) {
    const errorText = await res.text()
    throw new Error(`HTTP ${res.status}: ${errorText}`)
  }
  if (!res.body) {
    throw new Error('流式响应不可用')
  }

  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    buffer += decoder.decode(value, { stream: true })

    let boundary = buffer.indexOf('\n\n')
    while (boundary >= 0) {
      const chunk = buffer.slice(0, boundary)
      buffer = buffer.slice(boundary + 2)
      const lines = String(chunk).replace(/\r/g, '').split('\n')
      let event = 'message'
      const dataLines = []

      for (const line of lines) {
        if (!line) continue
        if (line.startsWith('event:')) {
          event = line.slice(6).trim() || 'message'
          continue
        }
        if (line.startsWith('data:')) {
          dataLines.push(line.slice(5).trimStart())
        }
      }

      const data = dataLines.length ? JSON.parse(dataLines.join('\n')) : {}
      if (event === 'done') {
        return data.result || data
      }
      if (event === 'error') {
        throw new Error(String(data.error || '请求失败'))
      }
      if (event === 'cancelled') {
        throw new Error('请求已取消')
      }

      boundary = buffer.indexOf('\n\n')
    }
  }

  throw new Error('未收到完成事件')
}

const performOCR = async (file, cacheKey, imageHash = '') => {
  if (!aiEnabled.value) return ''

  if (imageHash) {
    setOcrState(imageHash, OcrStatus.LOADING)
  }

  const dataUrl = await new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
    reader.onerror = () => reject(reader.error || new Error('读取图片失败'))
    reader.readAsDataURL(file)
  })

  const splitIndex = dataUrl.indexOf(',')
  if (splitIndex === -1) {
    throw new Error('图片编码失败')
  }

  const base64 = dataUrl.slice(splitIndex + 1)
  const headers = {
    'Content-Type': 'application/json',
  }

  try {
    const res = await fetch(OCR_URL, {
      method: 'POST',
      headers,
      credentials: 'include',
      body: JSON.stringify({
        image: base64,
        filename: file.name,
        language: 'auto',
        media_type: 'image',
        mime_type: file.type || 'image/png',
      }),
    })
    const data = await consumeSseResult(res)
    if (!data?.text) {
      throw new Error('OCR 返回结果为空')
    }
    setOcrCache(cacheKey, data.text)
    setOcrCache(file.name, data.text)
    if (imageHash) {
      setOcrByHash(imageHash, data.text)
      setOcrState(imageHash, OcrStatus.SUCCESS, data.text)
    }
    return data.text
  } catch (error) {
    if (imageHash) {
      setOcrState(imageHash, OcrStatus.FAILED, '', error?.message || 'OCR 识别失败')
    }
    throw error
  }
}

const prepareImageFile = async (file) => {
  if (!isImageFile(file)) {
    warnUnsupportedUploadType()
    return null
  }

  if (file.size > IMAGE_SIZE_LIMIT) {
    warnImageTooLarge()
    return null
  }

  const objectUrl = URL.createObjectURL(file)
  objectUrls.add(objectUrl)
  
  const arrayBuffer = await file.arrayBuffer()
  const imageBytes = new Uint8Array(arrayBuffer)
  const imageHash = await calculateImageHash(imageBytes)
  const existingOcr = getOcrByHash(imageHash)
  if (!existingOcr) {
    await performOCR(file, objectUrl, imageHash)
  } else {
    setOcrCache(objectUrl, existingOcr)
    setOcrCache(file.name, existingOcr)
    setOcrState(imageHash, OcrStatus.SUCCESS, existingOcr)
  }

  return objectUrl
}

const normalizeReplaceRange = (state, replaceRange = null) => {
  if (!replaceRange) {
    return {
      from: state.selection.from,
      to: state.selection.to,
    }
  }

  const docSize = state.doc.content.size
  const rawFrom = Number.isFinite(replaceRange.from) ? replaceRange.from : state.selection.from
  const rawTo = Number.isFinite(replaceRange.to) ? replaceRange.to : rawFrom
  const from = Math.max(0, Math.min(rawFrom, docSize))
  const to = Math.max(from, Math.min(rawTo, docSize))

  return { from, to }
}

const validateUploadFiles = (files, options = {}) => {
  const {
    batchLimit = MAX_UPLOAD_BATCH,
    enforceDocContext = true,
    allowedTypes = null,
  } = options

  if (files.length > batchLimit) {
    alert(t('uploadBatchLimit') || `一次最多上传${batchLimit}个文件`)
    return false
  }

  for (const file of files) {
    if (file.size > MAX_UPLOAD_FILE_SIZE) {
      alert(t('uploadSizeLimit') || `${file.name} 超过${MAX_UPLOAD_FILE_SIZE / 1024 / 1024}MB限制`)
      return false
    }
  }

  const docFiles = files.filter((file) => !isImageFile(file))
  if (docFiles.length > 0 && enforceDocContext && isDocUploadDisabled.value) {
    alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件')
    return false
  }

  for (const file of files) {
    const isSupported = isImageFile(file) || isSupportedDocFile(file)
    if (!isSupported) {
      alert(t('uploadFileTypeWarning') || '不支持的文件类型')
      return false
    }

    if (allowedTypes && !isUploadBlockTypeAllowed(file, allowedTypes)) {
      alert('该上传占位块不支持当前文件类型')
      return false
    }
  }

  return true
}

const parseDocFilesToBlocks = async (docFiles, progressOptions = {}) => {
  if (docFiles.length === 0) {
    return { blocksToInsert: [], errors: [] }
  }

  const total = Number.isFinite(progressOptions.total) ? progressOptions.total : docFiles.length
  const offset = Number.isFinite(progressOptions.offset) ? progressOptions.offset : 0
  uploadProgress.value = { current: offset, total, filename: '' }

  const results = []
  const errors = []

  const parsePromises = docFiles.map(async (file, index) => {
    const docType = getDocTypeFromFilename(file.name)
    let content = ''

    if (isTextFile(file)) {
      content = await file.text()
    } else if (isConvertibleFile(file)) {
      content = await convertFileToMarkdown(file)
    } else if (isAudioFile(file)) {
      content = await convertAudioToText(file)
    } else if (isVideoFile(file)) {
      content = await convertVideoToText(file)
    } else {
      throw new Error('不支持的文件类型')
    }

    if (!content) {
      throw new Error('文档解析结果为空')
    }

    return {
      docType,
      docName: file.name || `document.${docType}`,
      content,
      index,
    }
  })

  const settled = await Promise.allSettled(parsePromises)

  settled.forEach((result, idx) => {
    uploadProgress.value = { current: offset + idx + 1, total, filename: docFiles[idx].name }
    if (result.status === 'fulfilled') {
      results.push(result.value)
    } else {
      const message = result.reason instanceof Error ? result.reason.message : String(result.reason)
      errors.push({ filename: docFiles[idx].name, message })
    }
  })
  results.sort((a, b) => a.index - b.index)

  return {
    blocksToInsert: results.map(({ docType, docName, content }) => ({
      docType,
      docName,
      content,
      uploadTime: new Date().toISOString(),
      collapsed: false,
    })),
    errors,
  }
}

const processUploadedFiles = async (files, options = {}) => {
  const normalizedFiles = Array.isArray(files) ? files.filter(Boolean) : []
  if (normalizedFiles.length === 0) return false
  if (!validateUploadFiles(normalizedFiles, options)) return false

  clearCurrentGhost()

  const imageFiles = normalizedFiles.filter(isImageFile)
  const docFiles = normalizedFiles.filter((file) => !isImageFile(file))
  const replaceRange = options.replaceRange || null
  let rangeConsumed = false
  let progressIndex = 0
  const totalCount = normalizedFiles.length

  for (const file of imageFiles) {
    uploadProgress.value = { current: progressIndex + 1, total: totalCount, filename: `${file.name} · OCR` }
    try {
      const objectUrl = await prepareImageFile(file)
      if (!objectUrl) continue
      insertImageAtCursor(objectUrl, !rangeConsumed ? replaceRange : null)
      rangeConsumed = rangeConsumed || Boolean(replaceRange)
    } catch (error) {
      alert(`${file.name} OCR 失败：${error.message || '未知错误'}`)
    } finally {
      progressIndex += 1
    }
  }

  if (docFiles.length > 0) {
    const { blocksToInsert, errors } = await parseDocFilesToBlocks(docFiles, {
      offset: progressIndex,
      total: totalCount,
    })
    if (blocksToInsert.length > 0) {
      insertMultipleDocBlocks(blocksToInsert, !rangeConsumed ? replaceRange : null)
      rangeConsumed = rangeConsumed || Boolean(replaceRange)
    }

    if (errors.length > 0) {
      const failCount = errors.length
      const errorMsgs = errors.map((item) => `${item.filename}: ${item.message}`).join('\n')
      alert(`上传失败 ${failCount} 个文件：\n\n${errorMsgs}`)
    }
    progressIndex += docFiles.length
  }

  uploadProgress.value = null

  return true
}

const handleUploadBlockRequest = async ({ file, allowedTypes, from, to }) => {
  await processUploadedFiles([file], {
    batchLimit: 1,
    enforceDocContext: false,
    allowedTypes,
    replaceRange: { from, to },
  })
}

onMounted(async () => {
  resizeHandler = () => {
    viewportWidth.value = window.innerWidth
  }
  window.addEventListener('resize', resizeHandler)

  documentClickHandler = (event) => {
    const target = event.target
    if (!(target instanceof Element)) return

    if (!target.closest('.more-actions')) {
      closeMoreActions()
      showExportDropdown.value = false
      showTemplateDropdown.value = false
    }
  }

  document.addEventListener('click', documentClickHandler)
  document.addEventListener('keydown', handleMoreActionKeydown)


if (!root.value) throw new Error('root.value is null')

crepe = new Crepe({
        root: root.value,
        defaultValue: transformSpecialDocBlocksToLegacy(initialMarkdown.value || ''),
        features: {
            [Crepe.Feature.Latex]: true,
            [Crepe.Feature.ImageBlock]: true,
            [Crepe.Feature.Table]: true,
            [Crepe.Feature.ListCheck]: true,
        },
        featureConfigs: {
            [Crepe.Feature.Latex]: {
                katexOptions: {},
                inlineEditConfirm: 'Escape'
            },
            [Crepe.Feature.ImageBlock]: {
                onUpload: async (file) => {
                    const objectUrl = await prepareImageFile(file)
                    if (!objectUrl) return null
                    clearCurrentGhost()
                    return objectUrl
                }
            }
        },
        config: {
            showLineNumber: false,
        }
    })
    
    const settings = useSettingsStore()

    crepe.editor.config((ctx) => {
        ctx.set(copilotConfigCtx.key, {
            fetchSuggestion,
            debounceMs: getRecommendedDebounce(settings.debounceMs)
        })
    })

    crepe.editor.config((ctx) => {
      ctx.set(proBlockConfigCtx.key, {
        fetchSuggestionStream: fetchProSuggestionStream,
        t,
        showError: (message) => {
          alert(message || 'PRO 模式思考失败')
        },
      })
    })

    crepe.editor.config((ctx) => {
      ctx.set(uploadBlockConfigCtx.key, {
        requestUpload: handleUploadBlockRequest,
      })
    })

    crepe.editor.config((ctx) => {
      ctx.set(webSearchBlockConfigCtx.key, {
        fetchWebSearchStream,
        t,
        showError: (message) => {
          alert(message || '联网搜索失败')
        },
      })
    })

    crepe.editor.config((ctx) => {
        ctx.update(codeBlockConfig.key, (prev) => ({
            ...prev,
            renderPreview: mermaidRenderPreview,
        }))
    })

    watch(() => settings.debounceMs, (newVal) => {
        if (!crepe) return
        crepe.editor.action((ctx) => {
            const config = ctx.get(copilotConfigCtx.key)
            ctx.set(copilotConfigCtx.key, {
                ...config,
                debounceMs: getRecommendedDebounce(newVal)
            })
        })
    })
    
    crepe.editor.use(copilotConfigCtx)
    crepe.editor.use(proBlockConfigCtx)
    crepe.editor.use(uploadBlockConfigCtx)
    crepe.editor.use(webSearchBlockConfigCtx)
    crepe.editor.use(copilotGhostMark)
    crepe.editor.use(copilotPlugin)
    crepe.editor.use(proBlockRemark)
    crepe.editor.use(proBlockNode)
    crepe.editor.use(proBlockView)
    crepe.editor.use(proBlockInputPlugin)
    crepe.editor.use(proBlockHighlightPlugin)
    crepe.editor.use(hiddenTextRemark)
    crepe.editor.use(hiddenTextNode)
    crepe.editor.use(hiddenTextView)
    crepe.editor.use(hiddenTextInputPlugin)
    crepe.editor.use(uploadBlockRemark)
    crepe.editor.use(uploadBlockNode)
    crepe.editor.use(uploadBlockView)
    crepe.editor.use(uploadBlockInputPlugin)
    crepe.editor.use(webSearchBlockRemark)
    crepe.editor.use(webSearchBlockNode)
    crepe.editor.use(webSearchBlockView)
    crepe.editor.use(webSearchBlockInputPlugin)
    crepe.editor.use(docBlockRemark)
    crepe.editor.use(docBlockNode)
    crepe.editor.use(docBlockView)

    
  await crepe.create()

  crepe.editor.action((ctx) => {
    const view = ctx.get(editorViewCtx)
    const { doc } = view.state
    const endPos = doc.content.size
    const tr = view.state.tr.setSelection(Selection.near(doc.resolve(endPos), 1))
    view.dispatch(tr)
    view.focus()
  })

  crepe.on((listener) => {
        listener.updated((ctx, doc) => {
            const view = ctx.get(editorViewCtx)
            syncObjectUrls(doc)
            refreshSizeAndLimit(ctx)
            // Throttle history state updates (undo/redo depth) to save CPU on every keystroke
            if (!historyUpdateTimer) {
              historyUpdateTimer = setTimeout(() => {
                historyUpdateTimer = null
                updateHistoryState(view)
              }, 500)
            }
            refreshDocUploadState(view)
            scheduleMarkdownSync()
        })
    })

    crepe.editor.action((ctx) => {
        const view = ctx.get(editorViewCtx)
        setCopilotEnabled(view, aiEnabled.value)
        refreshSizeAndLimit(ctx)
        updateHistoryState(view)
        refreshDocUploadState(view)
        const editorDom = view.dom
        editorCopyHandler = (event) => {
            const state = view.state
            if (!selectionIncludesDocBlock(state)) return
            const { from, to } = state.selection
            const rawMarkdown = serializeSelectionToMarkdown(view, from, to)
            const clipboardMarkdown = transformDocBlockMarkdownForClipboard(rawMarkdown || '')
            if (!clipboardMarkdown) return
            event.preventDefault()
            event.clipboardData?.setData('text/plain', clipboardMarkdown)
        }
        editorDom.addEventListener('copy', editorCopyHandler)

        ttsMouseUpHandler = (event) => {
          setTimeout(() => {
            showTTSMenu(event)
          }, 10)
        }
        editorDom.addEventListener('mouseup', ttsMouseUpHandler)

        ttsClickOutsideHandler = (event) => {
          if (!event.target.closest('.tts-menu') && !event.target.closest('.tts-player')) {
            ttsMenuVisible.value = false
          }
        }
        document.addEventListener('mousedown', ttsClickOutsideHandler)
    })
    broadcastCopilotEnabled(aiEnabled.value)
    scheduleMarkdownSync()
})

const exportMarkdown = async () => {
    try {
        const markdown = await getExportMarkdown()
        const exportName = createExportName()
        const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
        downloadBlob(blob, `${exportName}.md`)
    } catch (error) {
        console.error('Markdown export failed:', error)
        alert(`Markdown 导出失败: ${error.message}`)
    }
}

const resolveEditorExportElement = () => {
    const editorShell = root.value?.querySelector('.milkdown-editor .milkdown')
      || root.value?.querySelector('.milkdown-editor')
      || root.value
    if (!(editorShell instanceof HTMLElement)) {
        throw new Error('未找到可导出的编辑器内容')
    }
    return editorShell
}

const exportDocx = async () => {
    try {
        console.log('Exporting DOCX from expanded editor HTML...')
        const blob = await exportEditorToDocxBlob(resolveEditorExportElement())
        const exportName = createExportName()
        downloadBlob(blob, `${exportName}.docx`)
        console.log('DOCX export completed')
    } catch (error) {
        console.error('DOCX export failed:', error)
        alert(`DOCX导出失败: ${error.message}`)
    }
}

const exportPdf = async () => {
    try {
        console.log('Exporting PDF from expanded editor HTML...')
        const exportName = createExportName()
        await exportEditorToPdf(resolveEditorExportElement(), `${exportName}.pdf`)
        console.log('PDF export completed successfully')
    } catch (error) {
        console.error('PDF export failed:', error)
        alert(`PDF导出失败: ${error.message}`)
    }
}

const triggerUpload = () => {
    if (isDocUploadDisabled.value) return
    uploadInputRef.value?.click()
}

const handleUpload = async (event) => {
    const input = event.target
    const files = Array.from(input.files || [])
    if (files.length > 0) {
      await processUploadedFiles(files)
  }

    input.value = ''
}

const triggerImportMd = () => {
    mdInputRef.value?.click()
}

const handleImportMd = async (event) => {
    const input = event.target
    const file = input.files?.[0]
    if (!file) return

    if (!isMarkdownFile(file)) {
        alert(t('uploadMdTypeWarning') || '仅支持 Markdown（.md）文件')
        input.value = ''
        return
    }

    try {
        const text = await file.text()
        if (crepe && crepe.editor) {
            crepe.editor.action(replaceAll(transformSpecialDocBlocksToLegacy(text)))
        }
    } catch {
        // ignore
    }

    input.value = ''
}

const toggleAI = async () => {
    if (isOverLimit.value || !crepe) return
    
    aiEnabled.value = !aiEnabled.value

    crepe.editor.action((ctx) => {
        const view = ctx.get(editorViewCtx)
        setCopilotEnabled(view, aiEnabled.value)
        if (!aiEnabled.value) {
            interruptCopilot(view)
            clearGhostSuggestion(view)
        }
    })

    broadcastCopilotEnabled(aiEnabled.value)
}

const toggleExportDropdown = () => {
  showExportDropdown.value = !showExportDropdown.value
  if (showExportDropdown.value) {
    showTemplateDropdown.value = false
  }
}

const insertImageAtCursor = (src, replaceRange = null) => {
    if (!crepe || !src) return
    
    crepe.editor.action((ctx) => {
        const view = ctx.get(editorViewCtx)
        const { state } = view
        const { schema } = state
    const { from, to } = normalizeReplaceRange(state, replaceRange)
        
        const imageType = schema.nodes.image
        if (!imageType) return
        
        const imageNode = imageType.create({ src })
        const tr = state.tr.replaceRangeWith(from, to, imageNode)
        const cursorPos = Math.min(from + imageNode.nodeSize, tr.doc.content.size)
        tr.setSelection(Selection.near(tr.doc.resolve(cursorPos), 1))
        view.dispatch(tr.scrollIntoView())
    })
}

const insertMarkdownAtCursor = (markdown) => {
    if (!crepe || !markdown) return
    crepe.editor.action((ctx) => {
        const view = ctx.get(editorViewCtx)
        const { from, to } = view.state.selection
        replaceRange(markdown, { from, to })(ctx)
        view.focus()
    })
}

const insertDocBlockAtCursor = (attrs) => {
    if (!crepe) return
    crepe.editor.action((ctx) => {
        const view = ctx.get(editorViewCtx)
        const { state } = view
        const { from, to } = state.selection
        const docBlockType = state.schema.nodes[DOC_BLOCK_NODE_TYPE]
        if (!docBlockType) return

        const blockNode = docBlockType.create({
            docType: attrs.docType,
            docName: attrs.docName,
            uploadTime: attrs.uploadTime,
            content: attrs.content,
            collapsed: Boolean(attrs.collapsed),
        })
        const tr = state.tr.replaceRangeWith(from, to, blockNode)
        const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
        tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
        view.dispatch(tr.scrollIntoView())
  view.focus()
  })
}

const insertEmptyParagraph = () => {
  if (!crepe) return
  crepe.editor.action((ctx) => {
    const view = ctx.get(editorViewCtx)
    const { state } = view
    const { from, to } = state.selection
    const tr = state.tr.insertText('\n\n', from, to)
    const nextPos = from + 2
    tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
    view.dispatch(tr)
  })
}

const insertMultipleDocBlocks = (blocks, replaceRange = null) => {
  if (!crepe || blocks.length === 0) return

  crepe.editor.action((ctx) => {
    const view = ctx.get(editorViewCtx)
    let tr = view.state.tr
    const docBlockType = view.state.schema.nodes[DOC_BLOCK_NODE_TYPE]
    if (!docBlockType) return

    const initialRange = normalizeReplaceRange(view.state, replaceRange)
    let currentPos = initialRange.from
    let currentTo = initialRange.to

 blocks.forEach((block) => {
 const maxPos = tr.doc.content.size
 const insertPos = Math.min(currentPos, maxPos)
 const replaceTo = Math.max(insertPos, Math.min(currentTo, maxPos))

 const blockNode = docBlockType.create({
 docType: block.docType,
 docName: block.docName,
 uploadTime: block.uploadTime,
 content: block.content,
 collapsed: Boolean(block.collapsed),
 })

 tr = tr.replaceRangeWith(insertPos, replaceTo, blockNode)
 currentPos = insertPos + blockNode.nodeSize
 currentTo = currentPos
 })

    const finalPos = Math.min(currentPos, tr.doc.content.size)
    if (finalPos >= 0 && finalPos <= tr.doc.content.size) {
      tr.setSelection(Selection.near(tr.doc.resolve(finalPos), 1))
    }
    view.dispatch(tr.scrollIntoView())
    view.focus()
  })
}

onUnmounted(() => {
    if (markdownSyncTimer) {
      clearTimeout(markdownSyncTimer)
      markdownSyncTimer = null
    }
    if (historyUpdateTimer) {
      clearTimeout(historyUpdateTimer)
      historyUpdateTimer = null
    }

for (const url of Array.from(objectUrls)) {
        revokeObjectUrl(url)
    }

    clearAllOcrCache()
    if (crepe) {
        if (editorCopyHandler) {
            crepe.editor.action((ctx) => {
                ctx.get(editorViewCtx).dom.removeEventListener('copy', editorCopyHandler)
            })
            editorCopyHandler = null
        }
        if (ttsMouseUpHandler) {
            crepe.editor.action((ctx) => {
                ctx.get(editorViewCtx).dom.removeEventListener('mouseup', ttsMouseUpHandler)
            })
            ttsMouseUpHandler = null
        }
        crepe.destroy()
        crepe = null
    }
    if (ttsClickOutsideHandler) {
        document.removeEventListener('mousedown', ttsClickOutsideHandler)
        ttsClickOutsideHandler = null
    }

    if (documentClickHandler) {
      document.removeEventListener('click', documentClickHandler)
      documentClickHandler = null
    }
    if (resizeHandler) {
      window.removeEventListener('resize', resizeHandler)
      resizeHandler = null
    }
    finishMoreActionsClosing()
    document.removeEventListener('keydown', handleMoreActionKeydown)
})
</script>

<style scoped>
.editor-container {
    position: relative;
    width: 100%;
    height: 100dvh;
    min-height: 0;
}

.history-buttons {
  position: fixed;
  bottom: 20px;
  left: 80px;
  display: flex;
  gap: 6px;
  z-index: 9000;
}

.history-btn {
    width: 34px;
    height: 34px;
    padding: 8px;
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    background: var(--btn-bg);
    color: var(--btn-fg);
    box-shadow: var(--panel-shadow);
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0.72;
}

.history-btn:hover:not(:disabled) {
    background-color: var(--btn-hover-bg);
    color: var(--btn-hover-fg);
    border-color: var(--btn-hover-bg);
    opacity: 1;
}

.history-btn:disabled {
    background-color: var(--btn-disabled-bg);
    color: var(--btn-disabled-fg);
    border-color: var(--btn-disabled-bg);
    cursor: not-allowed;
    opacity: 0.6;
    box-shadow: none;
}

.action-btn {
    width: 44px;
    height: 44px;
    padding: 10px;
    background-color: var(--btn-bg);
    color: var(--btn-fg);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: var(--panel-shadow);
    opacity: 0.8;
}

.action-btn:hover {
    background-color: var(--btn-hover-bg);
    color: var(--btn-hover-fg);
    border-color: var(--btn-hover-bg);
    opacity: 1;
}

.action-btn.ai-disabled {
    background-color: var(--crepe-color-surface-low);
    color: var(--crepe-color-on-background);
    border-color: var(--panel-border);
}

.action-btn.ai-disabled:hover {
    background-color: var(--btn-hover-bg);
    color: var(--btn-hover-fg);
    border-color: var(--btn-hover-bg);
}

.action-btn.force-disabled {
    background-color: var(--btn-disabled-bg);
    color: var(--btn-disabled-fg);
    border-color: var(--btn-disabled-bg);
    cursor: not-allowed;
    opacity: 0.6;
}

.action-btn.force-disabled:hover {
    background-color: var(--btn-disabled-bg);
    color: var(--btn-disabled-fg);
    border-color: var(--btn-disabled-bg);
    opacity: 0.6;
}

.size-indicator {
  position: fixed;
  right: 78px;
  bottom: 28px;
  z-index: 9000;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  padding: 4px 10px;
  font-size: 10px;
  color: var(--muted-text);
  border-radius: 12px;
  transition: all 0.3s ease;
  cursor: default;
}

.size-indicator.over-limit {
  color: var(--danger-text);
  background: rgba(220, 38, 38, 0.08);
  animation: pulse-warning 2s ease-in-out infinite;
}

.warning-icon {
  flex-shrink: 0;
  opacity: 0;
  transition: opacity 0.2s ease;
}

.warning-icon--visible {
  opacity: 1;
}

@keyframes pulse-warning {
  0%, 100% {
    opacity: 1;
    background: rgba(220, 38, 38, 0.08);
  }
  50% {
    opacity: 0.75;
    background: rgba(220, 38, 38, 0.12);
  }
}

.size-tooltip {
  position: absolute;
  bottom: 100%;
  right: 0;
  margin-bottom: 8px;
  padding: 8px 12px;
  background: var(--tooltip-bg);
  color: var(--tooltip-fg);
  border-radius: 6px;
  font-size: 12px;
  white-space: nowrap;
  box-shadow: var(--panel-shadow);
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.size-tooltip strong {
  font-weight: 600;
}

.size-tooltip span {
  opacity: 0.85;
  font-size: 11px;
}

.tooltip-fade-enter-active,
.tooltip-fade-leave-active {
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.tooltip-fade-enter-from,
.tooltip-fade-leave-to {
  opacity: 0;
  transform: translateY(4px);
}

.more-actions {
  position: fixed;
  right: 20px;
  bottom: 20px;
  z-index: 99999;
  display: flex;
  align-items: flex-end;
  justify-content: flex-end;
  pointer-events: none;
}

.more-actions.is-closing {
  pointer-events: none;
}

.more-actions__toggle {
  position: fixed;
  right: 20px;
  bottom: 20px;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 1px solid var(--panel-border);
  border-radius: 999px;
  background: var(--btn-bg);
  color: var(--btn-fg);
  box-shadow: var(--panel-shadow);
  cursor: pointer;
  pointer-events: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  z-index: 100000;
  opacity: 0.92;
  transform: translateZ(0);
  transition:
    transform 360ms cubic-bezier(.2,.8,.2,1),
    background-color 180ms ease,
    border-color 180ms ease,
    color 180ms ease;
}

.more-actions__toggle:hover {
  background-color: var(--btn-hover-bg);
  color: var(--btn-hover-fg);
  border-color: var(--btn-hover-bg);
  opacity: 1;
}

.more-actions__toggle.is-open {
  background: var(--btn-hover-bg);
  color: var(--btn-hover-fg);
  border-color: var(--btn-hover-bg);
  transform: rotate(90deg) scale(1.08);
}

.more-actions__dot {
  width: 5px;
  height: 5px;
  border-radius: 999px;
  background: currentColor;
  transition: transform 260ms ease, opacity 260ms ease, width 260ms ease;
}

.more-actions__dot:nth-child(1) {
  transform: translateX(-5px);
}

.more-actions__dot:nth-child(2) {
  opacity: 0.35;
}

.more-actions__dot:nth-child(3) {
  transform: translateX(5px);
}

.more-actions__toggle.is-open .more-actions__dot:nth-child(1) {
  transform: translateX(0) rotate(-90deg);
  width: 14px;
}

.more-actions__toggle.is-open .more-actions__dot:nth-child(2) {
  opacity: 0;
}

.more-actions__toggle.is-open .more-actions__dot:nth-child(3) {
  transform: translateX(0) rotate(90deg);
  width: 14px;
}

.more-actions__backdrop {
  position: fixed;
  inset: 0;
  z-index: 99998;
  background: radial-gradient(circle at calc(100% - 44px) calc(100% - 44px), rgba(15, 23, 42, 0.08), transparent 180px);
  pointer-events: auto;
}

.more-actions__panel-shell {
  position: fixed;
  right: 20px;
  bottom: 20px;
  z-index: 99999;
  pointer-events: none;
}

.more-actions__panel {
  position: absolute;
  right: 24px;
  bottom: 24px;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 14px;
  border: 1px solid color-mix(in srgb, var(--panel-border) 78%, rgba(255, 255, 255, 0.28));
  border-radius: 24px;
  background:
    linear-gradient(145deg, color-mix(in srgb, var(--panel-bg) 88%, rgba(255, 255, 255, 0.16)), color-mix(in srgb, var(--panel-bg) 95%, rgba(15, 23, 42, 0.08)));
  box-shadow:
    0 22px 52px rgba(15, 23, 42, 0.16),
    0 0 0 1px rgba(255, 255, 255, 0.08) inset,
    0 0 24px rgba(59, 130, 246, 0.12);
  backdrop-filter: blur(18px) saturate(120%);
  opacity: 0;
  transform: translate(16px, 16px) scale(0.76);
  transform-origin: calc(100% - 24px) calc(100% - 24px);
  filter: blur(2px);
  pointer-events: auto;
}

.more-actions.is-horizontal .more-actions__panel {
  right: calc(48px + 12px);
  flex-direction: row;
}

.more-actions.is-vertical .more-actions__panel {
  flex-direction: column;
  align-items: stretch;
  padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
}

.more-actions__item {
  position: relative;
  display: inline-flex;
}

.more-action-btn {
  position: relative;
  width: 48px;
  height: 48px;
  padding: 10px;
  flex: 0 0 auto;
  pointer-events: auto;
  opacity: 0;
  transform: scale(0.78) translateY(8px);
  filter: blur(2px);
  transition:
    opacity 280ms ease,
    transform 520ms cubic-bezier(.2,.8,.2,1),
    filter 360ms ease,
    background-color 180ms ease,
    border-color 180ms ease,
    color 180ms ease;
  transition-delay: var(--more-action-delay, 0ms);
}

.more-actions:not(.is-open) .more-action-btn {
  pointer-events: none;
}

.more-actions.is-open .more-actions__panel {
  animation: moreActionsPanelIn 440ms cubic-bezier(.2,.8,.2,1) both;
}

.more-actions.is-closing .more-actions__panel {
  animation: moreActionsPanelOut 300ms cubic-bezier(.4,0,.2,1) both;
}

.more-actions.is-open .more-action-btn,
.more-actions.is-closing .more-action-btn {
  opacity: 1;
}

.more-action-btn:hover {
  background-color: var(--btn-hover-bg);
  color: var(--btn-hover-fg);
  border-color: var(--btn-hover-bg);
  opacity: 1;
}

.more-action-btn.ai-disabled {
  background-color: var(--crepe-color-surface-low);
  color: var(--crepe-color-on-background);
  border-color: var(--panel-border);
}

.more-action-btn.ai-disabled:hover {
  background-color: var(--btn-hover-bg);
  color: var(--btn-hover-fg);
  border-color: var(--btn-hover-bg);
}

.more-action-btn.force-disabled {
  background-color: var(--btn-disabled-bg);
  color: var(--btn-disabled-fg);
  border-color: var(--btn-disabled-bg);
  cursor: not-allowed;
  opacity: 0.6;
}

.more-action-btn.force-disabled:hover {
  background-color: var(--btn-disabled-bg);
  color: var(--btn-disabled-fg);
  border-color: var(--btn-disabled-bg);
  opacity: 0.6;
}

.more-actions.is-open .more-action-btn {
  animation: moreActionPop 520ms cubic-bezier(.2,.8,.2,1) both;
  animation-delay: var(--more-action-delay, 0ms);
}

@keyframes moreActionPop {
  0% {
    opacity: 0;
    transform: scale(0.45) translateY(12px);
    filter: blur(2px);
  }
  55% {
    opacity: 1;
    transform: scale(1.08) translateY(-2px);
    filter: blur(0);
  }
  100% {
    opacity: 1;
    transform: scale(1) translateY(0);
    filter: blur(0);
  }
}

.more-actions.is-closing .more-action-btn {
  animation: moreActionClose 360ms cubic-bezier(.4,0,.2,1) both;
  animation-delay: 0ms;
  transition-delay: 0ms;
}

@keyframes moreActionClose {
  0% {
    opacity: 1;
    transform: scale(1) translateY(0);
    filter: blur(0);
  }
  100% {
    opacity: 0;
    transform: scale(0.74) translateY(10px);
    filter: blur(2px);
  }
}

.more-actions.is-open .more-actions__submenu {
  animation: moreDropdownIn 260ms ease both;
}

@keyframes moreDropdownIn {
  from {
    opacity: 0;
    transform: translateY(6px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

@keyframes moreActionsPanelIn {
  0% {
    opacity: 0;
    transform: translate(20px, 20px) scale(0.72);
    filter: blur(4px);
  }
  62% {
    opacity: 1;
    transform: translate(-4px, -4px) scale(1.03);
    filter: blur(0);
  }
  100% {
    opacity: 1;
    transform: translate(0, 0) scale(1);
    filter: blur(0);
  }
}

@keyframes moreActionsPanelOut {
  0% {
    opacity: 1;
    transform: translate(0, 0) scale(1);
    filter: blur(0);
  }
  100% {
    opacity: 0;
    transform: translate(14px, 14px) scale(0.82);
    filter: blur(3px);
  }
}


.action-btn {
    position: relative;
}

.btn-tooltip {
    position: absolute;
    top: 50%;
    right: 100%;
    transform: translateY(-50%);
    margin-right: 8px;
    background: var(--tooltip-bg);
    color: var(--tooltip-fg);
    font-size: 12px;
    padding: 4px 8px;
    border-radius: 4px;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.2s;
}

.action-btn:hover .btn-tooltip {
    opacity: 1;
}

.action-btn:focus-visible .btn-tooltip {
    opacity: 1;
}

.image-btn-wrapper {
    position: relative;
}

.image-dropdown {
    position: absolute;
    bottom: 100%;
    right: 0;
    margin-bottom: 8px;
    background: var(--panel-bg);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    box-shadow: var(--panel-shadow);
    overflow: hidden;
    z-index: 10000;
    min-width: 160px;
}

.image-dropdown button {
    display: block;
    width: 100%;
    padding: 10px 16px;
    border: none;
    background: none;
    text-align: left;
    cursor: pointer;
    font-size: 14px;
    color: var(--app-text);
}

.image-dropdown button:hover {
    background: var(--crepe-color-hover);
}

.export-dropdown {
    position: absolute;
    right: 0;
    background: var(--panel-bg);
    border: 1px solid var(--panel-border);
    border-radius: 8px;
    box-shadow: var(--panel-shadow);
    overflow: hidden;
    z-index: 10000;
    min-width: 160px;
}

.export-dropdown button {
    display: block;
    width: 100%;
    padding: 10px 16px;
    border: none;
    background: none;
    text-align: left;
    cursor: pointer;
    font-size: 14px;
    color: var(--app-text);
}

.export-dropdown button:hover {
    background: var(--crepe-color-hover);
}

.more-actions__submenu {
  min-width: 160px;
}

.more-actions.is-horizontal .more-actions__submenu {
  bottom: calc(100% + 10px);
  margin-bottom: 0;
}

.more-actions.is-horizontal .export-dropdown {
  top: auto;
  bottom: calc(100% + 10px);
}

.more-actions.is-horizontal .template-dropdown {
  top: auto;
  bottom: calc(100% + 10px);
}

.more-actions.is-vertical .more-actions__submenu {
  right: calc(100% + 10px);
  bottom: 0;
  margin-bottom: 0;
}

.template-dropdown {
  position: absolute;
  min-width: 260px;
  max-width: min(360px, calc(100vw - 40px));
  background: var(--panel-bg);
  border: 1px solid var(--panel-border);
  border-radius: 12px;
  box-shadow: var(--panel-shadow);
  overflow: hidden;
  z-index: 100000;
  display: flex;
  flex-direction: column;
  opacity: 1 !important;
}

.template-dropdown-section {
  padding: 8px 0;
}

.template-dropdown-title {
  padding: 0 16px 8px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted-text);
}

.template-dropdown-item {
  display: flex;
  width: 100%;
  padding: 10px 16px;
  border: none;
  background: none;
  text-align: left;
  cursor: pointer;
  font-size: 14px;
  color: var(--app-text);
}

.template-dropdown-item:hover {
  background: var(--crepe-color-hover);
}

.template-dropdown-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.template-dropdown-empty {
  margin: 0;
  padding: 0 16px 8px;
  font-size: 12px;
  color: var(--muted-text);
}

.template-dropdown-footer {
  padding: 10px 16px 14px;
  border-top: 1px solid var(--panel-border);
}

.template-dropdown-action {
  width: 100%;
  padding: 10px 12px;
  border: 1px dashed var(--panel-border);
  border-radius: 10px;
  background: transparent;
  color: var(--app-text);
  cursor: pointer;
}

.template-dropdown-action:hover {
  background: var(--crepe-color-hover);
  border-color: var(--btn-hover-bg);
}

.template-dialog-overlay {
  position: fixed;
  inset: 0;
  background: var(--overlay-bg);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100001;
  padding: 20px;
}

.template-dialog {
  width: min(720px, calc(100vw - 40px));
  max-height: calc(100vh - 40px);
  overflow: auto;
  background: var(--panel-bg);
  border: 1px solid var(--panel-border);
  border-radius: 16px;
  box-shadow: var(--panel-shadow);
  padding: 20px;
}

.template-dialog-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 12px;
}

.template-dialog-kicker {
  margin: 0 0 4px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted-text);
}

.template-dialog-header h3 {
  margin: 0;
  font-size: 18px;
  color: var(--app-text);
}

.template-dialog-close {
  width: 32px;
  height: 32px;
  border: 1px solid transparent;
  border-radius: 8px;
  background: transparent;
  color: var(--muted-text);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}

.template-dialog-close:hover {
  background: var(--crepe-color-hover);
  color: var(--app-text);
}

.template-dialog-meta {
  margin: 0 0 12px;
  font-size: 13px;
  color: var(--muted-text);
}

.template-preview-textarea {
  width: 100%;
  min-height: 300px;
  box-sizing: border-box;
  padding: 14px 16px;
  border: 1px solid var(--panel-border);
  border-radius: 12px;
  background: var(--crepe-color-background);
  color: var(--app-text);
  font-family: inherit;
  line-height: 1.6;
  resize: vertical;
}

.template-form {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.template-form-label {
  font-size: 13px;
  color: var(--muted-text);
}

.template-form-input,
.template-form-textarea {
  width: 100%;
  box-sizing: border-box;
  padding: 12px 14px;
  border: 1px solid var(--panel-border);
  border-radius: 12px;
  background: var(--crepe-color-background);
  color: var(--app-text);
  font-size: 14px;
}

.template-form-textarea {
  min-height: 300px;
  resize: vertical;
}

.template-form-input:focus,
.template-form-textarea:focus {
  outline: none;
  border-color: var(--focus-ring);
}

.template-dialog-error {
  margin: 0;
  font-size: 13px;
  color: var(--danger-text);
}

.template-dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 16px;
  flex-wrap: wrap;
}

.dialog-btn.danger {
  background: rgba(220, 38, 38, 0.08);
  color: var(--danger-text);
  border-color: rgba(220, 38, 38, 0.2);
}

.dialog-btn.danger:hover {
  background: rgba(220, 38, 38, 0.14);
}

.url-dialog-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: var(--overlay-bg);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 10001;
}

.url-dialog {
    background: var(--panel-bg);
    padding: 20px;
    border-radius: 8px;
    border: 1px solid var(--panel-border);
    box-shadow: var(--panel-shadow);
    min-width: 320px;
}

.url-dialog h3 {
    margin: 0 0 12px 0;
    font-size: 16px;
    color: var(--app-text);
}

.url-dialog input {
    width: 100%;
    box-sizing: border-box;
    padding: 10px 12px;
    border: 1px solid var(--panel-border);
    border-radius: 4px;
    font-size: 14px;
    margin-bottom: 16px;
    color: var(--app-text);
    background: var(--crepe-color-background);
}

.url-dialog input:focus {
    outline: none;
    border-color: var(--focus-ring);
}

.url-dialog-buttons {
    display: flex;
    justify-content: flex-end;
    gap: 8px;
}

.dialog-btn {
    padding: 8px 16px;
    border: 1px solid var(--panel-border);
    border-radius: 4px;
    cursor: pointer;
    font-size: 14px;
    background: var(--btn-bg);
    color: var(--btn-fg);
}

.dialog-btn:hover {
    background: var(--crepe-color-hover);
}

.dialog-btn.primary {
    background: var(--btn-hover-bg);
    color: var(--btn-hover-fg);
    border-color: var(--btn-hover-bg);
}

.dialog-btn.primary:hover {
    filter: brightness(0.92);
}

.milkdown-editor {
  width: 100%;
  height: 100dvh;
  min-height: 0;
  background-color: transparent !important;
  overflow-y: auto;
  overscroll-behavior-y: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--scrollbar-thumb) transparent;
}

.milkdown-editor :deep(.milkdown) {
    max-width: none;
    margin: 0 !important;
    padding: 0 40px !important;
    min-height: 0;
    height: auto !important;
}

.milkdown-editor :deep(.milkdown__main) {
    margin: 0 !important;
    padding: 0 !important;
    width: 100% !important;
}

.milkdown-editor :deep(.milkdown__editor) {
    margin: 0 !important;
    padding: 0 !important;
}

.milkdown-editor :deep(.milkdown > *:first-child) {
    margin-top: 0 !important;
    padding-top: 0 !important;
}

.milkdown-editor :deep(.ProseMirror) {
  margin: 0 !important;
  padding: 10px 0 24px 0 !important;
  min-height: 0;
  height: auto !important;
  overflow-x: hidden;
}

.milkdown-editor :deep(.ProseMirror img) {
    max-width: 60%;
    height: auto;
}

.milkdown-editor :deep(.ProseMirror > *:first-child) {
    margin-top: 0 !important;
}

.milkdown-editor :deep(.cm-scroller) {
  overflow-x: hidden;
  overflow-y: auto;
  overscroll-behavior-y: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--scrollbar-thumb) transparent;
}

.milkdown-editor :deep(.cm-editor) {
  min-height: 0;
  height: 100%;
  overflow: hidden;
}

.milkdown-editor :deep(.milkdown__aside),
.milkdown-editor :deep(.milkdown__aside-wrapper),
.milkdown-editor :deep([class*="aside"]),
.milkdown-editor :deep([class*="line-number"]),
.milkdown-editor :deep([class*="gutter"]),
.milkdown-editor :deep([class*="sidebar"]) {
    display: none !important;
    width: 0 !important;
    min-width: 0 !important;
    max-width: 0 !important;
}

.milkdown-editor::-webkit-scrollbar {
    width: 8px;
}

.milkdown-editor::-webkit-scrollbar-track {
    background: transparent;
}

.milkdown-editor::-webkit-scrollbar-thumb {
    background-color: var(--scrollbar-thumb);
    border-radius: 4px;
}

.milkdown-editor::-webkit-scrollbar-thumb:hover {
    background-color: var(--scrollbar-thumb-hover);
}

.milkdown-editor :deep(.milkdown__toolbar),
.milkdown-editor :deep(.milkdown__menu),
.milkdown-editor :deep(.milkdown__statusbar),
.milkdown-editor :deep(.milkdown-slate-toolbar),
.milkdown-editor :deep(.milkdown-bubble-menu),
.milkdown-editor :deep([class*="toolbar"]),
.milkdown-editor :deep([class*="menu"]) {
    display: none !important;
    visibility: hidden !important;
    height: 0 !important;
    width: 0 !important;
}

.milkdown-editor :deep(.milkdown__block-handle),
.milkdown-editor :deep([class*="block-handle"]),
.milkdown-editor :deep([class*="blockHandle"]) {
    display: none !important;
    visibility: hidden !important;
    width: 0 !important;
    min-width: 0 !important;
}
</style>

<style>
.copilot-ghost-text {
    color: var(--ghost-text);
    opacity: 0.72;
    pointer-events: auto;
    transition: color 0.12s ease, opacity 0.12s ease, background-color 0.12s ease;
}

.copilot-ghost-text.copilot-loading {
    opacity: 0.4;
}

.copilot-ghost-text strong,
.copilot-ghost-text em,
.copilot-ghost-text code,
.copilot-ghost-text a {
    color: inherit;
    opacity: inherit;
}

.copilot-ghost-text code {
    background-color: var(--ghost-code-bg);
    padding: 0.2em 0.4em;
    border-radius: 3px;
}

.copilot-ghost-text a {
    text-decoration: underline;
}

.copilot-ghost-block {
    color: var(--ghost-text);
    opacity: 0.72;
    transition: color 0.12s ease, opacity 0.12s ease, background-color 0.12s ease;
}

.copilot-ghost-block code,
.copilot-ghost-block pre,
.copilot-ghost-block a {
    color: inherit;
    opacity: inherit;
}

.copilot-ghost-block pre,
.copilot-ghost-block code {
  background-color: var(--ghost-code-bg);
}

.pro-block-accepted-highlight {
  background: transparent;
}

.pro-block-accepted-block {
  background: transparent;
  border-radius: 0;
}

.upload-progress-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.upload-progress-dialog {
  background: var(--editor-bg, white);
  padding: 24px 32px;
  border-radius: 8px;
  text-align: center;
  max-width: 400px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}

.spinner {
  width: 40px;
  height: 40px;
  margin: 0 auto 16px;
  border: 3px solid #f3f3f3;
  border-top: 3px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.progress-text {
  font-size: 18px;
  font-weight: 600;
  margin: 8px 0;
}

.filename {
  font-size: 14px;
  color: #666;
  word-break: break-all;
}
</style>
