import * as React from 'react'
import { EditorPane, type MarkdownEditorHandle } from './EditorPane'
import type { EditorView } from '@codemirror/view'
import { MarkdownFormatToolbar } from './MarkdownFormatToolbar'
import { PreviewArticle } from './PreviewArticle'
import {
  extractOutline,
  previewLineCenterInShell,
  renderMarkdown,
  scrollPreviewToSourceLine,
  type OutlineItem
} from './markdown'
import {
  addFileHistory,
  loadFileHistory,
  removeFileHistory
} from './fileHistory'
import { BACKGROUNDS, FONTS, validBackgroundId, validFontId, type ViewMode } from './appearance'
import { COLOR_SWATCH_ROWS } from './colorPresets'
import type { Tab } from './types'
import { clamp, newTab, tabLabel, isDirty, loadViewMode, viewModeLabel, loadPreviewLineHeight, normalizePathKey } from './utils'
import {
  BG_KEY, FONT_KEY, VIEW_KEY, BROWSE_KEY, SIDEBAR_W_KEY, SIDEBAR_HIDDEN_KEY,
  STATS_W_KEY, STATS_HIDDEN_KEY, SPLIT_KEY, PREVIEW_W_KEY, PREVIEW_LINE_KEY, SESSION_KEY, AUTO_SAVE_KEY
} from './constants'
import { TabBar } from './TabBar'
import { Sidebar } from './Sidebar'
import { StatsPanel } from './StatsPanel'

type SessionTabRow = { path: string | null; content: string; saved: string }
type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string }

