Standalone Image Uploader for discourse

Extracted image upload UI from bug-v3 as a standalone userscript with improved client_id mapping

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Standalone Image Uploader for discourse
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  Extracted image upload UI from bug-v3 as a standalone userscript with improved client_id mapping
// @author       auto
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict'

  // Minimal createE implementation
  function createE(tag, opts) {
    const el = document.createElement(tag)
    if (!opts) return el
    if (opts.wi) el.style.width = opts.wi
    if (opts.he) el.style.height = opts.he
    if (opts.class) el.className = opts.class
    if (opts.text) el.textContent = opts.text
    if (opts.ph && 'placeholder' in el) el.placeholder = opts.ph
    if (opts.type && 'type' in el) el.type = opts.type
    if (opts.val !== undefined && 'value' in el) el.value = opts.val
    if (opts.style) el.style.cssText = opts.style
    if (opts.src && 'src' in el) el.src = opts.src
    if (opts.attrs) for (const k in opts.attrs) el.setAttribute(k, opts.attrs[k])
    if (opts.dataset) for (const k in opts.dataset) el.dataset[k] = opts.dataset[k]
    if (opts.in) el.innerHTML = opts.in
    if (opts.ti) el.title = opts.ti
    if (opts.alt && 'alt' in el) el.alt = opts.alt
    if (opts.id) el.id = opts.id
    if (opts.accept && 'accept' in el) el.accept = opts.accept
    if (opts.multiple !== undefined && 'multiple' in el) el.multiple = opts.multiple
    if (opts.role) el.setAttribute('role', opts.role)
    if (opts.tabIndex !== undefined) el.tabIndex = Number(opts.tabIndex)
    if (opts.ld && 'loading' in el) el.loading = opts.ld
    if (opts.on) {
      for (const [evt, handler] of Object.entries(opts.on)) {
        el.addEventListener(evt, handler)
      }
    }
    return el
  }

  // Helper: insert into common editors (textarea or ProseMirror)
  function insertIntoEditor(text) {
    // Priority 1: Chat composer (highest priority)
    const chatComposer = document.querySelector('textarea#channel-composer.chat-composer__input')
    // Priority 2: Standard editor textarea
    const textArea = document.querySelector('textarea.d-editor-input')
    // Priority 3: Rich text editor
    const richEle = document.querySelector('.ProseMirror.d-editor-input')

    if (!chatComposer && !textArea && !richEle) {
      console.error('找不到输入框')
      return
    }

    if (chatComposer) {
      const start = chatComposer.selectionStart
      const end = chatComposer.selectionEnd
      const value = chatComposer.value

      chatComposer.value = value.substring(0, start) + text + value.substring(end)
      chatComposer.setSelectionRange(start + text.length, start + text.length)
      chatComposer.focus()

      const event = new Event('input', { bubbles: true })
      chatComposer.dispatchEvent(event)
    } else if (textArea) {
      const start = textArea.selectionStart
      const end = textArea.selectionEnd
      const value = textArea.value

      textArea.value = value.substring(0, start) + text + value.substring(end)
      textArea.setSelectionRange(start + text.length, start + text.length)
      textArea.focus()

      const event = new Event('input', { bubbles: true })
      textArea.dispatchEvent(event)
    } else if (richEle) {
      const selection = window.getSelection()
      if (selection && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0)
        const textNode = document.createTextNode(text)
        range.insertNode(textNode)
        range.setStartAfter(textNode)
        range.setEndAfter(textNode)
        selection.removeAllRanges()
        selection.addRange(range)
      }
      richEle.focus()
    }
  }

  // Parse filenames from markdown image tags
  function parseImageFilenamesFromMarkdown(markdownText) {
    const imageRegex = /!\[([^\]]*)\]\([^\)]+\)/g
    const filenames = []
    let match
    while ((match = imageRegex.exec(markdownText)) !== null) {
      const filename = match[1]
      if (filename && filename.trim()) filenames.push(filename.trim())
    }
    return filenames
  }

  const clientIdMap = new Map([
    ['linux.do', 'f06cb5577ba9410d94b9faf94e48c2d8']
    // Add other domain-client_id pairs here
  ]);
  const defaultClientId = 'b9cdb79908284b25925d62befbff3921';

  // Uploader class (adapted)
  class ImageUploader {
    constructor() {
      this.waitingQueue = []
      this.uploadingQueue = []
      this.failedQueue = []
      this.successQueue = []
      this.isProcessing = false
      this.maxRetries = 2
      this.progressDialog = null
    }

    uploadImage(file) {
      return new Promise((resolve, reject) => {
        const item = {
          id: `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
          file,
          resolve,
          reject,
          retryCount: 0,
          status: 'waiting',
          timestamp: Date.now()
        }

        this.waitingQueue.push(item)
        this.updateProgressDialog()
        this.processQueue()
      })
    }

    moveToQueue(item, targetStatus) {
      this.waitingQueue = this.waitingQueue.filter(i => i.id !== item.id)
      this.uploadingQueue = this.uploadingQueue.filter(i => i.id !== item.id)
      this.failedQueue = this.failedQueue.filter(i => i.id !== item.id)
      this.successQueue = this.successQueue.filter(i => i.id !== item.id)

      item.status = targetStatus
      if (targetStatus === 'waiting') this.waitingQueue.push(item)
      if (targetStatus === 'uploading') this.uploadingQueue.push(item)
      if (targetStatus === 'failed') this.failedQueue.push(item)
      if (targetStatus === 'success') this.successQueue.push(item)

      this.updateProgressDialog()
    }

    async processQueue() {
      if (this.isProcessing || this.waitingQueue.length === 0) return
      this.isProcessing = true

      while (this.waitingQueue.length > 0) {
        const item = this.waitingQueue.shift()
        if (!item) continue
        this.moveToQueue(item, 'uploading')
        try {
          const result = await this.performUpload(item.file)
          item.result = result
          this.moveToQueue(item, 'success')
          item.resolve(result)
          const markdown = `![${result.original_filename}](${result.url})`
          insertIntoEditor(markdown)
        } catch (error) {
          item.error = error
          if (this.shouldRetry(error, item)) {
            item.retryCount++
            if (error.error_type === 'rate_limit' && error.extras?.wait_seconds) {
              await this.sleep(error.extras.wait_seconds * 1000)
            } else {
              await this.sleep(Math.pow(2, item.retryCount) * 1000)
            }
            this.moveToQueue(item, 'waiting')
          } else {
            this.moveToQueue(item, 'failed')
            item.reject(error)
          }
        }
      }

      this.isProcessing = false
    }

    shouldRetry(error, item) {
      if (item.retryCount >= this.maxRetries) return false
      return error.error_type === 'rate_limit'
    }

    retryFailedItem(itemId) {
      const item = this.failedQueue.find(i => i.id === itemId)
      if (item && item.retryCount < this.maxRetries) {
        item.retryCount++
        this.moveToQueue(item, 'waiting')
        this.processQueue()
      }
    }

    showProgressDialog() {
      if (this.progressDialog) return
      this.progressDialog = this.createProgressDialog()
      document.body.appendChild(this.progressDialog)
    }

    hideProgressDialog() {
      if (this.progressDialog) {
        this.progressDialog.remove()
        this.progressDialog = null
      }
    }

    updateProgressDialog() {
      if (!this.progressDialog) return
      const allItems = [...this.waitingQueue, ...this.uploadingQueue, ...this.failedQueue, ...this.successQueue]
      this.renderQueueItems(this.progressDialog, allItems)
    }

    sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms))
    }

    createProgressDialog() {
      const dialog = createE('div', {
        style: `position: fixed; top: 20px; right: 20px; width: 350px; max-height: 400px; background: white; border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.15); z-index: 10000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; border:1px solid #e5e7eb; overflow: hidden;`
      })

      const header = createE('div', { style: `padding: 16px 20px; background:#f9fafb; border-bottom:1px solid #e5e7eb; font-weight:600; font-size:14px; color:#374151; display:flex; justify-content:space-between; align-items:center;`, text: '图片上传队列' })
      const closeButton = createE('button', { text: '✕', style: `background:none; border:none; font-size:16px; cursor:pointer; color:#6b7280; padding:4px; border-radius:4px; transition: background-color .2s;` })
      closeButton.addEventListener('click', () => this.hideProgressDialog())
      header.appendChild(closeButton)
      const content = createE('div', { class: 'upload-queue-content', style: `max-height:320px; overflow-y:auto; padding:12px;` })
      dialog.appendChild(header)
      dialog.appendChild(content)
      return dialog
    }

    renderQueueItems(dialog, allItems) {
      const content = dialog.querySelector('.upload-queue-content')
      if (!content) return
      content.innerHTML = ''
      if (allItems.length === 0) {
        content.appendChild(createE('div', { style: `text-align:center; color:#6b7280; font-size:14px; padding:20px;`, text: '暂无上传任务' }))
        return
      }

      allItems.forEach(item => {
        const itemEl = createE('div', { style: `display:flex; align-items:center; justify-content:space-between; padding:8px 12px; margin-bottom:8px; background:#f9fafb; border-radius:6px; border-left:4px solid ${this.getStatusColor(item.status)};` })
        const leftSide = createE('div', { style: `flex:1; min-width:0;` })
        const fileName = createE('div', { style: `font-size:13px; font-weight:500; color:#374151; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;`, text: item.file.name })
        const status = createE('div', { style: `font-size:12px; color:#6b7280; margin-top:2px;` })
        status.textContent = this.getStatusText(item)
        leftSide.appendChild(fileName)
        leftSide.appendChild(status)
        const rightSide = createE('div', { style: `display:flex; align-items:center; gap:8px;` })
        if (item.status === 'failed' && item.retryCount < this.maxRetries) {
          const retryButton = createE('button', { text: '🔄', style: `background:none; border:none; cursor:pointer; font-size:14px; padding:4px; border-radius:4px; transition: background-color .2s;`, ti: '重试上传' })
          retryButton.addEventListener('click', () => this.retryFailedItem(item.id))
          rightSide.appendChild(retryButton)
        }
        const statusIcon = createE('div', { style: 'font-size:16px;', text: this.getStatusIcon(item.status) })
        rightSide.appendChild(statusIcon)
        itemEl.appendChild(leftSide)
        itemEl.appendChild(rightSide)
        content.appendChild(itemEl)
      })
    }

    getStatusColor(status) {
      switch (status) {
        case 'waiting': return '#f59e0b'
        case 'uploading': return '#3b82f6'
        case 'success': return '#10b981'
        case 'failed': return '#ef4444'
        default: return '#6b7280'
      }
    }

    getStatusText(item) {
      switch (item.status) {
        case 'waiting': return '等待上传'
        case 'uploading': return '正在上传...'
        case 'success': return '上传成功'
        case 'failed': return item.error?.error_type === 'rate_limit' ? `上传失败 - 请求过于频繁 (重试 ${item.retryCount}/${this.maxRetries})` : `上传失败 (重试 ${item.retryCount}/${this.maxRetries})`
        default: return '未知状态'
      }
    }

    getStatusIcon(status) {
      switch (status) {
        case 'waiting': return '⏳'
        case 'uploading': return '📤'
        case 'success': return '✅'
        case 'failed': return '❌'
        default: return '❓'
      }
    }

    async performUpload(file) {
      const sha1 = await this.calculateSHA1(file)
      const formData = new FormData()
      formData.append('upload_type', 'composer')
      formData.append('relativePath', 'null')
      formData.append('name', file.name)
      formData.append('type', file.type)
      formData.append('sha1_checksum', sha1)
      formData.append('file', file, file.name)

      const csrfToken = this.getCSRFToken()
      const headers = { 'X-Csrf-Token': csrfToken }
      if (document.cookie) headers['Cookie'] = document.cookie

      const clientId = clientIdMap.get(window.location.host) || defaultClientId;
      const response = await fetch(`${window.location.origin}/uploads.json?client_id=${clientId}`, { method: 'POST', headers, body: formData })

      if (!response.ok) {
        const errorData = await response.json()
        throw errorData
      }

      return await response.json()
    }

    getCSRFToken() {
      const metaToken = document.querySelector('meta[name="csrf-token"]')
      if (metaToken) return metaToken.content
      const match = document.cookie.match(/csrf_token=([^;]+)/)
      if (match) return decodeURIComponent(match[1])
      const hiddenInput = document.querySelector('input[name="authenticity_token"]')
      if (hiddenInput) return hiddenInput.value
      console.warn('[Image Uploader] No CSRF token found')
      return ''
    }

    async calculateSHA1(file) {
      const text = `${file.name}-${file.size}-${file.lastModified}`
      const encoder = new TextEncoder()
      const data = encoder.encode(text)
      if (crypto.subtle) {
        try {
          const hashBuffer = await crypto.subtle.digest('SHA-1', data)
          const hashArray = Array.from(new Uint8Array(hashBuffer))
          return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
        } catch (e) {
          console.warn('[Image Uploader] Could not calculate SHA1, using fallback')
        }
      }
      let hash = 0
      for (let i = 0; i < text.length; i++) {
        const char = text.charCodeAt(i)
        hash = (hash << 5) - hash + char
        hash = hash & hash
      }
      return Math.abs(hash).toString(16).padStart(40, '0')
    }
  }

  const uploader = new ImageUploader()

  // --- tar.gz support helpers ---
  function isTarGzFile(file) {
    const name = (file && file.name) || ''
    return name.toLowerCase().endsWith('.tar.gz') || name.toLowerCase().endsWith('.tgz')
  }

  async function decompressGzipToArrayBuffer(blob) {
    if (typeof DecompressionStream === 'function') {
      try {
        const ds = new DecompressionStream('gzip')
        const decompressedStream = blob.stream().pipeThrough(ds)
        const ab = await new Response(decompressedStream).arrayBuffer()
        return ab
      } catch (e) {
        console.error('DecompressionStream failed', e)
        throw e
      }
    }
    // No native DecompressionStream available
    throw new Error('浏览器不支持 DecompressionStream(gzip),无法解压 tar.gz')
  }

  function readStringFromBytes(bytes, start, length) {
    const slice = bytes.subarray(start, start + length)
    // Trim at first NUL
    let end = slice.length
    for (let i = 0; i < slice.length; i++) {
      if (slice[i] === 0) { end = i; break }
    }
    return new TextDecoder().decode(slice.subarray(0, end))
  }

  function parseOctalString(s) {
    const str = s.replace(/\0/g, '').trim()
    if (!str) return 0
    return parseInt(str, 8) || 0
  }

  function parseTarFromArrayBuffer(ab) {
    const bytes = new Uint8Array(ab)
    const files = []
    let offset = 0
    while (offset + 512 <= bytes.length) {
      // Check for two consecutive zero blocks
      const isEmpty = (i => {
        for (let j = 0; j < 512; j++) if (bytes[i + j] !== 0) return false
        return true
      })(offset)
      if (isEmpty) break

      const name = readStringFromBytes(bytes, offset + 0, 100)
      if (!name) break
      const sizeStr = readStringFromBytes(bytes, offset + 124, 12)
      const size = parseOctalString(sizeStr)

      const dataStart = offset + 512
      const dataEnd = dataStart + size
      if (dataEnd > bytes.length) break
      const fileBytes = bytes.slice(dataStart, dataEnd)

      files.push({ name, size, bytes: fileBytes })

      // Advance to next header (file data is padded to 512)
      const padded = Math.ceil(size / 512) * 512
      offset = dataStart + padded
    }
    return files
  }

  function isLikelyImageName(name) {
    const lower = name.toLowerCase()
    return /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(lower)
  }

  async function extractFilesFromTarGzFile(file) {
    // returns array of File objects
    try {
      const ab = await decompressGzipToArrayBuffer(file)
      const entries = parseTarFromArrayBuffer(ab)
      const out = []
      for (const e of entries) {
        if (!e.name) continue
        if (!isLikelyImageName(e.name)) continue
        const blob = new Blob([e.bytes], { type: 'application/octet-stream' })
        // infer mime from extension
        const ext = (e.name.split('.').pop() || '').toLowerCase()
        let mime = 'application/octet-stream'
        if (ext === 'png') mime = 'image/png'
        else if (ext === 'jpg' || ext === 'jpeg') mime = 'image/jpeg'
        else if (ext === 'gif') mime = 'image/gif'
        else if (ext === 'webp') mime = 'image/webp'
        else if (ext === 'svg') mime = 'image/svg+xml'
        else if (ext === 'avif') mime = 'image/avif'
        const imageBlob = new Blob([e.bytes], { type: mime })
        const fileObj = new File([imageBlob], e.name, { type: mime })
        out.push(fileObj)
      }
      return out
    } catch (err) {
      console.error('extractFilesFromTarGzFile error', err)
      alert('无法解压 tar.gz:' + (err && err.message ? err.message : String(err)))
      return []
    }
  }
  // --- end tar.gz helpers ---

  function createDragDropUploadPanel() {
    const panel = createE('div', { class: 'drag-drop-upload-panel', style: `position: fixed; top:50%; left:50%; transform: translate(-50%,-50%); width:500px; max-width:90vw; background:white; border-radius:12px; box-shadow:0 20px 25px -5px rgba(0,0,0,0.1),0 10px 10px -5px rgba(0,0,0,0.04); z-index:10000; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;` })

    const overlay = createE('div', { style: `position: fixed; top:0; left:0; right:0; bottom:0; background: rgba(0,0,0,0.5); z-index:9999;` })

    const header = createE('div', { style: `padding:20px 24px 0; display:flex; justify-content:space-between; align-items:center;` })
    const title = createE('h2', { text: '上传图片', style: `margin:0; font-size:18px; font-weight:600; color:#111827;` })
    const closeButton = createE('button', { in: '✕', style: `background:none; border:none; font-size:20px; cursor:pointer; color:#6b7280; padding:4px; border-radius:4px; transition:background-color .2s;` })
    header.appendChild(title)
    header.appendChild(closeButton)

    const content = createE('div', { class: 'upload-panel-content', style: `padding:24px;` })
    const tabContainer = createE('div', { style: `display:flex; border-bottom:1px solid #e5e7eb; margin-bottom:20px;` })
    const regularTab = createE('button', { text: '常规上传', style: `flex:1; padding:10px 20px; background:none; border:none; border-bottom:2px solid #3b82f6; color:#3b82f6; font-weight:500; cursor:pointer; transition:all .2s;` })
    const diffTab = createE('button', { text: '差分上传', style: `flex:1; padding:10px 20px; background:none; border:none; border-bottom:2px solid transparent; color:#6b7280; font-weight:500; cursor:pointer; transition:all .2s;` })
    tabContainer.appendChild(regularTab)
    tabContainer.appendChild(diffTab)

    const regularPanel = createE('div', { class: 'regular-upload-panel', style: `display:block;` })
    const dropZone = createE('div', { class: 'drop-zone', style: `border:2px dashed #d1d5db; border-radius:8px; padding:40px 20px; text-align:center; background:#f9fafb; transition:all .2s; cursor:pointer;` })
    const dropIcon = createE('div', { in: '📁', style: `font-size:48px; margin-bottom:16px;` })
    const dropText = createE('div', { in: `<div style="font-size:16px; font-weight:500; color:#374151; margin-bottom:8px;">拖拽图片到此处,或点击选择文件</div><div style="font-size:14px; color:#6b7280;">支持 JPG、PNG、GIF 等格式,最大 10MB</div>` })
    const fileInput = createE('input', { type: 'file', accept: 'image/*', multiple: true, style: `display:none;` })
    dropZone.appendChild(dropIcon)
    dropZone.appendChild(dropText)
    regularPanel.appendChild(dropZone)
    regularPanel.appendChild(fileInput)

    const diffPanel = createE('div', { class: 'diff-upload-panel', style: `display:none;` })
    const markdownTextarea = createE('textarea', { ph: '请粘贴包含图片的 markdown 文本...', style: `width:100%; height:120px; padding:12px; border:1px solid #d1d5db; border-radius:6px; font-family:monospace; font-size:14px; resize:vertical; margin-bottom:12px; box-sizing:border-box;` })
    const diffDropZone = createE('div', { class: 'diff-drop-zone', style: `border:2px dashed #d1d5db; border-radius:8px; padding:30px 20px; text-align:center; background:#f9fafb; transition:all .2s; cursor:pointer; margin-bottom:12px;` })
    const diffFileInput = createE('input', { type: 'file', accept: 'image/*', multiple: true, style: `display:none;` })
    diffPanel.appendChild(markdownTextarea)
    diffPanel.appendChild(diffDropZone)
    diffPanel.appendChild(diffFileInput)

    content.appendChild(tabContainer)
    content.appendChild(regularPanel)
    content.appendChild(diffPanel)
    panel.appendChild(header)
    panel.appendChild(content)

    const switchToTab = (activeTab, inactiveTab, activePanel, inactivePanel) => {
      activeTab.style.borderBottomColor = '#3b82f6'
      activeTab.style.color = '#3b82f6'
      inactiveTab.style.borderBottomColor = 'transparent'
      inactiveTab.style.color = '#6b7280'
      activePanel.style.display = 'block'
      inactivePanel.style.display = 'none'
    }

    regularTab.addEventListener('click', () => switchToTab(regularTab, diffTab, regularPanel, diffPanel))
    diffTab.addEventListener('click', () => switchToTab(diffTab, regularTab, diffPanel, regularPanel))

    return { panel, overlay, dropZone, fileInput, closeButton, diffDropZone, diffFileInput, markdownTextarea }
  }

  async function showImageUploadDialog() {
    return new Promise(resolve => {
      const { panel, overlay, dropZone, fileInput, closeButton, diffDropZone, diffFileInput, markdownTextarea } = createDragDropUploadPanel()
      let isDragOver = false
      let isDiffDragOver = false

      const cleanup = () => {
        document.body.removeChild(overlay)
        document.body.removeChild(panel)
        resolve()
      }

      const handleFiles = async files => {
        if (!files || files.length === 0) return
        cleanup()
        uploader.showProgressDialog()
        try {
            const expanded = []
            for (const f of Array.from(files)) {
              if (isTarGzFile(f)) {
                const imgs = await extractFilesFromTarGzFile(f)
                expanded.push(...imgs)
              } else {
                expanded.push(f)
              }
            }

            if (expanded.length === 0) return

            const promises = expanded.map(async file => {
            try { return await uploader.uploadImage(file) } catch (e) { console.error('[Image Uploader] Upload failed:', e); throw e }
          })
          await Promise.allSettled(promises)
        } finally {
          setTimeout(() => uploader.hideProgressDialog(), 3000)
        }
      }

      const handleDiffFiles = async files => {
        if (!files || files.length === 0) return
        const markdownText = markdownTextarea.value
        const existingFilenames = parseImageFilenamesFromMarkdown(markdownText)
          const expanded = []
          for (const f of Array.from(files)) {
            if (isTarGzFile(f)) {
              const imgs = await extractFilesFromTarGzFile(f)
              expanded.push(...imgs)
            } else {
              expanded.push(f)
            }
          }

          const filesToUpload = expanded.filter(file => !existingFilenames.includes(file.name))
        if (filesToUpload.length === 0) { alert('所有选择的图片都已在 markdown 文本中存在,无需上传。'); return }
        if (filesToUpload.length < files.length) {
          const skippedCount = files.length - filesToUpload.length
          const proceed = confirm(`发现 ${skippedCount} 个图片已存在于markdown文本中,将被跳过。是否继续上传剩余 ${filesToUpload.length} 个图片?`)
          if (!proceed) return
        }
        cleanup()
        uploader.showProgressDialog()
        try {
          const promises = filesToUpload.map(async file => { try { return await uploader.uploadImage(file) } catch (e) { console.error('[Image Uploader] Diff upload failed:', e); throw e } })
          await Promise.allSettled(promises)
        } finally {
          setTimeout(() => uploader.hideProgressDialog(), 3000)
        }
      }

      fileInput.addEventListener('change', async event => { const files = event.target.files; if (files) await handleFiles(files) })
      dropZone.addEventListener('click', () => fileInput.click())
      dropZone.addEventListener('dragover', e => { e.preventDefault(); if (!isDragOver) { isDragOver = true; dropZone.style.borderColor = '#3b82f6'; dropZone.style.backgroundColor = '#eff6ff' } })
      dropZone.addEventListener('dragleave', e => { e.preventDefault(); if (!dropZone.contains(e.relatedTarget)) { isDragOver = false; dropZone.style.borderColor = '#d1d5db'; dropZone.style.backgroundColor = '#f9fafb' } })
      dropZone.addEventListener('drop', async e => { e.preventDefault(); isDragOver = false; dropZone.style.borderColor = '#d1d5db'; dropZone.style.backgroundColor = '#f9fafb'; const files = e.dataTransfer?.files; if (files) await handleFiles(files) })

      diffFileInput.addEventListener('change', async event => { const files = event.target.files; if (files) await handleDiffFiles(files) })
      diffDropZone.addEventListener('click', () => diffFileInput.click())
      diffDropZone.addEventListener('dragover', e => { e.preventDefault(); if (!isDiffDragOver) { isDiffDragOver = true; diffDropZone.style.borderColor = '#3b82f6'; diffDropZone.style.backgroundColor = '#eff6ff' } })
      diffDropZone.addEventListener('dragleave', e => { e.preventDefault(); if (!diffDropZone.contains(e.relatedTarget)) { isDiffDragOver = false; diffDropZone.style.borderColor = '#d1d5db'; diffDropZone.style.backgroundColor = '#f9fafb' } })
      diffDropZone.addEventListener('drop', async e => { e.preventDefault(); isDiffDragOver = false; diffDropZone.style.borderColor = '#d1d5db'; diffDropZone.style.backgroundColor = '#f9fafb'; const files = e.dataTransfer?.files; if (files) await handleDiffFiles(files) })

      closeButton.addEventListener('click', cleanup)
      overlay.addEventListener('click', cleanup)

      const preventDefaults = e => { e.preventDefault(); e.stopPropagation() }
      ;['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => document.addEventListener(eventName, preventDefaults, false))

      const originalCleanup = cleanup
      const enhancedCleanup = () => { ;['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => document.removeEventListener(eventName, preventDefaults, false)); originalCleanup() }

      closeButton.removeEventListener('click', cleanup)
      overlay.removeEventListener('click', cleanup)
      closeButton.addEventListener('click', enhancedCleanup)
      overlay.addEventListener('click', enhancedCleanup)

      document.body.appendChild(overlay)
      document.body.appendChild(panel)
    })
  }

  // Floating trigger button
  function createFloatingButton() {
    const btn = createE('button', { text: '上传', style: `position:fixed; right:18px; bottom:18px; z-index:100000; padding:10px 14px; border-radius:9999px; background:#3b82f6; color:white; border:none; font-weight:600; cursor:pointer; box-shadow:0 6px 18px rgba(59,130,246,0.3);` })
    btn.addEventListener('click', () => showImageUploadDialog())
    document.body.appendChild(btn)
  }

  // Wait DOM ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', createFloatingButton)
  } else {
    createFloatingButton()
  }

  // Expose for debugging
  window.__standaloneImageUploader = { uploader, showImageUploadDialog }
})();