export default function App(): React.ReactElement {
  const api = window.myreader
  const editorRef = React.useRef<MarkdownEditorHandle | null>(null)
  const [tabs, setTabs] = React.useState<Tab[]>([])
  const [activeId, setActiveId] = React.useState<string>('')
  const [sessionHydrated, setSessionHydrated] = React.useState(false)
  const [viewMode, setViewMode] = React.useState<ViewMode>(() => loadViewMode())
  const [bgId, setBgId] = React.useState<string>(() => {
    return (
      validBackgroundId(localStorage.getItem(BG_KEY)) ??
      validBackgroundId(localStorage.getItem('myreader-theme')) ??
      validBackgroundId(localStorage.getItem('myreader-bg')) ??
      '1'
    )
  })
  const [fontId, setFontId] = React.useState<string>(() => {
    return validFontId(localStorage.getItem(FONT_KEY)) ?? '0'
  })
  const [previewLineHeight, setPreviewLineHeight] = React.useState<string>(() => loadPreviewLineHeight())
  const [browseDir, setBrowseDir] = React.useState<string>('')
  const [dirEntries, setDirEntries] = React.useState<
    { name: string; path: string; kind: 'file' | 'dir' }[]
  >([])
  const [dirError, setDirError] = React.useState<string | null>(null)
  const [sidebarTab, setSidebarTab] = React.useState<'files' | 'history' | 'outline'>('files')
  const [gotoLine, setGotoLine] = React.useState<{ line: number; seq: number } | null>(null)
  const [parentDisabled, setParentDisabled] = React.useState(false)
  const [sidebarHidden, setSidebarHidden] = React.useState<boolean>(
    () => localStorage.getItem(SIDEBAR_HIDDEN_KEY) === '1'
  )
  const [sidebarWidth, setSidebarWidth] = React.useState<number>(() => {
    const n = Number(localStorage.getItem(SIDEBAR_W_KEY) ?? '260')
    return Number.isFinite(n) ? clamp(n, 160, 560) : 260
  })
  const [statsWidth, setStatsWidth] = React.useState<number>(() => {
    const n = Number(localStorage.getItem(STATS_W_KEY) ?? '220')
    return Number.isFinite(n) ? clamp(n, 160, 400) : 220
  })
  const [statsHidden, setStatsHidden] = React.useState<boolean>(
    () => localStorage.getItem(STATS_HIDDEN_KEY) === '1'
  )
  const [splitPct, setSplitPct] = React.useState<number>(() => {
    const n = Number(localStorage.getItem(SPLIT_KEY) ?? '48')
    return Number.isFinite(n) ? clamp(n, 22, 78) : 48
  })
  const [previewMaxWidth, setPreviewMaxWidth] = React.useState<number>(() => {
    const n = Number(localStorage.getItem(PREVIEW_W_KEY) ?? '880')
    return Number.isFinite(n) ? clamp(n, 420, 1400) : 880
  })
  const [closePromptTabId, setClosePromptTabId] = React.useState<string | null>(null)
  const [ctxMenu, setCtxMenu] = React.useState<{
    x: number
    y: number
    path: string
    kind: 'file' | 'dir'
  } | null>(null)
  const [textPrompt, setTextPrompt] = React.useState<{
    title: string
    defaultValue: string
    resolve: (v: string | null) => void
  } | null>(null)
  const [textPromptInput, setTextPromptInput] = React.useState('')
  const [imageInsert, setImageInsert] = React.useState<{
    resolve: (v: { url: string; alt: string } | null) => void
  } | null>(null)
  const [imageUrlInput, setImageUrlInput] = React.useState('')
  const [imageAltInput, setImageAltInput] = React.useState('图片')
  const [colorPicker, setColorPicker] = React.useState<{
    resolve: (v: string | null) => void
  } | null>(null)
  const [colorHexInput, setColorHexInput] = React.useState('#dc2626')
  const [fileHistory, setFileHistory] = React.useState<string[]>(() => loadFileHistory())
  const [histCtxMenu, setHistCtxMenu] = React.useState<{
    x: number
    y: number
    path: string
  } | null>(null)
  const readPreviewOuterRef = React.useRef<HTMLDivElement | null>(null)
  const readPreviewArticleRef = React.useRef<HTMLElement | null>(null)
  const hybridPreviewRef = React.useRef<HTMLDivElement | null>(null)
  const hybridPreviewShellRef = React.useRef<HTMLDivElement | null>(null)
  const hybridPreviewArticleRef = React.useRef<HTMLElement | null>(null)
  const hybridEditorViewRef = React.useRef<EditorView | null>(null)
  const [hybridViewEpoch, setHybridViewEpoch] = React.useState(0)
  const [hybridLinePulse, setHybridLinePulse] = React.useState<{
    topPx: number
    seq: number
  } | null>(null)
  const [autoSave, setAutoSave] = React.useState<boolean>(
    () => localStorage.getItem(AUTO_SAVE_KEY) === '1'
  )
  const [statusText, setStatusText] = React.useState('')
  const [searchVisible, setSearchVisible] = React.useState(false)
  const [searchQuery, setSearchQuery] = React.useState('')
  const [dragOver, setDragOver] = React.useState(false)

  const activeTab = tabs.find((t) => t.id === activeId) ?? null
  const activeDoc = activeTab?.content ?? ''
  const activeLineCount = React.useMemo(
    () => (activeDoc ? activeDoc.split(/\r?\n/).length : 1),
    [activeDoc]
  )

  const tabsRef = React.useRef(tabs)
  tabsRef.current = tabs

  const refreshDir = React.useCallback(async (): Promise<void> => {
    if (!api || !browseDir) {
      setDirEntries([])
      return
    }
    const res = await api.listDirectory(browseDir)
    setDirError(res.error ?? null)
    setDirEntries(res.entries)
    if (api) {
      const parent = await api.parentDirectory(browseDir)
      setParentDisabled(parent === browseDir)
    }
  }, [api, browseDir])

  React.useEffect(() => {
    void refreshDir()
  }, [refreshDir])

  React.useEffect(() => {
    if (!api) return
    void (async () => {
      const stored = localStorage.getItem(BROWSE_KEY)
      if (stored) {
        setBrowseDir(stored)
        return
      }
      const d = await api.defaultDirectory()
      setBrowseDir(d)
    })()
  }, [api])

  React.useEffect(() => {
    if (!api) {
      setSessionHydrated(true)
      return
    }
    let cancelled = false
    void (async () => {
      try {
        const raw = localStorage.getItem(SESSION_KEY)
        if (!raw) return
        const data = JSON.parse(raw) as Partial<SessionV1>
        if (data.v !== 1 || !Array.isArray(data.tabs)) return
        const restored: Tab[] = []
        for (const row of data.tabs) {
          if (!row || typeof row !== 'object') continue
          const id = crypto.randomUUID()
          const content = typeof row.content === 'string' ? row.content : ''
          const saved = typeof row.saved === 'string' ? row.saved : content
          if (row.path) {
            try {
              await api.readFile(row.path)
              restored.push({ id, filePath: row.path, content, savedContent: saved })
            } catch {
              /* missing or unreadable */
            }
          } else {
            restored.push({ id, filePath: null, content, savedContent: saved })
          }
        }
        if (cancelled) return
        setTabs(restored)
        if (restored.length) {
          let idx = typeof data.activeIndex === 'number' ? data.activeIndex : 0
          if (!Number.isFinite(idx)) idx = 0
          idx = Math.max(0, Math.min(idx, restored.length - 1))
          setActiveId(restored[idx]!.id)
        }
        if (typeof data.bgId === 'string') {
          const b = validBackgroundId(data.bgId)
          if (b) setBgId(b)
        }
        if (typeof data.fontId === 'string') {
          const f = validFontId(data.fontId)
          if (f) setFontId(f)
        }
      } catch {
        /* ignore corrupt session */
      } finally {
        if (!cancelled) setSessionHydrated(true)
      }
    })()
    return () => {
      cancelled = true
    }
  }, [api])

  React.useEffect(() => {
    if (!sessionHydrated) return
    const id = window.setTimeout(() => {
      const activeIndex =
        tabs.length === 0 ? 0 : Math.max(0, tabs.findIndex((t) => t.id === activeId))
      const idx =
        tabs.length === 0
          ? 0
          : activeIndex < 0
            ? 0
            : Math.min(activeIndex, tabs.length - 1)
      const payload: SessionV1 = {
        v: 1,
        activeIndex: idx,
        tabs: tabs.map((t) => ({
          path: t.filePath,
          content: t.content,
          saved: t.savedContent
        })),
        bgId,
        fontId
      }
      localStorage.setItem(SESSION_KEY, JSON.stringify(payload))
    }, 400)
    return () => window.clearTimeout(id)
  }, [tabs, activeId, sessionHydrated, bgId, fontId])

  React.useEffect(() => {
    localStorage.setItem(BG_KEY, bgId)
    document.documentElement.dataset.bg = bgId
  }, [bgId])

  React.useEffect(() => {
    localStorage.setItem(FONT_KEY, fontId)
    document.documentElement.dataset.font = fontId
  }, [fontId])

  React.useEffect(() => {
    localStorage.setItem(PREVIEW_LINE_KEY, previewLineHeight)
    document.documentElement.style.setProperty('--preview-line-height', previewLineHeight)
  }, [previewLineHeight])

  React.useEffect(() => {
    localStorage.setItem(VIEW_KEY, viewMode)
  }, [viewMode])

  React.useEffect(() => {
    localStorage.setItem(SIDEBAR_HIDDEN_KEY, sidebarHidden ? '1' : '0')
  }, [sidebarHidden])

  React.useEffect(() => {
    localStorage.setItem(SIDEBAR_W_KEY, String(sidebarWidth))
  }, [sidebarWidth])

  React.useEffect(() => {
    localStorage.setItem(STATS_W_KEY, String(statsWidth))
  }, [statsWidth])

  React.useEffect(() => {
    localStorage.setItem(STATS_HIDDEN_KEY, statsHidden ? '1' : '0')
  }, [statsHidden])

  React.useEffect(() => {
    localStorage.setItem(SPLIT_KEY, String(splitPct))
  }, [splitPct])

  React.useEffect(() => {
    localStorage.setItem(PREVIEW_W_KEY, String(previewMaxWidth))
  }, [previewMaxWidth])

  React.useEffect(() => {
    if (browseDir) localStorage.setItem(BROWSE_KEY, browseDir)
  }, [browseDir])

  React.useEffect(() => {
    localStorage.setItem(AUTO_SAVE_KEY, autoSave ? '1' : '0')
  }, [autoSave])

  React.useEffect(() => {
    if (!activeDoc) {
      setStatusText('')
      return
    }
    setStatusText('')
  }, [activeDoc, activeLineCount])

  React.useEffect(() => {
    const onDoc = (): void => {
      setCtxMenu(null)
      setHistCtxMenu(null)
    }
    window.addEventListener('mousedown', onDoc)
    window.addEventListener('scroll', onDoc, true)
    return () => {
      window.removeEventListener('mousedown', onDoc)
      window.removeEventListener('scroll', onDoc, true)
    }
  }, [])

  React.useEffect(() => {
    if (textPrompt) setTextPromptInput(textPrompt.defaultValue)
  }, [textPrompt])

  React.useEffect(() => {
    if (imageInsert) {
      setImageUrlInput('')
      setImageAltInput('图片')
    }
  }, [imageInsert])

  React.useEffect(() => {
    if (colorPicker) setColorHexInput('#dc2626')
  }, [colorPicker])

  React.useEffect(() => {
    if (viewMode !== 'hybrid') setHybridLinePulse(null)
  }, [viewMode])

  const requestText = React.useCallback((title: string, defaultValue: string) => {
    return new Promise<string | null>((resolve) => {
      setTextPrompt({ title, defaultValue, resolve })
    })
  }, [])

  const requestImageDetails = React.useCallback((): Promise<{ url: string; alt: string } | null> => {
    return new Promise((resolve) => {
      setImageInsert({ resolve })
    })
  }, [])

  const requestColor = React.useCallback((): Promise<string | null> => {
    return new Promise((resolve) => {
      setColorPicker({ resolve })
    })
  }, [])

  React.useEffect(() => {
    if (viewMode !== 'hybrid') return
    const view = hybridEditorViewRef.current
    const prevEl = hybridPreviewRef.current
    if (!view || !prevEl) return

    const edScroll = view.scrollDOM
    const maxScroll = (el: HTMLElement): number => Math.max(1, el.scrollHeight - el.clientHeight)
    let syncing = false

    const syncFromEditor = (): void => {
      if (syncing) return
      syncing = true
      const re = edScroll.scrollTop / maxScroll(edScroll)
      prevEl.scrollTop = re * maxScroll(prevEl)
      requestAnimationFrame(() => {
        syncing = false
      })
    }
    const syncFromPreview = (): void => {
      if (syncing) return
      syncing = true
      const rp = prevEl.scrollTop / maxScroll(prevEl)
      edScroll.scrollTop = rp * maxScroll(edScroll)
      requestAnimationFrame(() => {
        syncing = false
      })
    }

    edScroll.addEventListener('scroll', syncFromEditor, { passive: true })
    prevEl.addEventListener('scroll', syncFromPreview, { passive: true })
    requestAnimationFrame(() => {
      syncFromEditor()
    })
    return () => {
      edScroll.removeEventListener('scroll', syncFromEditor)
      prevEl.removeEventListener('scroll', syncFromPreview)
    }
  }, [viewMode, hybridViewEpoch, activeId])

  React.useEffect(() => {
    if (viewMode !== 'hybrid') return
    const view = hybridEditorViewRef.current
    const prevEl = hybridPreviewRef.current
    if (!view || !prevEl) return
    requestAnimationFrame(() => {
      const edScroll = view.scrollDOM
      const maxE = Math.max(1, edScroll.scrollHeight - edScroll.clientHeight)
      const maxP = Math.max(1, prevEl.scrollHeight - prevEl.clientHeight)
      const r = edScroll.scrollTop / maxE
      prevEl.scrollTop = r * maxP
    })
  }, [viewMode, activeDoc])

  const onHybridViewReady = React.useCallback((view: EditorView | null) => {
    hybridEditorViewRef.current = view
    setHybridViewEpoch((e) => e + 1)
  }, [])

  const onHybridCursorLine = React.useCallback(
    (line: number) => {
      requestAnimationFrame(() => {
        const article = hybridPreviewArticleRef.current
        const shell = hybridPreviewShellRef.current
        if (!article || !shell) return
        const topPx = previewLineCenterInShell(article, shell, line, activeLineCount)
        if (topPx === null) return
        setHybridLinePulse({ topPx, seq: Date.now() })
      })
    },
    [activeLineCount]
  )

  const navigateToOutline = React.useCallback(
    (item: OutlineItem) => {
      const line = item.line
      if (viewMode === 'read') {
        requestAnimationFrame(() => {
          const scrollEl = readPreviewOuterRef.current
          const article = readPreviewArticleRef.current
          if (scrollEl && article) {
            scrollPreviewToSourceLine(scrollEl, article, line, 'smooth', activeLineCount)
          }
        })
        return
      }
      setGotoLine({ line, seq: Date.now() })
      if (viewMode === 'hybrid') {
        requestAnimationFrame(() => {
          const scrollEl = hybridPreviewRef.current
          const article = hybridPreviewArticleRef.current
          const shell = hybridPreviewShellRef.current
          if (scrollEl && article) {
            scrollPreviewToSourceLine(scrollEl, article, line, 'smooth', activeLineCount)
          }
          if (article && shell) {
            const topPx = previewLineCenterInShell(article, shell, line, activeLineCount)
            if (topPx !== null) setHybridLinePulse({ topPx, seq: Date.now() })
          }
        })
      }
    },
    [viewMode, activeLineCount]
  )

  const outline = React.useMemo(() => extractOutline(activeDoc), [activeDoc])

  const previewHtml = React.useMemo(
    () => renderMarkdown(activeDoc, { baseFilePath: activeTab?.filePath ?? null }),
    [activeDoc, activeTab?.filePath]
  )

  const recordFileOpen = React.useCallback((path: string): void => {
    if (!/\.(md|markdown)$/i.test(path)) return
    setFileHistory((prev) => addFileHistory(prev, path))
  }, [])

  const pickLocalImage = React.useCallback(async (): Promise<void> => {
    if (!api) return
    const overlay = document.querySelector<HTMLElement>('.modal-overlay')
    if (overlay) overlay.style.visibility = 'hidden'
    try {
      const r = await api.openImageDialog()
      if (!r.canceled && r.url) setImageUrlInput(r.url)
    } finally {
      if (overlay) overlay.style.visibility = ''
    }
  }, [api])

  const updateActive = React.useCallback(
    (fn: (t: Tab) => Tab): void => {
      if (!activeId) return
      setTabs((prev) => prev.map((t) => (t.id === activeId ? fn(t) : t)))
    },
    [activeId]
  )

  const onEditorChange = React.useCallback(
    (next: string) => {
      updateActive((t) => ({ ...t, content: next }))
    },
    [updateActive]
  )

  const editorMountKey = activeTab ? `${activeTab.id}-${viewMode}` : `empty-${viewMode}`

  const scrollPreviewToAnchorId = React.useCallback(
    (anchorId: string, mode: ViewMode = viewMode): void => {
      const id = decodeURIComponent(anchorId)
      if (!id) return
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          const article =
            mode === 'read' ? readPreviewArticleRef.current : hybridPreviewArticleRef.current
          if (!article) return
          let el: Element | null = null
          try {
            el = article.querySelector(`#${CSS.escape(id)}`)
          } catch {
            el = article.querySelector(`#${id}`)
          }
          if (!el) el = document.getElementById(id)
          el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
        })
      })
    },
    [viewMode]
  )

  const openPathInTab = React.useCallback(
    async (path: string, anchorId?: string): Promise<void> => {
      if (!api) return
      try {
        const key = normalizePathKey(path)
        const existing = tabsRef.current.find(
          (t) => t.filePath && normalizePathKey(t.filePath) === key
        )
        if (existing) {
          setActiveId(existing.id)
          setBrowseDir(await api.parentDirectory(path))
          recordFileOpen(path)
          if (anchorId) scrollPreviewToAnchorId(anchorId)
          return
        }
        const content = await api.readFile(path)
        const parent = await api.parentDirectory(path)
        setBrowseDir(parent)
        const nt: Tab = { id: crypto.randomUUID(), filePath: path, content, savedContent: content }
        setTabs((prev) => [...prev, nt])
        setActiveId(nt.id)
        setViewMode('read')
        recordFileOpen(path)
        if (anchorId) scrollPreviewToAnchorId(anchorId, 'read')
      } catch (e) {
        const msg = e instanceof Error ? e.message : String(e)
        window.alert(`无法打开文件：${msg}`)
      }
    },
    [api, recordFileOpen, scrollPreviewToAnchorId]
  )

  const openFileDialog = React.useCallback(async (): Promise<void> => {
    if (!api) return
    const r = await api.openFileDialog()
    if (r.canceled || !r.path || r.content === undefined) return
    const parent = await api.parentDirectory(r.path)
    setBrowseDir(parent)
    const existing = tabsRef.current.find(
      (t) => t.filePath && normalizePathKey(t.filePath) === normalizePathKey(r.path!)
    )
    if (existing) {
      setActiveId(existing.id)
      recordFileOpen(r.path)
      return
    }
    setViewMode('read')
    const nt: Tab = {
      id: crypto.randomUUID(),
      filePath: r.path,
      content: r.content,
      savedContent: r.content
    }
    setTabs((prev) => [...prev, nt])
    setActiveId(nt.id)
    recordFileOpen(r.path)
  }, [api, recordFileOpen])

  const saveTabById = React.useCallback(
    async (tabId: string): Promise<boolean> => {
      if (!api) return false
      const t = tabsRef.current.find((x) => x.id === tabId)
      if (!t) return false
      if (!t.filePath) {
        const d = await api.saveFileDialog()
        if (d.canceled || !d.path) return false
        await api.writeFile(d.path, t.content)
        setTabs((prev) =>
          prev.map((x) => (x.id === tabId ? { ...x, filePath: d.path!, savedContent: x.content } : x))
        )
        return true
      }
      await api.writeFile(t.filePath, t.content)
      setTabs((prev) =>
        prev.map((x) => (x.id === tabId ? { ...x, savedContent: x.content } : x))
      )
      return true
    },
    [api]
  )

  const saveActive = React.useCallback(async (): Promise<void> => {
    if (!activeId) return
    await saveTabById(activeId)
  }, [activeId, saveTabById])

  React.useEffect(() => {
    if (!autoSave || !activeId) return
    const t = tabsRef.current.find((x) => x.id === activeId)
    if (!t || !t.filePath || !isDirty(t)) return
    const timer = window.setTimeout(() => {
      void saveTabById(activeId)
      setStatusText('已自动保存')
      window.setTimeout(() => setStatusText(''), 2000)
    }, 3000)
    return () => window.clearTimeout(timer)
  }, [activeDoc, autoSave, activeId, saveTabById])

  const saveActiveAs = React.useCallback(async (): Promise<void> => {
    if (!api) return
    const t = tabsRef.current.find((x) => x.id === activeId)
    if (!t) return
    const d = await api.saveFileDialog(t.filePath ?? undefined)
    if (d.canceled || !d.path) return
    await api.writeFile(d.path, t.content)
    setTabs((prev) =>
      prev.map((x) =>
        x.id === activeId ? { ...x, filePath: d.path!, savedContent: x.content } : x
      )
    )
  }, [api, activeId])

  const removeTabById = React.useCallback((tabId: string): void => {
    setTabs((prev) => {
      if (prev.length <= 1) {
        setActiveId('')
        return []
      }
      const idx = prev.findIndex((x) => x.id === tabId)
      if (idx < 0) return prev
      const next = prev.filter((x) => x.id !== tabId)
      const pick = next[Math.min(idx, next.length - 1)]!
      setActiveId(pick.id)
      return next
    })
  }, [])

  const requestCloseTab = React.useCallback(
    (tabId: string): void => {
      const t = tabsRef.current.find((x) => x.id === tabId)
      if (!t) return
      if (!isDirty(t)) {
        removeTabById(tabId)
        return
      }
      setClosePromptTabId(tabId)
    },
    [removeTabById]
  )

  const closeOtherTabs = React.useCallback(
    (tabId: string): void => {
      setTabs((prev) => prev.filter((t) => t.id === tabId))
      setActiveId(tabId)
    },
    []
  )

  const closeAllTabs = React.useCallback((): void => {
    setTabs([])
    setActiveId('')
  }, [])

  const newTabAction = React.useCallback((): void => {
    setViewMode('edit')
    const nt = newTab()
    setTabs((prev) => [...prev, nt])
    setActiveId(nt.id)
  }, [])

  const closeTabAction = React.useCallback((): void => {
    requestCloseTab(activeId)
  }, [activeId, requestCloseTab])

  const tabNext = React.useCallback((): void => {
    if (tabs.length === 0) return
    const idx = tabs.findIndex((x) => x.id === activeId)
    const n = tabs[(idx + 1) % tabs.length]
    if (n) setActiveId(n.id)
  }, [activeId, tabs])

  const tabPrev = React.useCallback((): void => {
    if (tabs.length === 0) return
    const idx = tabs.findIndex((x) => x.id === activeId)
    const n = tabs[(idx - 1 + tabs.length) % tabs.length]
    if (n) setActiveId(n.id)
  }, [activeId, tabs])

  const cycleViewMode = React.useCallback((): void => {
    setViewMode((m) => (m === 'edit' ? 'read' : m === 'read' ? 'hybrid' : 'edit'))
  }, [])

  const openFolderDialog = React.useCallback(async (): Promise<void> => {
    if (!api) return
    const r = await api.openFolderDialog()
    if (r.canceled || !r.path) return
    setBrowseDir(r.path)
  }, [api])

  const startSidebarResize = React.useCallback((e: React.MouseEvent): void => {
    e.preventDefault()
    const startX = e.clientX
    const startW = sidebarWidth
    const onMove = (ev: MouseEvent): void => {
      setSidebarWidth(clamp(startW + (ev.clientX - startX), 160, 560))
    }
    const onUp = (): void => {
      document.removeEventListener('mousemove', onMove)
      document.removeEventListener('mouseup', onUp)
    }
    document.addEventListener('mousemove', onMove)
    document.addEventListener('mouseup', onUp)
  }, [sidebarWidth])

  const startStatsResize = React.useCallback((e: React.MouseEvent): void => {
    e.preventDefault()
    const startX = e.clientX
    const startW = statsWidth
    const onMove = (ev: MouseEvent): void => {
      setStatsWidth(clamp(startW - (ev.clientX - startX), 160, 400))
    }
    const onUp = (): void => {
      document.removeEventListener('mousemove', onMove)
      document.removeEventListener('mouseup', onUp)
    }
    document.addEventListener('mousemove', onMove)
    document.addEventListener('mouseup', onUp)
  }, [statsWidth])

  const startHybridSplitResize = React.useCallback((e: React.MouseEvent): void => {
    e.preventDefault()
    const shell = (e.currentTarget as HTMLElement).closest('.hybrid-row')
    if (!shell) return
    const onMove = (ev: MouseEvent): void => {
      const rect = shell.getBoundingClientRect()
      const x = ev.clientX - rect.left
      const pct = clamp((x / Math.max(1, rect.width)) * 100, 22, 78)
      setSplitPct(pct)
    }
    const onUp = (): void => {
      document.removeEventListener('mousemove', onMove)
      document.removeEventListener('mouseup', onUp)
    }
    document.addEventListener('mousemove', onMove)
    document.addEventListener('mouseup', onUp)
  }, [])

  const startPreviewWidthResize = React.useCallback(
    (e: React.MouseEvent): void => {
      e.preventDefault()
      e.stopPropagation()
      const startX = e.clientX
      const startW = previewMaxWidth
      const onMove = (ev: MouseEvent): void => {
        setPreviewMaxWidth(clamp(startW + (ev.clientX - startX), 420, 1400))
      }
      const onUp = (): void => {
        document.removeEventListener('mousemove', onMove)
        document.removeEventListener('mouseup', onUp)
      }
      document.addEventListener('mousemove', onMove)
      document.addEventListener('mouseup', onUp)
    },
    [previewMaxWidth]
  )

  const handleDrop = React.useCallback(
    async (e: React.DragEvent): Promise<void> => {
      e.preventDefault()
      e.stopPropagation()
      setDragOver(false)
      const files = e.dataTransfer.files
      if (!files || files.length === 0) return
      for (let i = 0; i < files.length; i++) {
        const file = files[i]
        if (!file) continue
        if (/\.(md|markdown|mdown|mkd|txt)$/i.test(file.name)) {
          try {
            const content = await file.text()
            const filePath: string | null = (file as { path?: string }).path ?? null
            if (filePath) {
              const key = normalizePathKey(filePath)
              const existing = tabsRef.current.find(
                (t) => t.filePath && normalizePathKey(t.filePath) === key
              )
              if (existing) {
                setActiveId(existing.id)
                if (api) {
                  const parent = await api.parentDirectory(filePath)
                  setBrowseDir(parent)
                }
                continue
              }
            }
            const nt: Tab = {
              id: crypto.randomUUID(),
              filePath: filePath ?? file.name,
              content,
              savedContent: content
            }
            setTabs((prev) => [...prev, nt])
            setActiveId(nt.id)
            setViewMode('read')
            if (filePath && api) {
              const parent = await api.parentDirectory(filePath)
              setBrowseDir(parent)
            }
          } catch {
            /* ignore */
          }
        }
      }
    },
    [api]
  )

  const handleDragOver = React.useCallback((e: React.DragEvent): void => {
    e.preventDefault()
    e.stopPropagation()
    setDragOver(true)
  }, [])

  const handleDragLeave = React.useCallback((e: React.DragEvent): void => {
    e.preventDefault()
    const target = e.currentTarget as HTMLElement | null
    const related = e.relatedTarget as HTMLElement | null
    if (target && related && target.contains(related)) return
    setDragOver(false)
  }, [])

  const exportHtml = React.useCallback((): void => {
    if (!activeDoc.trim()) return
    const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${activeTab?.filePath ? tabLabel(activeTab) : '导出文档'}</title>
<style>
body { max-width: 860px; margin: 40px auto; padding: 0 20px; font-family: Georgia, 'Noto Serif SC', serif; font-size: 16px; line-height: 1.7; color: #333; }
img { max-width: 100%; }
pre { background: #f5f5f5; padding: 16px; border-radius: 6px; overflow-x: auto; }
code { font-family: 'Consolas', monospace; font-size: 0.9em; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
blockquote { border-left: 4px solid #ddd; margin: 0; padding: 0 16px; color: #666; }
</style>
</head>
<body>
${previewHtml}
</body>
</html>`
    const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `${activeTab?.filePath ? tabLabel(activeTab).replace(/\.\w+$/, '') : 'document'}.html`
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    URL.revokeObjectURL(url)
    setStatusText('已导出 HTML')
    window.setTimeout(() => setStatusText(''), 2000)
  }, [activeDoc, activeTab, previewHtml])

  const deleteFileAt = React.useCallback(
    async (filePath: string): Promise<void> => {
      if (!api) return
      const ok = window.confirm(`确定删除文件？\n${filePath}`)
      if (!ok) return
      const curActive = activeId
      await api.deleteFile(filePath)
      setTabs((prev) => {
        const next = prev.filter((t) => t.filePath !== filePath)
        if (next.length === 0) {
          setActiveId('')
          return []
        }
        if (!next.some((t) => t.id === curActive)) {
          setActiveId(next[0]!.id)
        }
        return next
      })
      setCtxMenu(null)
      void refreshDir()
    },
    [api, activeId, refreshDir]
  )

  const renamePathEntry = React.useCallback(
    async (oldPath: string): Promise<void> => {
      if (!api) return
      const parts = oldPath.split(/[/\\]/)
      const base = parts[parts.length - 1] ?? oldPath
      const nn = await requestText('新名称（文件需带扩展名）', base)
      if (nn === null || nn.trim() === '' || nn.trim() === base) return
      const { newPath } = await api.renamePath(oldPath, nn.trim())
      setTabs((prev) =>
        prev.map((t) => (t.filePath === oldPath ? { ...t, filePath: newPath } : t))
      )
      setBrowseDir((cur) => {
        if (!cur) return cur
        if (cur === oldPath) return newPath
        if (cur.startsWith(oldPath)) return newPath + cur.slice(oldPath.length)
        return cur
      })
      setCtxMenu(null)
      void refreshDir()
    },
    [api, refreshDir, requestText]
  )

  const handlersRef = React.useRef({
    openFileDialog: async (): Promise<void> => {},
    saveActive: async (): Promise<void> => {},
    saveActiveAs: async (): Promise<void> => {},
    newTabAction: (): void => {},
    closeTabAction: (): void => {},
    tabNext: (): void => {},
    tabPrev: (): void => {}
  })
  handlersRef.current = {
    openFileDialog,
    saveActive,
    saveActiveAs,
    newTabAction,
    closeTabAction,
    tabNext,
    tabPrev
  }

  React.useEffect(() => {
    if (!api) return
    const unsubClose = api.onRequestClose(() => {
      try {
        const anyDirty = tabsRef.current.some(isDirty)
        if (!anyDirty) {
          api.allowClose()
          return
        }
        const ok = window.confirm('有未保存的标签，确定退出并丢弃更改？')
        if (ok) api.allowClose()
      } catch (e) {
        console.error('[MyReader] request-close handler', e)
        api.allowClose()
      }
    })
    const unsubMenu = api.onMenuAction((action) => {
      void (async () => {
        try {
          const h = handlersRef.current
          switch (action) {
          case 'file-open':
            await h.openFileDialog()
            break
          case 'file-save':
            await h.saveActive()
            break
          case 'file-save-as':
            await h.saveActiveAs()
            break
          case 'tab-new':
            h.newTabAction()
            break
          case 'tab-close':
            h.closeTabAction()
            break
          case 'tab-next':
            h.tabNext()
            break
          case 'tab-prev':
            h.tabPrev()
            break
          case 'toggle-read':
            cycleViewMode()
            break
          case 'help-about':
            window.alert(
              " Huangzhijun's Reader 0.1 — 本地 Markdown 阅读与编辑 \n \n 作者：黄志军 \n 支持与协助(wx)：liaofan199404"
            )
            break
          default:
            break
        }
        } catch (e) {
          console.error('[MyReader] menu action', action, e)
        }
      })()
    })
    return () => {
      unsubClose()
      unsubMenu()
    }
  }, [api, cycleViewMode])

  React.useEffect(() => {
    if (!activeTab?.filePath || !api) return
    void api.parentDirectory(activeTab.filePath).then(setBrowseDir)
  }, [activeId, activeTab?.filePath, api])

  React.useEffect(() => {
    const onKeyDown = (e: KeyboardEvent): void => {
      if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
        e.preventDefault()
        setSearchVisible((v) => !v)
      }
      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
        e.preventDefault()
        void saveActive()
      }
      if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
        e.preventDefault()
        newTabAction()
      }
      if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
        e.preventDefault()
        requestCloseTab(activeId)
      }
      if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
        e.preventDefault()
        cycleViewMode()
      }
      if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
        e.preventDefault()
        setSidebarHidden((v) => !v)
      }
      if ((e.ctrlKey || e.metaKey) && e.key === 'o') {
        e.preventDefault()
        void openFileDialog()
      }
      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
        e.preventDefault()
        void saveActiveAs()
      }
      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'e') {
        e.preventDefault()
        exportHtml()
      }
      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'f') {
        e.preventDefault()
        setAutoSave((v) => !v)
      }
      if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') {
        e.preventDefault()
        const idx = tabs.findIndex((t) => t.id === activeId)
        if (e.shiftKey) {
          const prev = idx <= 0 ? tabs.length - 1 : idx - 1
          setActiveId(tabs[prev]?.id ?? activeId)
        } else {
          const next = idx >= tabs.length - 1 ? 0 : idx + 1
          setActiveId(tabs[next]?.id ?? activeId)
        }
      }
      if ((e.ctrlKey || e.metaKey) && /^[1-9]$/.test(e.key)) {
        e.preventDefault()
        const num = Number.parseInt(e.key, 10)
        const target = tabs[num - 1]
        if (target) setActiveId(target.id)
      }
      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'o') {
        e.preventDefault()
        void openFolderDialog()
      }
      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'r') {
        e.preventDefault()
        void refreshDir()
      }
      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '1') {
        e.preventDefault()
        setSidebarTab('files')
      }
      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '2') {
        e.preventDefault()
        setSidebarTab('history')
      }
      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '3') {
        e.preventDefault()
        setSidebarTab('outline')
      }
      if (e.key === 'Escape' && searchVisible) {
        setSearchVisible(false)
        setSearchQuery('')
      }
    }
    window.addEventListener('keydown', onKeyDown)
    return () => window.removeEventListener('keydown', onKeyDown)
  }, [searchVisible, saveActive, newTabAction, activeId, requestCloseTab, cycleViewMode, openFileDialog, saveActiveAs, exportHtml, openFolderDialog, refreshDir])

  if (!api) {
    return (
      <div className="app-root">
        <p className="no-api">请在 Electron 中运行以使用文件系统功能。</p>
      </div>
    )
  }

  const renderPreviewArticle = (articleRef?: React.Ref<HTMLElement>): React.ReactElement => (
    <PreviewArticle
      key={activeId || 'no-tab'}
      html={previewHtml}
      articleRef={articleRef}
      baseFilePath={activeTab?.filePath ?? null}
      resolveRelativePath={(base, href) => api.resolveRelativePath(base, href)}
      onOpenMarkdown={(path, anchorId) => openPathInTab(path, anchorId)}
      onOpenExternal={(url) => api.openExternal(url)}
    />
  )

  const closePromptTab = closePromptTabId
    ? tabsRef.current.find((t) => t.id === closePromptTabId)
    : undefined

  return (
    <div className="app-root">
      {closePromptTabId && closePromptTab ? (
        <div
          className="modal-overlay"
          role="presentation"
          onMouseDown={() => setClosePromptTabId(null)}
        >
          <div
            className="modal"
            role="dialog"
            aria-modal="true"
            onMouseDown={(e) => e.stopPropagation()}
          >
            <h3 className="modal-title">关闭标签</h3>
            <p className="modal-body">
              「{tabLabel(closePromptTab)}」有未保存的更改，是否保存？
            </p>
            <div className="modal-actions">
              <button
                type="button"
                className="primary"
                onClick={() => {
                  void (async () => {
                    const ok = await saveTabById(closePromptTabId)
                    if (!ok) return
                    removeTabById(closePromptTabId)
                    setClosePromptTabId(null)
                  })()
                }}
              >
                保存
              </button>
              <button
                type="button"
                onClick={() => {
                  removeTabById(closePromptTabId)
                  setClosePromptTabId(null)
                }}
              >
                不保存
              </button>
              <button type="button" onClick={() => setClosePromptTabId(null)}>
                取消
              </button>
            </div>
          </div>
        </div>
      ) : null}

      {textPrompt ? (
        <div
          className="modal-overlay"
          role="presentation"
          onMouseDown={(e) => {
            if (e.target === e.currentTarget) {
              textPrompt.resolve(null)
              setTextPrompt(null)
            }
          }}
        >
          <div
            className="modal"
            role="dialog"
            aria-modal="true"
            onMouseDown={(e) => e.stopPropagation()}
          >
            <h3 className="modal-title">{textPrompt.title}</h3>
            <input
              type="text"
              className="modal-text-input"
              value={textPromptInput}
              onChange={(e) => setTextPromptInput(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Enter') {
                  e.preventDefault()
                  textPrompt.resolve(textPromptInput)
                  setTextPrompt(null)
                }
                if (e.key === 'Escape') {
                  e.preventDefault()
                  textPrompt.resolve(null)
                  setTextPrompt(null)
                }
              }}
              autoFocus
            />
            <div className="modal-actions">
              <button
                type="button"
                className="primary"
                onClick={() => {
                  textPrompt.resolve(textPromptInput)
                  setTextPrompt(null)
                }}
              >
                确定
              </button>
              <button
                type="button"
                onClick={() => {
                  textPrompt.resolve(null)
                  setTextPrompt(null)
                }}
              >
                取消
              </button>
            </div>
          </div>
        </div>
      ) : null}

      {imageInsert ? (
        <div
          className="modal-overlay"
          role="presentation"
          onMouseDown={(e) => {
            if (e.target === e.currentTarget) {
              imageInsert.resolve(null)
              setImageInsert(null)
            }
          }}
        >
          <div
            className="modal modal--wide"
            role="dialog"
            aria-modal="true"
            onMouseDown={(e) => e.stopPropagation()}
          >
            <h3 className="modal-title">插入图片</h3>
            <p className="modal-hint">支持网络地址或本地图片文件（Markdown 中保存为 file:// 链接）。</p>
            <label className="modal-field">
              <span className="modal-label">图片地址</span>
              <input
                type="text"
                className="modal-text-input"
                placeholder="https://… 或选择本地文件"
                value={imageUrlInput}
                onChange={(e) => setImageUrlInput(e.target.value)}
              />
            </label>
            <div className="modal-row-btns">
              <button type="button" onClick={() => void pickLocalImage()}>
                浏览本地图片…
              </button>
            </div>
            <label className="modal-field">
              <span className="modal-label">替代文字</span>
              <input
                type="text"
                className="modal-text-input"
                value={imageAltInput}
                onChange={(e) => setImageAltInput(e.target.value)}
              />
            </label>
            <div className="modal-actions">
              <button
                type="button"
                className="primary"
                onClick={() => {
                  const url = imageUrlInput.trim()
                  if (!url) return
                  imageInsert.resolve({ url, alt: imageAltInput.trim() || '图片' })
                  setImageInsert(null)
                }}
              >
                插入
              </button>
              <button
                type="button"
                onClick={() => {
                  imageInsert.resolve(null)
                  setImageInsert(null)
                }}
              >
                取消
              </button>
            </div>
          </div>
        </div>
      ) : null}

      {colorPicker ? (
        <div
          className="modal-overlay"
          role="presentation"
          onMouseDown={(e) => {
            if (e.target === e.currentTarget) {
              colorPicker.resolve(null)
              setColorPicker(null)
            }
          }}
        >
          <div
            className="modal modal--wide"
            role="dialog"
            aria-modal="true"
            onMouseDown={(e) => e.stopPropagation()}
          >
            <h3 className="modal-title">文字颜色</h3>
            <div className="color-picker-native">
              <span className="modal-label">取色器</span>
              <input
                type="color"
                className="color-picker-spectrum"
                value={/^#[0-9a-fA-F]{6}$/.test(colorHexInput) ? colorHexInput : '#dc2626'}
                onChange={(e) => setColorHexInput(e.target.value.toLowerCase())}
                aria-label="色谱"
              />
            </div>
            <label className="modal-field">
              <span className="modal-label">十六进制</span>
              <input
                type="text"
                className="modal-text-input"
                value={colorHexInput}
                onChange={(e) => setColorHexInput(e.target.value)}
                spellCheck={false}
              />
            </label>
            <div className="color-swatch-grid" role="list">
              {COLOR_SWATCH_ROWS.map((row, ri) => (
                <div key={ri} className="color-swatch-row" role="presentation">
                  {row.map((hex) => (
                    <button
                      key={hex + ri}
                      type="button"
                      className={`color-swatch ${colorHexInput.toLowerCase() === hex ? 'selected' : ''}`}
                      style={{ backgroundColor: hex }}
                      title={hex}
                      aria-label={hex}
                      onClick={() => setColorHexInput(hex)}
                    />
                  ))}
                </div>
              ))}
            </div>
            <div className="modal-actions">
              <button
                type="button"
                className="primary"
                onClick={() => {
                  colorPicker.resolve(colorHexInput.trim() || '#333333')
                  setColorPicker(null)
                }}
              >
                使用此颜色
              </button>
              <button
                type="button"
                onClick={() => {
                  colorPicker.resolve(null)
                  setColorPicker(null)
                }}
              >
                取消
              </button>
            </div>
          </div>
        </div>
      ) : null}

      {histCtxMenu ? (
        <div
          className="ctx-menu"
          style={{
            left: Math.min(histCtxMenu.x, window.innerWidth - 140),
            top: Math.min(histCtxMenu.y, window.innerHeight - 100)
          }}
          onMouseDown={(e) => e.stopPropagation()}
        >
          <button
            type="button"
            className="ctx-item danger"
            onClick={() => {
              setFileHistory((prev) => removeFileHistory(prev, histCtxMenu.path))
              setHistCtxMenu(null)
            }}
          >
            从历史中移除
          </button>
        </div>
      ) : null}

      {ctxMenu ? (
        <div
          className="ctx-menu"
          style={{
            left: Math.min(ctxMenu.x, window.innerWidth - 140),
            top: Math.min(ctxMenu.y, window.innerHeight - 100)
          }}
          onMouseDown={(e) => e.stopPropagation()}
        >
          <button
            type="button"
            className="ctx-item"
            onClick={() => void renamePathEntry(ctxMenu.path)}
          >
            重命名
          </button>
          {ctxMenu.kind === 'file' ? (
            <button
              type="button"
              className="ctx-item danger"
              onClick={() => void deleteFileAt(ctxMenu.path)}
            >
              删除
            </button>
          ) : null}
        </div>
      ) : null}

      <header className="tab-bar">
        <TabBar
          tabs={tabs}
          activeId={activeId}
          onSelect={setActiveId}
          onClose={requestCloseTab}
          onCloseOthers={closeOtherTabs}
          onCloseAll={closeAllTabs}
        />
      </header>

      <div className="toolbar">
        <button type="button" onClick={() => void openFileDialog()}>
          打开
        </button>
        <button type="button" onClick={() => void openFolderDialog()}>
          打开文件夹
        </button>
        <button type="button" onClick={() => void saveActive()}>
          保存
        </button>
        <button type="button" onClick={() => void saveActiveAs()}>
          另存为
        </button>
        <button type="button" onClick={newTabAction}>
          新标签
        </button>
        <button type="button" onClick={cycleViewMode} title="在 编辑 / 阅读 / 分栏 间切换">
          视图：{viewModeLabel(viewMode)}
        </button>
        <button type="button" onClick={() => setSidebarHidden((v) => !v)}>
          {sidebarHidden ? '显示侧栏' : '隐藏侧栏'}
        </button>
        <button type="button" onClick={() => setStatsHidden((v) => !v)}>
          {statsHidden ? '显示统计' : '隐藏统计'}
        </button>
        <button
          type="button"
          onClick={exportHtml}
          disabled={!activeDoc.trim()}
          title="导出当前文档为 HTML 文件"
        >
          导出 HTML
        </button>
        <button
          type="button"
          onClick={() => setAutoSave((v) => !v)}
          title={autoSave ? '自动保存已开启（3秒无操作后保存）' : '自动保存已关闭'}
          style={autoSave ? { borderColor: 'var(--accent)', color: 'var(--accent)' } : undefined}
        >
          {autoSave ? '自动保存：开' : '自动保存：关'}
        </button>
        <div className="toolbar-tail">
          <label className="bg-select">
            行距
            <input
              type="number"
              className="toolbar-mini-input"
              min={1}
              max={3}
              step={0.05}
              value={previewLineHeight}
              title="阅读视图行距（1–3）"
              onChange={(e) => setPreviewLineHeight(e.target.value)}
              onBlur={() => {
                const n = Number.parseFloat(previewLineHeight)
                if (!Number.isFinite(n)) {
                  setPreviewLineHeight('1.5')
                  return
                }
                setPreviewLineHeight(String(clamp(n, 1, 3)))
              }}
            />
          </label>
          <label className="bg-select">
            字体
            <select value={fontId} onChange={(e) => setFontId(e.target.value)}>
              {FONTS.map((t) => (
                <option key={t.id} value={t.id}>
                  {t.name}
                </option>
              ))}
            </select>
          </label>
          <label className="bg-select">
            背景
            <select value={bgId} onChange={(e) => setBgId(e.target.value)}>
              {BACKGROUNDS.map((t) => (
                <option key={t.id} value={t.id}>
                  {t.name}
                </option>
              ))}
            </select>
          </label>
        </div>
      </div>

      <div className="main">
        {!sidebarHidden ? (
          <>
            <aside className="sidebar" style={{ width: sidebarWidth, minWidth: sidebarWidth }}>
              <Sidebar
                sidebarTab={sidebarTab}
                onSidebarTabChange={setSidebarTab}
                browseDir={browseDir}
                dirEntries={dirEntries}
                dirError={dirError}
                parentDisabled={parentDisabled}
                onNavigateDir={setBrowseDir}
                onOpenFile={(path) => void openPathInTab(path)}
                onContextMenu={(e, path, kind) => {
                  e.preventDefault()
                  setCtxMenu({ x: e.clientX, y: e.clientY, path, kind })
                }}
                fileHistory={fileHistory}
                onHistoryOpen={(path) => void openPathInTab(path)}
                onHistoryContextMenu={(e, path) => {
                  e.preventDefault()
                  setHistCtxMenu({ x: e.clientX, y: e.clientY, path })
                }}
                outlineItems={outline}
                onOutlineSelect={navigateToOutline}
                tabs={tabs}
                activeId={activeId}
              />
            </aside>
            <div
              className="sidebar-resize"
              onMouseDown={startSidebarResize}
              title="拖动调整侧栏宽度"
            />
          </>
        ) : (
          <button
            type="button"
            className="sidebar-reopen"
            title="显示侧栏"
            onClick={() => setSidebarHidden(false)}
          >
            ⟨
          </button>
        )}

        {searchVisible ? (
          <div className="search-bar">
            <input
              type="text"
              className="search-input"
              placeholder="搜索文档内容…（Enter 查找下一个，Esc 关闭）"
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Escape') {
                  setSearchVisible(false)
                  setSearchQuery('')
                }
                if (e.key === 'Enter') {
                  e.preventDefault()
                  if (!searchQuery.trim()) return
                  if (viewMode === 'read') {
                    const article = readPreviewArticleRef.current
                    if (article) {
                      const text = article.textContent || ''
                      const idx = text.toLowerCase().indexOf(searchQuery.toLowerCase())
                      if (idx >= 0) {
                        const range = document.createRange()
                        const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT)
                        let currentIdx = 0
                        let node: Text | null = null
                        while ((node = walker.nextNode() as Text | null)) {
                          const len = node.textContent?.length || 0
                          if (currentIdx + len > idx) {
                            range.setStart(node, idx - currentIdx)
                            range.setEnd(node, idx - currentIdx + searchQuery.length)
                            range.startContainer.parentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' })
                            break
                          }
                          currentIdx += len
                        }
                      }
                    }
                    return
                  }
                  if (viewMode === 'edit') {
                    editorRef.current?.searchText(searchQuery)
                    return
                  }
                  const view = hybridEditorViewRef.current
                  if (view) {
                    const doc = view.state.doc.toString()
                    const idx = doc.toLowerCase().indexOf(searchQuery.toLowerCase())
                    if (idx >= 0) {
                      view.dispatch({
                        selection: { anchor: idx, head: idx + searchQuery.length },
                        scrollIntoView: true
                      })
                    }
                  }
                }
              }}
              autoFocus
            />
            <button
              type="button"
              className="search-next-btn"
              title="查找下一个"
              onClick={() => {
                if (!searchQuery.trim()) return
                if (viewMode === 'read') {
                  const article = readPreviewArticleRef.current
                  if (article) {
                    const text = article.textContent || ''
                    const idx = text.toLowerCase().indexOf(searchQuery.toLowerCase())
                    if (idx >= 0) {
                      const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT)
                      let currentIdx = 0
                      let node: Text | null = null
                      while ((node = walker.nextNode() as Text | null)) {
                        const len = node.textContent?.length || 0
                        if (currentIdx + len > idx) {
                          node.parentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' })
                          break
                        }
                        currentIdx += len
                      }
                    }
                  }
                  return
                }
                if (viewMode === 'edit') {
                  editorRef.current?.searchText(searchQuery)
                  return
                }
                const view = hybridEditorViewRef.current
                if (view) {
                  const doc = view.state.doc.toString()
                  const idx = doc.toLowerCase().indexOf(searchQuery.toLowerCase())
                  if (idx >= 0) {
                    view.dispatch({
                      selection: { anchor: idx, head: idx + searchQuery.length },
                      scrollIntoView: true
                    })
                  }
                }
              }}
            >
              ↓
            </button>
            <button
              type="button"
              className="search-close-btn"
              onClick={() => {
                setSearchVisible(false)
                setSearchQuery('')
              }}
            >
              ✕
            </button>
          </div>
        ) : null}

        <section className={`content${dragOver ? ' content-drag-over' : ''}`} onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave}>
          {(viewMode === 'edit' || viewMode === 'hybrid') && activeTab ? (
            <MarkdownFormatToolbar editorRef={editorRef} />
          ) : null}
          {viewMode === 'read' ? (
            activeTab ? (
              <div className="preview-outer" ref={readPreviewOuterRef}>
                <div className="preview-shell" style={{ maxWidth: previewMaxWidth }}>
                  {renderPreviewArticle(readPreviewArticleRef)}
                  <div
                    className="preview-resizer"
                    title="拖动调整阅读区宽度"
                    onMouseDown={startPreviewWidthResize}
                  />
                </div>
              </div>
            ) : (
              <div className="content-empty">
                <div className="content-empty-icon">📖</div>
                <div>请从侧栏选择文件、使用工具栏「打开」，或点击「新标签」开始编辑。</div>
                <div className="content-empty-hint">支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存</div>
                <div className="content-drop-zone">
                  <span className="content-drop-zone-icon">📂</span>
                  <span>拖放 Markdown 文件到此处打开</span>
                </div>
              </div>
            )
          ) : null}

          {viewMode === 'edit' ? (
            activeTab ? (
              <EditorPane
                ref={editorRef}
                mountKey={editorMountKey}
                value={activeTab.content}
                onChange={onEditorChange}
                gotoLine={gotoLine}
                showLineNumbers
                requestText={requestText}
                requestImageDetails={requestImageDetails}
                requestColor={requestColor}
              />
            ) : (
              <div className="content-empty">
                <div className="content-empty-icon">✏️</div>
                <div>请从侧栏选择文件、使用工具栏「打开」，或点击「新标签」开始编辑。</div>
                <div className="content-empty-hint">支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存</div>
                <div className="content-drop-zone">
                  <span className="content-drop-zone-icon">📂</span>
                  <span>拖放 Markdown 文件到此处打开</span>
                </div>
              </div>
            )
          ) : null}

          {viewMode === 'hybrid' ? (
            activeTab ? (
              <div className="hybrid-row">
                <div className="hybrid-editor" style={{ flex: `0 0 ${splitPct}%` }}>
                  <EditorPane
                    ref={editorRef}
                    mountKey={`${editorMountKey}-hybrid`}
                    value={activeTab.content}
                    onChange={onEditorChange}
                    gotoLine={gotoLine}
                    showLineNumbers={false}
                    requestText={requestText}
                    requestImageDetails={requestImageDetails}
                    requestColor={requestColor}
                    onViewReady={onHybridViewReady}
                    onHybridCursorLine={onHybridCursorLine}
                  />
                </div>
                <div
                  className="hybrid-sash"
                  onMouseDown={startHybridSplitResize}
                  title="拖动调整分栏比例"
                />
                <div ref={hybridPreviewRef} className="hybrid-preview">
                  <div
                    ref={hybridPreviewShellRef}
                    className="preview-shell preview-shell--tight hybrid-preview-shell"
                    style={{ maxWidth: '100%' }}
                  >
                    {hybridLinePulse ? (
                      <div
                        key={hybridLinePulse.seq}
                        className="hybrid-line-pulse"
                        style={{ top: `${hybridLinePulse.topPx}px` }}
                        aria-hidden
                      />
                    ) : null}
                    {renderPreviewArticle(hybridPreviewArticleRef)}
                  </div>
                </div>
              </div>
            ) : (
              <div className="content-empty">
                <div className="content-empty-icon">📝</div>
                <div>请从侧栏选择文件、使用工具栏「打开」，或点击「新标签」开始编辑。</div>
                <div className="content-empty-hint">支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存</div>
                <div className="content-drop-zone">
                  <span className="content-drop-zone-icon">📂</span>
                  <span>拖放 Markdown 文件到此处打开</span>
                </div>
              </div>
            )
          ) : null}
        </section>

        {activeTab && !statsHidden ? (
          <StatsPanel
            doc={activeDoc}
            width={statsWidth}
            onResizeStart={startStatsResize}
          />
        ) : null}

        {statusText ? <div className="status-bar">{statusText}</div> : null}
      </div>
      {dragOver ? (
        <div className="global-drop-overlay">
          <div className="global-drop-overlay-box">
            <span className="global-drop-overlay-icon">📂</span>
            <span className="global-drop-overlay-text">释放以打开 Markdown 文件</span>
          </div>
        </div>
      ) : null}
    </div>
  )
}
