@l1yp/file-viewer 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +2 -0
  2. package/dist/components/AnsiText.d.ts +47 -0
  3. package/dist/components/AnsiText.js +41 -0
  4. package/dist/components/AnsiText.js.map +1 -0
  5. package/dist/components/ArchiveBrowser.js +1 -1
  6. package/dist/components/ArchiveBrowser.js.map +1 -1
  7. package/dist/components/ArchiveBrowser.vue_vue_type_script_setup_true_lang.js +105 -94
  8. package/dist/components/ArchiveBrowser.vue_vue_type_script_setup_true_lang.js.map +1 -1
  9. package/dist/components/CodeView.js +1 -1
  10. package/dist/components/CodeView.js.map +1 -1
  11. package/dist/components/CodeView.vue_vue_type_script_setup_true_lang.js +80 -71
  12. package/dist/components/CodeView.vue_vue_type_script_setup_true_lang.js.map +1 -1
  13. package/dist/components/DocxEmbeddedFiles.js +9 -0
  14. package/dist/components/DocxEmbeddedFiles.js.map +1 -0
  15. package/dist/components/DocxEmbeddedFiles.vue.d.ts +15 -0
  16. package/dist/components/DocxEmbeddedFiles.vue_vue_type_script_setup_true_lang.js +69 -0
  17. package/dist/components/DocxEmbeddedFiles.vue_vue_type_script_setup_true_lang.js.map +1 -0
  18. package/dist/components/DocxViewer.js +1 -1
  19. package/dist/components/DocxViewer.js.map +1 -1
  20. package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js +180 -50
  21. package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  22. package/dist/components/FileViewerBody.js +1 -1
  23. package/dist/components/FileViewerBody.js.map +1 -1
  24. package/dist/components/FileViewerBody.vue.d.ts +1 -0
  25. package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js +117 -114
  26. package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js.map +1 -1
  27. package/dist/components/FileViewerPanel.js +1 -1
  28. package/dist/components/FileViewerPanel.js.map +1 -1
  29. package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js +18 -18
  30. package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js.map +1 -1
  31. package/dist/components/ImageLightbox.js +1 -1
  32. package/dist/components/ImageLightbox.js.map +1 -1
  33. package/dist/components/ImageLightbox.vue_vue_type_script_setup_true_lang.js +101 -55
  34. package/dist/components/ImageLightbox.vue_vue_type_script_setup_true_lang.js.map +1 -1
  35. package/dist/components/LightboxGalleryStrip.js +9 -0
  36. package/dist/components/LightboxGalleryStrip.js.map +1 -0
  37. package/dist/components/LightboxGalleryStrip.vue.d.ts +12 -0
  38. package/dist/components/LightboxGalleryStrip.vue_vue_type_script_setup_true_lang.js +60 -0
  39. package/dist/components/LightboxGalleryStrip.vue_vue_type_script_setup_true_lang.js.map +1 -0
  40. package/dist/components/LogViewer.js +1 -1
  41. package/dist/components/LogViewer.js.map +1 -1
  42. package/dist/components/LogViewer.vue.d.ts +3 -1
  43. package/dist/components/LogViewer.vue_vue_type_script_setup_true_lang.js +284 -280
  44. package/dist/components/LogViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  45. package/dist/components/SevenZipBrowser.js +1 -1
  46. package/dist/components/SevenZipBrowser.js.map +1 -1
  47. package/dist/components/SevenZipBrowser.vue_vue_type_script_setup_true_lang.js +157 -146
  48. package/dist/components/SevenZipBrowser.vue_vue_type_script_setup_true_lang.js.map +1 -1
  49. package/dist/components/ZipBrowser.js +1 -1
  50. package/dist/components/ZipBrowser.js.map +1 -1
  51. package/dist/components/ZipBrowser.vue_vue_type_script_setup_true_lang.js +70 -61
  52. package/dist/components/ZipBrowser.vue_vue_type_script_setup_true_lang.js.map +1 -1
  53. package/dist/components/ZoomImage.js +1 -1
  54. package/dist/components/ZoomImage.js.map +1 -1
  55. package/dist/components/ZoomImage.vue.d.ts +2 -0
  56. package/dist/components/ZoomImage.vue_vue_type_script_setup_true_lang.js +94 -85
  57. package/dist/components/ZoomImage.vue_vue_type_script_setup_true_lang.js.map +1 -1
  58. package/dist/composables/useArchiveImageGallery.d.ts +15 -0
  59. package/dist/composables/useArchiveImageGallery.js +62 -0
  60. package/dist/composables/useArchiveImageGallery.js.map +1 -0
  61. package/dist/composables/useLightbox.d.ts +29 -21
  62. package/dist/composables/useLightbox.js +72 -12
  63. package/dist/composables/useLightbox.js.map +1 -1
  64. package/dist/lib/ansi.d.ts +44 -0
  65. package/dist/lib/ansi.js +224 -0
  66. package/dist/lib/ansi.js.map +1 -0
  67. package/dist/lib/archiveImages.d.ts +14 -0
  68. package/dist/lib/archiveImages.js +15 -0
  69. package/dist/lib/archiveImages.js.map +1 -0
  70. package/dist/lib/cfb.d.ts +37 -0
  71. package/dist/lib/cfb.js +112 -0
  72. package/dist/lib/cfb.js.map +1 -0
  73. package/dist/lib/docxEmbeddedDom.d.ts +22 -0
  74. package/dist/lib/docxEmbeddedDom.js +128 -0
  75. package/dist/lib/docxEmbeddedDom.js.map +1 -0
  76. package/dist/lib/docxEmbeddings.d.ts +41 -0
  77. package/dist/lib/docxEmbeddings.js +226 -0
  78. package/dist/lib/docxEmbeddings.js.map +1 -0
  79. package/dist/lib/msi.js +62 -154
  80. package/dist/lib/msi.js.map +1 -1
  81. package/dist/lib/olePackage.d.ts +8 -0
  82. package/dist/lib/olePackage.js +37 -0
  83. package/dist/lib/olePackage.js.map +1 -0
  84. package/dist/lib/pptxFirstSlide.d.ts +11 -0
  85. package/dist/lib/pptxFirstSlide.js +34 -0
  86. package/dist/lib/pptxFirstSlide.js.map +1 -0
  87. package/dist/style.css +1 -1
  88. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"FileViewerPanel.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/FileViewerPanel.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { useFileViewer, type ViewerFile } from '@/composables/useFileViewer'\nimport { canToggleView, skipsTextLoad } from '@/lib/viewerKind'\nimport { VIEWER_FORMATTERS, findFormatter } from '@/lib/viewerFormats'\nimport { fileExt } from '@/lib/fileType'\nimport FileViewerBody from '@/components/FileViewerBody.vue'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Right-docked viewer for files (DetailView reflows the conversation left to make room — a split pane, not an\n * overlay). The header owns the title, the render/source toggle, and the copy/download/refresh/close actions;\n * the CONTENT is delegated to {@link FileViewerBody}, the shared type dispatcher (also used by the archive\n * browsers, so a file inside a zip previews with the same viewers). The left edge is a drag splitter.\n */\nconst { current, width, setWidth, close } = useFileViewer()\n\ntype ViewMode = 'render' | 'source'\nconst mode = ref<ViewMode>('render')\nconst text = ref('') // body emits the loaded text up so the copy button can use it\nconst copied = ref(false)\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nconst bodyRef = ref<{\n reload: () => void\n pickFormat: (key: string) => void\n resetFormat: () => void\n} | null>(null)\n\n// The EFFECTIVE file the body is actually showing — it differs from `current` when the user picks a formatter\n// for an otherwise-unpreviewable file (\"open with\", see FileViewerBody). The body announces it via\n// `formatchange`, so the header's render/source toggle + copy/refresh track the chosen format, not the raw file.\nconst shown = ref<ViewerFile | null>(null)\nconst effective = computed(() => shown.value ?? current.value)\n\n// Inline content (e.g. a loaded skill): rendered directly — no download/refresh, but copy still applies.\nconst isInline = computed(() => current.value?.content != null)\n// A file whose content comes from its own sub-viewer / URL (not the panel-loaded text): no copy/refresh.\nconst selfRendered = computed(() => skipsTextLoad(effective.value))\n// Markdown / html / svg / subtitle / geo / csv / mind-map can flip between rendered and source.\nconst canToggle = computed(() => canToggleView(effective.value, text.value))\n\n// ── \"open with\" menu ──────────────────────────────────────────────────────────────────────────\n// The universal entry point: any file can be force-opened as a different formatter (hex / text / image /\n// audio / …) when auto-detection guessed wrong. The body owns the actual override; this header menu just\n// drives it through the exposed pickFormat / resetFormat. `activeFormat` mirrors the body's current choice\n// (it announces it via `formatpick`), so the menu checks the active row and the button labels it.\nconst formatters = VIEWER_FORMATTERS\nconst menuOpen = ref(false)\nconst menuRef = ref<HTMLElement | null>(null)\nconst activeFormat = ref('')\nconst activeFormatter = computed(() => findFormatter(activeFormat.value))\n// Formatters reinterpret fetched bytes, so they need a URL — hide the menu for inline-only content (no url).\nconst showChooser = computed(() => !!current.value?.url)\nconst chooserTitle = computed(() =>\n activeFormatter.value\n ? `已强制以「${activeFormatter.value.label}」打开 —— 点击切换查看方式`\n : '打开方式 —— 选择查看器(十六进制 / 文本 / 图片 / 音频 …)',\n)\n\nfunction chooseFormat(key: string): void {\n bodyRef.value?.pickFormat(key)\n menuOpen.value = false\n}\nfunction chooseAuto(): void {\n bodyRef.value?.resetFormat()\n menuOpen.value = false\n}\n\n// Close the menu on an outside click or Esc — listeners live only while it's open (SSR-guarded). The toggle\n// button + popup both sit inside menuRef, so a click on either is \"inside\" and never self-closes.\nfunction onDocPointer(e: MouseEvent): void {\n if (menuRef.value && !menuRef.value.contains(e.target as Node)) menuOpen.value = false\n}\nfunction onMenuKey(e: KeyboardEvent): void {\n if (e.key === 'Escape') menuOpen.value = false\n}\nwatch(menuOpen, (on) => {\n if (typeof document === 'undefined') return\n if (on) {\n document.addEventListener('click', onDocPointer)\n document.addEventListener('keydown', onMenuKey)\n } else {\n document.removeEventListener('click', onDocPointer)\n document.removeEventListener('keydown', onMenuKey)\n }\n})\n\n// Header: \"name · EXT\" with the extension split off and emphasized.\nconst ext = computed(() => fileExt(current.value?.name ?? '').toUpperCase())\nconst baseName = computed(() => {\n const n = current.value?.name ?? ''\n const e = fileExt(n)\n return e ? n.slice(0, n.length - e.length - 1) : n\n})\n\n// Re-load whenever a different file is opened (the panel stays mounted across files);\n// each newly opened file starts in its rendered (default) view. The body owns the actual fetch.\nwatch(current, (f) => {\n if (!f) return\n mode.value = 'render'\n text.value = ''\n shown.value = null // body re-announces via `formatchange`; clear so a stale override never lingers\n activeFormat.value = '' // a new file starts auto-detected (the body also re-announces via `formatpick`)\n menuOpen.value = false\n})\n\nasync function copy(): Promise<void> {\n try {\n await navigator.clipboard?.writeText(text.value)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copied.value = true\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copied.value = false), 1400)\n}\n\nconst resizing = ref(false)\n\n// Drag the left edge to resize. The pane is docked right, so moving the cursor left widens it.\n// `resizing` drives a scoped class that colors only the grip — no global body class, so the color\n// cannot leak onto the page. The page-wide resize cursor + selection lock are set inline on <body>.\nfunction onResizeStart(e: PointerEvent): void {\n e.preventDefault()\n const el = e.currentTarget as HTMLElement\n const startX = e.clientX\n const startW = width.value\n const body = document.body\n const prevCursor = body.style.cursor\n const prevSelect = body.style.userSelect\n el.setPointerCapture(e.pointerId)\n resizing.value = true\n body.style.cursor = 'col-resize'\n body.style.userSelect = 'none'\n const move = (ev: PointerEvent) => setWidth(startW + (startX - ev.clientX))\n const noSelect = (ev: Event) => ev.preventDefault()\n const up = (ev: PointerEvent) => {\n el.releasePointerCapture(ev.pointerId)\n el.removeEventListener('pointermove', move)\n el.removeEventListener('pointerup', up)\n document.removeEventListener('selectstart', noSelect)\n body.style.cursor = prevCursor\n body.style.userSelect = prevSelect\n resizing.value = false\n }\n el.addEventListener('pointermove', move)\n el.addEventListener('pointerup', up)\n document.addEventListener('selectstart', noSelect)\n}\n\nonBeforeUnmount(() => {\n clearTimeout(copyTimer)\n if (typeof document !== 'undefined') {\n document.removeEventListener('click', onDocPointer)\n document.removeEventListener('keydown', onMenuKey)\n }\n})\n</script>\n\n<template>\n <aside v-if=\"current\" class=\"fv\">\n <div\n class=\"fv__resizer\"\n :class=\"{ 'fv__resizer--active': resizing }\"\n title=\"拖动调整宽度\"\n @pointerdown=\"onResizeStart\"\n >\n <span class=\"fv__grip\" />\n </div>\n\n <header class=\"fv__head\">\n <!-- markdown / html / svg / …: flip between the rendered view and its source. -->\n <div v-if=\"canToggle\" class=\"fv__view\" role=\"group\" aria-label=\"渲染或源码\">\n <button\n type=\"button\"\n class=\"fv__view-btn\"\n :class=\"{ 'fv__view-btn--on': mode === 'render' }\"\n :aria-pressed=\"mode === 'render'\"\n title=\"渲染\"\n @click=\"mode = 'render'\"\n >\n <Icon name=\"eye\" :size=\"15\" />\n </button>\n <button\n type=\"button\"\n class=\"fv__view-btn\"\n :class=\"{ 'fv__view-btn--on': mode === 'source' }\"\n :aria-pressed=\"mode === 'source'\"\n title=\"源码\"\n @click=\"mode = 'source'\"\n >\n <Icon name=\"code\" :size=\"15\" />\n </button>\n </div>\n <span class=\"fv__title\" :title=\"current.name\">\n <span class=\"fv__fname\">{{ baseName }}</span>\n <template v-if=\"ext\"\n ><span class=\"fv__dot\">·</span><span class=\"fv__ext\">{{ ext }}</span></template\n >\n </span>\n <div class=\"fv__actions\">\n <!-- \"open with\" menu: universal viewer chooser — any file can be force-opened as hex/text/image/audio/…\n when auto-detection guessed wrong. Drives the body's override via the exposed pickFormat/resetFormat. -->\n <div v-if=\"showChooser\" ref=\"menuRef\" class=\"fv__menu\">\n <button\n type=\"button\"\n class=\"fv__btn fv__btn--text\"\n :class=\"{ 'fv__btn--on': activeFormat }\"\n :title=\"chooserTitle\"\n :aria-haspopup=\"true\"\n :aria-expanded=\"menuOpen\"\n @click=\"menuOpen = !menuOpen\"\n >\n <Icon name=\"apps\" :size=\"14\" /><span>{{\n activeFormatter ? activeFormatter.label : '打开方式'\n }}</span>\n </button>\n <div v-if=\"menuOpen\" class=\"fv__menu-pop\" role=\"menu\">\n <button\n type=\"button\"\n class=\"fv__menu-item\"\n :class=\"{ 'fv__menu-item--on': !activeFormat }\"\n role=\"menuitemradio\"\n :aria-checked=\"!activeFormat\"\n @click=\"chooseAuto\"\n >\n <Icon name=\"eye\" :size=\"14\" /><span class=\"fv__menu-label\">自动检测</span>\n <Icon v-if=\"!activeFormat\" name=\"check\" :size=\"15\" class=\"fv__menu-check\" />\n </button>\n <div class=\"fv__menu-sep\" />\n <button\n v-for=\"f in formatters\"\n :key=\"f.key\"\n type=\"button\"\n class=\"fv__menu-item\"\n :class=\"{ 'fv__menu-item--on': activeFormat === f.key }\"\n role=\"menuitemradio\"\n :aria-checked=\"activeFormat === f.key\"\n @click=\"chooseFormat(f.key)\"\n >\n <Icon :name=\"f.icon\" :size=\"14\" /><span class=\"fv__menu-label\">{{ f.label }}</span>\n <Icon v-if=\"activeFormat === f.key\" name=\"check\" :size=\"15\" class=\"fv__menu-check\" />\n </button>\n </div>\n </div>\n <button v-if=\"!selfRendered\" type=\"button\" class=\"fv__btn fv__btn--text\" @click=\"copy\">\n <Icon name=\"copy\" :size=\"14\" /><span>{{ copied ? 'Copied' : 'Copy' }}</span>\n </button>\n <a\n v-if=\"!isInline\"\n class=\"fv__btn\"\n :href=\"current.url\"\n :download=\"current.name\"\n title=\"下载\"\n ><Icon name=\"download\" :size=\"15\"\n /></a>\n <button\n v-if=\"!isInline && !selfRendered\"\n type=\"button\"\n class=\"fv__btn\"\n title=\"刷新\"\n @click=\"bodyRef?.reload()\"\n >\n <Icon name=\"retry\" :size=\"15\" />\n </button>\n <button type=\"button\" class=\"fv__btn\" title=\"关闭\" @click=\"close\">\n <Icon name=\"close\" :size=\"16\" />\n </button>\n </div>\n </header>\n\n <FileViewerBody\n ref=\"bodyRef\"\n :file=\"current\"\n :mode=\"mode\"\n host-chooser\n @loaded=\"text = $event\"\n @formatchange=\"shown = $event\"\n @formatpick=\"activeFormat = $event\"\n />\n </aside>\n</template>\n\n<style scoped>\n/* In-flow flex sibling of the conversation (DetailView is the flex row), so each pane owns its own\n * scrollbar — the conversation's sits at its right edge, not hidden behind a floating overlay. */\n.fv {\n --fv-code-fs: 12.5px;\n --fv-code-lh: 21px;\n position: relative;\n flex: 0 0 var(--fv-width);\n min-width: 0;\n height: 100%;\n z-index: 55;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n border-left: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n/* Left-edge drag handle: a wide invisible hit area with a full-height rail + a small centered grip. */\n.fv__resizer {\n position: absolute;\n top: 0;\n bottom: 0;\n left: -4px;\n width: 11px;\n z-index: 2;\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: col-resize;\n touch-action: none;\n}\n.fv__resizer::before {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n left: 50%;\n width: 2px;\n transform: translateX(-50%);\n background: transparent;\n transition: background 0.15s;\n}\n.fv__grip {\n position: relative;\n width: 5px;\n height: 34px;\n border-radius: 3px;\n background: var(--border-2);\n transition:\n background 0.15s,\n height 0.15s;\n}\n.fv__resizer:hover::before,\n.fv__resizer--active::before {\n background: #6ea8fe;\n}\n.fv__resizer:hover .fv__grip,\n.fv__resizer--active .fv__grip {\n background: #6ea8fe;\n}\n.fv__resizer--active .fv__grip {\n height: 48px;\n}\n\n.fv__head {\n display: flex;\n align-items: center;\n gap: 10px;\n flex-shrink: 0;\n padding: 10px 12px 10px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n/* Render/source segmented toggle, top-left. */\n.fv__view {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n gap: 2px;\n padding: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.fv__view-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 30px;\n height: 26px;\n padding: 0;\n background: transparent;\n border: none;\n border-radius: 6px;\n color: var(--faint);\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.fv__view-btn:hover {\n color: var(--muted);\n}\n.fv__view-btn--on {\n background: var(--surface);\n color: var(--accent);\n box-shadow: var(--shadow);\n}\n.fv__title {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n align-items: baseline;\n gap: 5px;\n white-space: nowrap;\n overflow: hidden;\n}\n.fv__fname {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n font-size: 13px;\n font-weight: 500;\n color: var(--text-strong);\n}\n.fv__dot {\n flex-shrink: 0;\n color: var(--faint);\n}\n.fv__ext {\n flex-shrink: 0;\n font-size: 15px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--muted);\n}\n.fv__actions {\n display: flex;\n align-items: center;\n gap: 2px;\n flex-shrink: 0;\n}\n.fv__btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 5px;\n height: 30px;\n min-width: 30px;\n padding: 0 7px;\n background: transparent;\n border: none;\n border-radius: 7px;\n color: var(--muted);\n font-family: inherit;\n font-size: 12px;\n text-decoration: none;\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.fv__btn:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.fv__btn--text {\n padding: 0 9px;\n}\n/* \"open with\" toggle, accented while an override is active (so the forced format reads at a glance). */\n.fv__btn--on {\n color: var(--accent);\n background: var(--surface-2);\n}\n.fv__btn--on:hover {\n color: var(--accent);\n}\n\n/* \"Open with\" dropdown: anchored under its toggle button, right-aligned to the actions edge. */\n.fv__menu {\n position: relative;\n display: inline-flex;\n}\n.fv__menu-pop {\n position: absolute;\n top: calc(100% + 6px);\n right: 0;\n z-index: 60;\n min-width: 184px;\n padding: 5px;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 10px;\n box-shadow: var(--shadow);\n}\n.fv__menu-item {\n display: flex;\n align-items: center;\n gap: 9px;\n width: 100%;\n padding: 7px 9px;\n background: transparent;\n border: none;\n border-radius: 7px;\n color: var(--text);\n font-family: inherit;\n font-size: 12.5px;\n text-align: left;\n cursor: pointer;\n transition:\n background 0.12s,\n color 0.12s;\n}\n.fv__menu-item:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.fv__menu-item--on {\n color: var(--accent);\n}\n.fv__menu-label {\n flex: 1 1 auto;\n min-width: 0;\n}\n.fv__menu-check {\n flex-shrink: 0;\n color: var(--accent);\n}\n.fv__menu-sep {\n height: 1px;\n margin: 4px 6px;\n background: var(--border);\n}\n\n@media (max-width: 860px) {\n /* Too narrow to split — overlay the viewer full-screen instead. */\n .fv {\n position: fixed;\n inset: 0;\n flex-basis: auto;\n width: 100%;\n z-index: 70;\n }\n .fv__resizer {\n display: none;\n }\n}\n</style>\n"],"mappings":"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"}
1
+ {"version":3,"file":"FileViewerPanel.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/FileViewerPanel.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { useFileViewer, type ViewerFile } from '@/composables/useFileViewer'\nimport { canToggleView, skipsTextLoad } from '@/lib/viewerKind'\nimport { VIEWER_FORMATTERS, findFormatter } from '@/lib/viewerFormats'\nimport { fileExt } from '@/lib/fileType'\nimport FileViewerBody from '@/components/FileViewerBody.vue'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Right-docked viewer for files (DetailView reflows the conversation left to make room — a split pane, not an\n * overlay). The header owns the title, the render/source toggle, and the copy/download/refresh/close actions;\n * the CONTENT is delegated to {@link FileViewerBody}, the shared type dispatcher (also used by the archive\n * browsers, so a file inside a zip previews with the same viewers). The left edge is a drag splitter.\n */\nconst { current, width, setWidth, close } = useFileViewer()\n\ntype ViewMode = 'render' | 'source'\nconst mode = ref<ViewMode>('render')\nconst text = ref('') // body emits the loaded text up so the copy button can use it\nconst copied = ref(false)\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nconst bodyRef = ref<{\n reload: () => void\n pickFormat: (key: string) => void\n resetFormat: () => void\n copyText: () => string\n} | null>(null)\n\n// The EFFECTIVE file the body is actually showing — it differs from `current` when the user picks a formatter\n// for an otherwise-unpreviewable file (\"open with\", see FileViewerBody). The body announces it via\n// `formatchange`, so the header's render/source toggle + copy/refresh track the chosen format, not the raw file.\nconst shown = ref<ViewerFile | null>(null)\nconst effective = computed(() => shown.value ?? current.value)\n\n// Inline content (e.g. a loaded skill): rendered directly — no download/refresh, but copy still applies.\nconst isInline = computed(() => current.value?.content != null)\n// A file whose content comes from its own sub-viewer / URL (not the panel-loaded text): no copy/refresh.\nconst selfRendered = computed(() => skipsTextLoad(effective.value))\n// LogViewer self-fetches for virtualization but exposes its control-free lines for on-demand copy.\nconst canCopy = computed(() => !selfRendered.value || effective.value?.log === true)\n// Markdown / html / svg / subtitle / geo / csv / mind-map can flip between rendered and source.\nconst canToggle = computed(() => canToggleView(effective.value, text.value))\n\n// ── \"open with\" menu ──────────────────────────────────────────────────────────────────────────\n// The universal entry point: any file can be force-opened as a different formatter (hex / text / image /\n// audio / …) when auto-detection guessed wrong. The body owns the actual override; this header menu just\n// drives it through the exposed pickFormat / resetFormat. `activeFormat` mirrors the body's current choice\n// (it announces it via `formatpick`), so the menu checks the active row and the button labels it.\nconst formatters = VIEWER_FORMATTERS\nconst menuOpen = ref(false)\nconst menuRef = ref<HTMLElement | null>(null)\nconst activeFormat = ref('')\nconst activeFormatter = computed(() => findFormatter(activeFormat.value))\n// Formatters reinterpret fetched bytes, so they need a URL — hide the menu for inline-only content (no url).\nconst showChooser = computed(() => !!current.value?.url)\nconst chooserTitle = computed(() =>\n activeFormatter.value\n ? `已强制以「${activeFormatter.value.label}」打开 —— 点击切换查看方式`\n : '打开方式 —— 选择查看器(十六进制 / 文本 / 图片 / 音频 …)',\n)\n\nfunction chooseFormat(key: string): void {\n bodyRef.value?.pickFormat(key)\n menuOpen.value = false\n}\nfunction chooseAuto(): void {\n bodyRef.value?.resetFormat()\n menuOpen.value = false\n}\n\n// Close the menu on an outside click or Esc — listeners live only while it's open (SSR-guarded). The toggle\n// button + popup both sit inside menuRef, so a click on either is \"inside\" and never self-closes.\nfunction onDocPointer(e: MouseEvent): void {\n if (menuRef.value && !menuRef.value.contains(e.target as Node)) menuOpen.value = false\n}\nfunction onMenuKey(e: KeyboardEvent): void {\n if (e.key === 'Escape') menuOpen.value = false\n}\nwatch(menuOpen, (on) => {\n if (typeof document === 'undefined') return\n if (on) {\n document.addEventListener('click', onDocPointer)\n document.addEventListener('keydown', onMenuKey)\n } else {\n document.removeEventListener('click', onDocPointer)\n document.removeEventListener('keydown', onMenuKey)\n }\n})\n\n// Header: \"name · EXT\" with the extension split off and emphasized.\nconst ext = computed(() => fileExt(current.value?.name ?? '').toUpperCase())\nconst baseName = computed(() => {\n const n = current.value?.name ?? ''\n const e = fileExt(n)\n return e ? n.slice(0, n.length - e.length - 1) : n\n})\n\n// Re-load whenever a different file is opened (the panel stays mounted across files);\n// each newly opened file starts in its rendered (default) view. The body owns the actual fetch.\nwatch(current, (f) => {\n if (!f) return\n mode.value = 'render'\n text.value = ''\n shown.value = null // body re-announces via `formatchange`; clear so a stale override never lingers\n activeFormat.value = '' // a new file starts auto-detected (the body also re-announces via `formatpick`)\n menuOpen.value = false\n})\n\nasync function copy(): Promise<void> {\n try {\n await navigator.clipboard?.writeText(bodyRef.value?.copyText() ?? text.value)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copied.value = true\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copied.value = false), 1400)\n}\n\nconst resizing = ref(false)\n\n// Drag the left edge to resize. The pane is docked right, so moving the cursor left widens it.\n// `resizing` drives a scoped class that colors only the grip — no global body class, so the color\n// cannot leak onto the page. The page-wide resize cursor + selection lock are set inline on <body>.\nfunction onResizeStart(e: PointerEvent): void {\n e.preventDefault()\n const el = e.currentTarget as HTMLElement\n const startX = e.clientX\n const startW = width.value\n const body = document.body\n const prevCursor = body.style.cursor\n const prevSelect = body.style.userSelect\n el.setPointerCapture(e.pointerId)\n resizing.value = true\n body.style.cursor = 'col-resize'\n body.style.userSelect = 'none'\n const move = (ev: PointerEvent) => setWidth(startW + (startX - ev.clientX))\n const noSelect = (ev: Event) => ev.preventDefault()\n const up = (ev: PointerEvent) => {\n el.releasePointerCapture(ev.pointerId)\n el.removeEventListener('pointermove', move)\n el.removeEventListener('pointerup', up)\n document.removeEventListener('selectstart', noSelect)\n body.style.cursor = prevCursor\n body.style.userSelect = prevSelect\n resizing.value = false\n }\n el.addEventListener('pointermove', move)\n el.addEventListener('pointerup', up)\n document.addEventListener('selectstart', noSelect)\n}\n\nonBeforeUnmount(() => {\n clearTimeout(copyTimer)\n if (typeof document !== 'undefined') {\n document.removeEventListener('click', onDocPointer)\n document.removeEventListener('keydown', onMenuKey)\n }\n})\n</script>\n\n<template>\n <aside v-if=\"current\" class=\"fv\">\n <div\n class=\"fv__resizer\"\n :class=\"{ 'fv__resizer--active': resizing }\"\n title=\"拖动调整宽度\"\n @pointerdown=\"onResizeStart\"\n >\n <span class=\"fv__grip\" />\n </div>\n\n <header class=\"fv__head\">\n <!-- markdown / html / svg / …: flip between the rendered view and its source. -->\n <div v-if=\"canToggle\" class=\"fv__view\" role=\"group\" aria-label=\"渲染或源码\">\n <button\n type=\"button\"\n class=\"fv__view-btn\"\n :class=\"{ 'fv__view-btn--on': mode === 'render' }\"\n :aria-pressed=\"mode === 'render'\"\n title=\"渲染\"\n @click=\"mode = 'render'\"\n >\n <Icon name=\"eye\" :size=\"15\" />\n </button>\n <button\n type=\"button\"\n class=\"fv__view-btn\"\n :class=\"{ 'fv__view-btn--on': mode === 'source' }\"\n :aria-pressed=\"mode === 'source'\"\n title=\"源码\"\n @click=\"mode = 'source'\"\n >\n <Icon name=\"code\" :size=\"15\" />\n </button>\n </div>\n <span class=\"fv__title\" :title=\"current.name\">\n <span class=\"fv__fname\">{{ baseName }}</span>\n <template v-if=\"ext\"\n ><span class=\"fv__dot\">·</span><span class=\"fv__ext\">{{ ext }}</span></template\n >\n </span>\n <div class=\"fv__actions\">\n <!-- \"open with\" menu: universal viewer chooser — any file can be force-opened as hex/text/image/audio/…\n when auto-detection guessed wrong. Drives the body's override via the exposed pickFormat/resetFormat. -->\n <div v-if=\"showChooser\" ref=\"menuRef\" class=\"fv__menu\">\n <button\n type=\"button\"\n class=\"fv__btn fv__btn--text\"\n :class=\"{ 'fv__btn--on': activeFormat }\"\n :title=\"chooserTitle\"\n :aria-haspopup=\"true\"\n :aria-expanded=\"menuOpen\"\n @click=\"menuOpen = !menuOpen\"\n >\n <Icon name=\"apps\" :size=\"14\" /><span>{{\n activeFormatter ? activeFormatter.label : '打开方式'\n }}</span>\n </button>\n <div v-if=\"menuOpen\" class=\"fv__menu-pop\" role=\"menu\">\n <button\n type=\"button\"\n class=\"fv__menu-item\"\n :class=\"{ 'fv__menu-item--on': !activeFormat }\"\n role=\"menuitemradio\"\n :aria-checked=\"!activeFormat\"\n @click=\"chooseAuto\"\n >\n <Icon name=\"eye\" :size=\"14\" /><span class=\"fv__menu-label\">自动检测</span>\n <Icon v-if=\"!activeFormat\" name=\"check\" :size=\"15\" class=\"fv__menu-check\" />\n </button>\n <div class=\"fv__menu-sep\" />\n <button\n v-for=\"f in formatters\"\n :key=\"f.key\"\n type=\"button\"\n class=\"fv__menu-item\"\n :class=\"{ 'fv__menu-item--on': activeFormat === f.key }\"\n role=\"menuitemradio\"\n :aria-checked=\"activeFormat === f.key\"\n @click=\"chooseFormat(f.key)\"\n >\n <Icon :name=\"f.icon\" :size=\"14\" /><span class=\"fv__menu-label\">{{ f.label }}</span>\n <Icon v-if=\"activeFormat === f.key\" name=\"check\" :size=\"15\" class=\"fv__menu-check\" />\n </button>\n </div>\n </div>\n <button v-if=\"canCopy\" type=\"button\" class=\"fv__btn fv__btn--text\" @click=\"copy\">\n <Icon name=\"copy\" :size=\"14\" /><span>{{ copied ? 'Copied' : 'Copy' }}</span>\n </button>\n <a\n v-if=\"!isInline\"\n class=\"fv__btn\"\n :href=\"current.url\"\n :download=\"current.name\"\n title=\"下载\"\n ><Icon name=\"download\" :size=\"15\"\n /></a>\n <button\n v-if=\"!isInline && !selfRendered\"\n type=\"button\"\n class=\"fv__btn\"\n title=\"刷新\"\n @click=\"bodyRef?.reload()\"\n >\n <Icon name=\"retry\" :size=\"15\" />\n </button>\n <button type=\"button\" class=\"fv__btn\" title=\"关闭\" @click=\"close\">\n <Icon name=\"close\" :size=\"16\" />\n </button>\n </div>\n </header>\n\n <FileViewerBody\n ref=\"bodyRef\"\n :file=\"current\"\n :mode=\"mode\"\n host-chooser\n @loaded=\"text = $event\"\n @formatchange=\"shown = $event\"\n @formatpick=\"activeFormat = $event\"\n />\n </aside>\n</template>\n\n<style scoped>\n/* In-flow flex sibling of the conversation (DetailView is the flex row), so each pane owns its own\n * scrollbar — the conversation's sits at its right edge, not hidden behind a floating overlay. */\n.fv {\n --fv-code-fs: 12.5px;\n --fv-code-lh: 21px;\n position: relative;\n flex: 0 0 var(--fv-width);\n min-width: 0;\n height: 100%;\n z-index: 55;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n border-left: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n/* Left-edge drag handle: a wide invisible hit area with a full-height rail + a small centered grip. */\n.fv__resizer {\n position: absolute;\n top: 0;\n bottom: 0;\n left: -4px;\n width: 11px;\n z-index: 2;\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: col-resize;\n touch-action: none;\n}\n.fv__resizer::before {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n left: 50%;\n width: 2px;\n transform: translateX(-50%);\n background: transparent;\n transition: background 0.15s;\n}\n.fv__grip {\n position: relative;\n width: 5px;\n height: 34px;\n border-radius: 3px;\n background: var(--border-2);\n transition:\n background 0.15s,\n height 0.15s;\n}\n.fv__resizer:hover::before,\n.fv__resizer--active::before {\n background: #6ea8fe;\n}\n.fv__resizer:hover .fv__grip,\n.fv__resizer--active .fv__grip {\n background: #6ea8fe;\n}\n.fv__resizer--active .fv__grip {\n height: 48px;\n}\n\n.fv__head {\n display: flex;\n align-items: center;\n gap: 10px;\n flex-shrink: 0;\n padding: 10px 12px 10px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n/* Render/source segmented toggle, top-left. */\n.fv__view {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n gap: 2px;\n padding: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.fv__view-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 30px;\n height: 26px;\n padding: 0;\n background: transparent;\n border: none;\n border-radius: 6px;\n color: var(--faint);\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.fv__view-btn:hover {\n color: var(--muted);\n}\n.fv__view-btn--on {\n background: var(--surface);\n color: var(--accent);\n box-shadow: var(--shadow);\n}\n.fv__title {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n align-items: baseline;\n gap: 5px;\n white-space: nowrap;\n overflow: hidden;\n}\n.fv__fname {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n font-size: 13px;\n font-weight: 500;\n color: var(--text-strong);\n}\n.fv__dot {\n flex-shrink: 0;\n color: var(--faint);\n}\n.fv__ext {\n flex-shrink: 0;\n font-size: 15px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--muted);\n}\n.fv__actions {\n display: flex;\n align-items: center;\n gap: 2px;\n flex-shrink: 0;\n}\n.fv__btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 5px;\n height: 30px;\n min-width: 30px;\n padding: 0 7px;\n background: transparent;\n border: none;\n border-radius: 7px;\n color: var(--muted);\n font-family: inherit;\n font-size: 12px;\n text-decoration: none;\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.fv__btn:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.fv__btn--text {\n padding: 0 9px;\n}\n/* \"open with\" toggle, accented while an override is active (so the forced format reads at a glance). */\n.fv__btn--on {\n color: var(--accent);\n background: var(--surface-2);\n}\n.fv__btn--on:hover {\n color: var(--accent);\n}\n\n/* \"Open with\" dropdown: anchored under its toggle button, right-aligned to the actions edge. */\n.fv__menu {\n position: relative;\n display: inline-flex;\n}\n.fv__menu-pop {\n position: absolute;\n top: calc(100% + 6px);\n right: 0;\n z-index: 60;\n min-width: 184px;\n padding: 5px;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 10px;\n box-shadow: var(--shadow);\n}\n.fv__menu-item {\n display: flex;\n align-items: center;\n gap: 9px;\n width: 100%;\n padding: 7px 9px;\n background: transparent;\n border: none;\n border-radius: 7px;\n color: var(--text);\n font-family: inherit;\n font-size: 12.5px;\n text-align: left;\n cursor: pointer;\n transition:\n background 0.12s,\n color 0.12s;\n}\n.fv__menu-item:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.fv__menu-item--on {\n color: var(--accent);\n}\n.fv__menu-label {\n flex: 1 1 auto;\n min-width: 0;\n}\n.fv__menu-check {\n flex-shrink: 0;\n color: var(--accent);\n}\n.fv__menu-sep {\n height: 1px;\n margin: 4px 6px;\n background: var(--border);\n}\n\n@media (max-width: 860px) {\n /* Too narrow to split — overlay the viewer full-screen instead. */\n .fv {\n position: fixed;\n inset: 0;\n flex-basis: auto;\n width: 100%;\n z-index: 70;\n }\n .fv__resizer {\n display: none;\n }\n}\n</style>\n"],"mappings":"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"}
@@ -2,7 +2,7 @@ import e from "../_virtual/_plugin-vue_export-helper.js";
2
2
  import t from "./ImageLightbox.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/ImageLightbox.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-edff5d07"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-f8581b22"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8
 
@@ -1 +1 @@
1
- {"version":3,"file":"ImageLightbox.js","names":[],"sources":["../../src/components/ImageLightbox.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { useLightbox } from '@/composables/useLightbox'\nimport { fileExt } from '@/lib/fileType'\nimport Icon from '@/components/icons/Icon.vue'\nimport ZoomImage from '@/components/ZoomImage.vue'\n\n/**\n * Full-screen media lightbox (the \"弹框预览\"). A single instance is mounted in App.vue and driven by the\n * `useLightbox` singleton — any thumbnail / file chip opens it. Teleported to <body> so it overlays everything\n * regardless of the message tree's stacking contexts. Close via the X button, the empty backdrop, or Esc.\n *\n * Images render in the shared {@link ZoomImage} stage (wheel-zoom, drag-pan, dbl-click fit↔2×, toolbar, and\n * long-image fill-width). +/-/0 keys drive it via the exposed methods; a no-drag click on empty area closes.\n * Videos are FETCHED and played from a blob URL: the download endpoint serves `octet-stream` + `nosniff`, so a\n * direct `<video src>` can't determine a codec — but a blob tagged with the right MIME does (no backend change;\n * the same trick ZipBrowser uses).\n */\nconst { current, close } = useLightbox()\n\n// Extension → a concrete video MIME so the blob decodes (the upstream bytes arrive as octet-stream).\nconst VIDEO_MIME: Record<string, string> = {\n mp4: 'video/mp4',\n m4v: 'video/mp4',\n webm: 'video/webm',\n ogv: 'video/ogg',\n mov: 'video/quicktime',\n mkv: 'video/x-matroska',\n avi: 'video/x-msvideo',\n}\n\nconst isVideo = computed(() => current.value?.kind === 'video')\nconst zoomImg = ref<InstanceType<typeof ZoomImage> | null>(null)\n\nconst videoUrl = ref('')\nconst videoLoading = ref(false)\nconst videoError = ref('')\n\nfunction onKey(e: KeyboardEvent): void {\n if (e.key === 'Escape') {\n close()\n } else if (!isVideo.value) {\n if (e.key === '+' || e.key === '=') zoomImg.value?.zoomIn()\n else if (e.key === '-' || e.key === '_') zoomImg.value?.zoomOut()\n else if (e.key === '0') zoomImg.value?.fit()\n }\n}\n\nfunction revokeVideo(): void {\n if (videoUrl.value) {\n URL.revokeObjectURL(videoUrl.value)\n videoUrl.value = ''\n }\n}\n\n// Fetch a video's bytes and wrap them in a typed blob the <video> element can actually decode.\nasync function loadVideo(media: NonNullable<typeof current.value>): Promise<void> {\n videoLoading.value = true\n videoError.value = ''\n try {\n const res = await fetch(media.src)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const raw = await res.blob()\n // Don't trust raw.type — the endpoint always serves octet-stream; derive from the filename extension.\n const type = VIDEO_MIME[fileExt(media.alt || '')] || 'video/mp4'\n const url = URL.createObjectURL(new Blob([raw], { type }))\n // Guard against the lightbox having closed / switched media while the fetch was in flight.\n if (current.value === media) videoUrl.value = url\n else URL.revokeObjectURL(url)\n } catch (e) {\n if (current.value === media) videoError.value = e instanceof Error ? e.message : String(e)\n } finally {\n if (current.value === media) videoLoading.value = false\n }\n}\n\n// Add the Esc listener and lock body scroll only while open; reset video state on every change and (re)load\n// when the new media is a video. Guarded for SSR.\nwatch(current, (media) => {\n if (typeof window === 'undefined') return\n if (media) {\n window.addEventListener('keydown', onKey)\n document.body.style.overflow = 'hidden'\n } else {\n window.removeEventListener('keydown', onKey)\n document.body.style.overflow = ''\n }\n revokeVideo()\n videoError.value = ''\n videoLoading.value = false\n if (media?.kind === 'video') void loadVideo(media)\n})\n\nonBeforeUnmount(() => {\n if (typeof window !== 'undefined') {\n window.removeEventListener('keydown', onKey)\n document.body.style.overflow = ''\n }\n revokeVideo()\n})\n</script>\n\n<template>\n <Teleport to=\"body\">\n <div v-if=\"current\" class=\"lightbox\">\n <button\n type=\"button\"\n class=\"lightbox__close\"\n title=\"关闭 (Esc)\"\n aria-label=\"关闭\"\n @click=\"close\"\n >\n <Icon name=\"close\" :size=\"22\" :stroke-width=\"2.2\" />\n </button>\n\n <template v-if=\"isVideo\">\n <p v-if=\"videoLoading\" class=\"lightbox__msg\">加载视频中…</p>\n <p v-else-if=\"videoError\" class=\"lightbox__msg\">视频加载失败:{{ videoError }}</p>\n <video\n v-else-if=\"videoUrl\"\n class=\"lightbox__video\"\n :src=\"videoUrl\"\n controls\n autoplay\n playsinline\n />\n </template>\n\n <ZoomImage\n v-else\n ref=\"zoomImg\"\n class=\"lightbox__zoom\"\n :src=\"current.src\"\n :alt=\"current.alt || ''\"\n :long=\"current.long\"\n @empty-click=\"close\"\n />\n </div>\n </Teleport>\n</template>\n\n<style scoped>\n.lightbox {\n position: fixed;\n inset: 0;\n z-index: 2000;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.82);\n animation: lightbox-in 0.12s ease-out;\n}\n@keyframes lightbox-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n/* The zoom/pan stage fills the overlay; its transparent background lets the dark backdrop show through. */\n.lightbox__zoom {\n position: absolute;\n inset: 0;\n}\n.lightbox__video {\n max-width: 94vw;\n max-height: 94vh;\n border-radius: 6px;\n background: #000;\n box-shadow: 0 16px 56px rgba(0, 0, 0, 0.55);\n}\n.lightbox__msg {\n color: #fff;\n font-size: 14px;\n}\n.lightbox__close {\n position: fixed;\n top: 16px;\n right: 16px;\n z-index: 1;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 40px;\n height: 40px;\n border: none;\n border-radius: 10px;\n background: rgba(255, 255, 255, 0.12);\n color: #fff;\n cursor: pointer;\n transition: background 0.15s ease;\n}\n.lightbox__close:hover {\n background: rgba(255, 255, 255, 0.24);\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"ImageLightbox.js","names":[],"sources":["../../src/components/ImageLightbox.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'\nimport { useLightbox } from '@/composables/useLightbox'\nimport { fileExt } from '@/lib/fileType'\nimport Icon from '@/components/icons/Icon.vue'\nimport LightboxGalleryStrip from '@/components/LightboxGalleryStrip.vue'\nimport ZoomImage from '@/components/ZoomImage.vue'\n\n/**\n * Full-screen media lightbox (the \"弹框预览\"). A single instance is mounted in App.vue and driven by the\n * `useLightbox` singleton — any thumbnail / file chip opens it. Teleported to <body> so it overlays everything\n * regardless of the message tree's stacking contexts. Close via the X button, the empty backdrop, or Esc.\n *\n * Images render in the shared {@link ZoomImage} stage (wheel-zoom, drag-pan, dbl-click fit↔2×, toolbar, and\n * long-image fill-width). +/-/0 keys drive it via the exposed methods; a no-drag click on empty area closes.\n * Videos are FETCHED and played from a blob URL: the download endpoint serves `octet-stream` + `nosniff`, so a\n * direct `<video src>` can't determine a codec — but a blob tagged with the right MIME does (no backend change;\n * the same trick ZipBrowser uses).\n */\nconst { current, gallery, currentIndex, hasPrevious, hasNext, close, previous, next, select } =\n useLightbox()\n\n// Extension → a concrete video MIME so the blob decodes (the upstream bytes arrive as octet-stream).\nconst VIDEO_MIME: Record<string, string> = {\n mp4: 'video/mp4',\n m4v: 'video/mp4',\n webm: 'video/webm',\n ogv: 'video/ogg',\n mov: 'video/quicktime',\n mkv: 'video/x-matroska',\n avi: 'video/x-msvideo',\n}\n\nconst isVideo = computed(() => current.value?.kind === 'video')\nconst hasGallery = computed(() => !isVideo.value && gallery.value.length > 1)\nconst zoomImg = useTemplateRef<InstanceType<typeof ZoomImage>>('zoomImg')\n\nconst videoUrl = shallowRef('')\nconst videoLoading = shallowRef(false)\nconst videoError = shallowRef('')\n\nfunction onKey(e: KeyboardEvent): void {\n if (e.key === 'Escape') {\n close()\n } else if (!isVideo.value) {\n if (e.key === 'ArrowLeft') previous()\n else if (e.key === 'ArrowRight') next()\n else if (e.key === '+' || e.key === '=') zoomImg.value?.zoomIn()\n else if (e.key === '-' || e.key === '_') zoomImg.value?.zoomOut()\n else if (e.key === '0') zoomImg.value?.fit()\n }\n}\n\nfunction revokeVideo(): void {\n if (videoUrl.value) {\n URL.revokeObjectURL(videoUrl.value)\n videoUrl.value = ''\n }\n}\n\n// Fetch a video's bytes and wrap them in a typed blob the <video> element can actually decode.\nasync function loadVideo(media: NonNullable<typeof current.value>): Promise<void> {\n videoLoading.value = true\n videoError.value = ''\n try {\n const res = await fetch(media.src)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const raw = await res.blob()\n // Don't trust raw.type — the endpoint always serves octet-stream; derive from the filename extension.\n const type = VIDEO_MIME[fileExt(media.alt || '')] || 'video/mp4'\n const url = URL.createObjectURL(new Blob([raw], { type }))\n // Guard against the lightbox having closed / switched media while the fetch was in flight.\n if (current.value === media) videoUrl.value = url\n else URL.revokeObjectURL(url)\n } catch (e) {\n if (current.value === media) videoError.value = e instanceof Error ? e.message : String(e)\n } finally {\n if (current.value === media) videoLoading.value = false\n }\n}\n\n// Add the Esc listener and lock body scroll only while open; reset video state on every change and (re)load\n// when the new media is a video. Guarded for SSR.\nwatch(current, (media) => {\n if (typeof window === 'undefined') return\n if (media) {\n window.addEventListener('keydown', onKey)\n document.body.style.overflow = 'hidden'\n } else {\n window.removeEventListener('keydown', onKey)\n document.body.style.overflow = ''\n }\n revokeVideo()\n videoError.value = ''\n videoLoading.value = false\n if (media?.kind === 'video') void loadVideo(media)\n})\n\nonBeforeUnmount(() => {\n if (typeof window !== 'undefined') {\n window.removeEventListener('keydown', onKey)\n document.body.style.overflow = ''\n }\n revokeVideo()\n})\n</script>\n\n<template>\n <Teleport to=\"body\">\n <div v-if=\"current\" class=\"lightbox\">\n <button\n type=\"button\"\n class=\"lightbox__close\"\n title=\"关闭 (Esc)\"\n aria-label=\"关闭\"\n @click=\"close\"\n >\n <Icon name=\"close\" :size=\"22\" :stroke-width=\"2.2\" />\n </button>\n\n <div v-if=\"hasGallery\" class=\"lightbox__meta\" aria-live=\"polite\">\n <span class=\"lightbox__name\">{{ current.alt || '图片' }}</span>\n <span class=\"lightbox__count\">{{ currentIndex + 1 }} / {{ gallery.length }}</span>\n </div>\n\n <button\n v-if=\"hasPrevious\"\n type=\"button\"\n class=\"lightbox__nav lightbox__nav--prev\"\n title=\"上一张 (←)\"\n aria-label=\"上一张图片\"\n @click=\"previous\"\n >\n <Icon name=\"chevronLeft\" :size=\"24\" :stroke-width=\"2.2\" />\n </button>\n <button\n v-if=\"hasNext\"\n type=\"button\"\n class=\"lightbox__nav lightbox__nav--next\"\n title=\"下一张 (→)\"\n aria-label=\"下一张图片\"\n @click=\"next\"\n >\n <Icon name=\"chevronRight\" :size=\"24\" :stroke-width=\"2.2\" />\n </button>\n\n <template v-if=\"isVideo\">\n <p v-if=\"videoLoading\" class=\"lightbox__msg\">加载视频中…</p>\n <p v-else-if=\"videoError\" class=\"lightbox__msg\">视频加载失败:{{ videoError }}</p>\n <video\n v-else-if=\"videoUrl\"\n class=\"lightbox__video\"\n :src=\"videoUrl\"\n controls\n autoplay\n playsinline\n />\n </template>\n\n <ZoomImage\n v-else\n :key=\"current.src\"\n ref=\"zoomImg\"\n class=\"lightbox__zoom\"\n :src=\"current.src\"\n :alt=\"current.alt || ''\"\n :long=\"current.long\"\n :toolbar-bottom=\"hasGallery ? 108 : 14\"\n @empty-click=\"close\"\n />\n\n <LightboxGalleryStrip\n v-if=\"hasGallery\"\n class=\"lightbox__gallery\"\n :items=\"gallery\"\n :current-index=\"currentIndex\"\n @select=\"select\"\n />\n </div>\n </Teleport>\n</template>\n\n<style scoped>\n.lightbox {\n position: fixed;\n inset: 0;\n z-index: 2000;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(4, 5, 9, 0.86);\n backdrop-filter: blur(3px);\n animation: lightbox-in 0.12s ease-out;\n}\n@keyframes lightbox-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n/* The zoom/pan stage fills the overlay; its transparent background lets the dark backdrop show through. */\n.lightbox__zoom {\n position: absolute;\n inset: 0;\n}\n.lightbox__meta {\n position: fixed;\n top: 16px;\n left: 50%;\n z-index: 2;\n display: flex;\n align-items: center;\n gap: 9px;\n max-width: min(560px, calc(100vw - 160px));\n min-height: 38px;\n padding: 7px 13px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 12px;\n background: rgba(24, 25, 30, 0.52);\n color: #fff;\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.24);\n backdrop-filter: blur(16px) saturate(1.2);\n transform: translateX(-50%);\n}\n.lightbox__name {\n overflow: hidden;\n font-size: 13px;\n font-weight: 500;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.lightbox__count {\n flex: 0 0 auto;\n color: rgba(255, 255, 255, 0.62);\n font-size: 11.5px;\n font-variant-numeric: tabular-nums;\n}\n.lightbox__nav {\n position: fixed;\n top: 50%;\n z-index: 2;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 48px;\n height: 64px;\n border: none;\n background: transparent;\n color: rgba(255, 255, 255, 0.78);\n filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.72));\n opacity: 0.82;\n cursor: pointer;\n transform: translateY(-50%);\n transition:\n color 0.16s ease,\n filter 0.16s ease,\n opacity 0.16s ease,\n transform 0.16s ease;\n}\n.lightbox__nav:hover {\n background: transparent;\n color: #fff;\n filter: drop-shadow(0 3px 8px rgba(0, 0, 0, 0.82));\n opacity: 1;\n transform: translateY(-50%) scale(1.12);\n}\n.lightbox__nav:focus-visible {\n outline: 2px solid #fff;\n outline-offset: 3px;\n}\n.lightbox__nav--prev {\n left: 18px;\n}\n.lightbox__nav--next {\n right: 18px;\n}\n.lightbox__gallery {\n position: fixed;\n bottom: 16px;\n left: 50%;\n z-index: 2;\n transform: translateX(-50%);\n}\n.lightbox__video {\n max-width: 94vw;\n max-height: 94vh;\n border-radius: 6px;\n background: #000;\n box-shadow: 0 16px 56px rgba(0, 0, 0, 0.55);\n}\n.lightbox__msg {\n color: #fff;\n font-size: 14px;\n}\n.lightbox__close {\n position: fixed;\n top: 16px;\n right: 16px;\n z-index: 3;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 40px;\n height: 40px;\n border: none;\n border-radius: 50%;\n background: transparent;\n color: rgba(255, 255, 255, 0.82);\n filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.68));\n cursor: pointer;\n transition:\n background 0.15s ease,\n color 0.15s ease,\n transform 0.15s ease;\n}\n.lightbox__close:hover {\n background: rgba(255, 255, 255, 0.18);\n color: #fff;\n transform: scale(1.06);\n}\n.lightbox__close:focus-visible {\n outline: 2px solid #fff;\n outline-offset: 3px;\n}\n\n@media (max-width: 640px) {\n .lightbox__meta {\n top: 12px;\n left: 12px;\n max-width: calc(100vw - 76px);\n transform: none;\n }\n .lightbox__close {\n top: 12px;\n right: 12px;\n }\n .lightbox__nav {\n width: 40px;\n height: 54px;\n border-radius: 13px;\n }\n .lightbox__nav--prev {\n left: 8px;\n }\n .lightbox__nav--next {\n right: 8px;\n }\n .lightbox__gallery {\n bottom: 12px;\n }\n}\n</style>\n"],"mappings":""}
@@ -2,21 +2,26 @@ import { fileExt as e } from "../lib/fileType.js";
2
2
  import t from "./icons/Icon.js";
3
3
  import { useLightbox as n } from "../composables/useLightbox.js";
4
4
  import r from "./ZoomImage.js";
5
- import { Fragment as i, Teleport as a, computed as o, createBlock as s, createCommentVNode as c, createElementBlock as l, createElementVNode as u, createVNode as d, defineComponent as f, onBeforeUnmount as p, openBlock as m, ref as h, toDisplayString as g, unref as _, watch as v } from "vue";
5
+ import i from "./LightboxGalleryStrip.js";
6
+ import { Fragment as a, Teleport as o, computed as s, createBlock as c, createCommentVNode as l, createElementBlock as u, createElementVNode as d, createVNode as f, defineComponent as p, onBeforeUnmount as m, openBlock as h, shallowRef as g, toDisplayString as _, unref as v, useTemplateRef as y, watch as b } from "vue";
6
7
  //#region src/components/ImageLightbox.vue?vue&type=script&setup=true&lang.ts
7
- var y = {
8
+ var x = {
8
9
  key: 0,
9
10
  class: "lightbox"
10
- }, b = {
11
+ }, S = {
12
+ key: 0,
13
+ class: "lightbox__meta",
14
+ "aria-live": "polite"
15
+ }, C = { class: "lightbox__name" }, w = { class: "lightbox__count" }, T = {
11
16
  key: 0,
12
17
  class: "lightbox__msg"
13
- }, x = {
18
+ }, E = {
14
19
  key: 1,
15
20
  class: "lightbox__msg"
16
- }, S = ["src"], C = /*@__PURE__*/ f({
21
+ }, D = ["src"], O = /*@__PURE__*/ p({
17
22
  __name: "ImageLightbox",
18
- setup(f) {
19
- let { current: C, close: w } = n(), T = {
23
+ setup(p) {
24
+ let { current: O, gallery: k, currentIndex: A, hasPrevious: j, hasNext: M, close: N, previous: P, next: F, select: I } = n(), L = {
20
25
  mp4: "video/mp4",
21
26
  m4v: "video/mp4",
22
27
  webm: "video/webm",
@@ -24,65 +29,106 @@ var y = {
24
29
  mov: "video/quicktime",
25
30
  mkv: "video/x-matroska",
26
31
  avi: "video/x-msvideo"
27
- }, E = o(() => C.value?.kind === "video"), D = h(null), O = h(""), k = h(!1), A = h("");
28
- function j(e) {
29
- e.key === "Escape" ? w() : E.value || (e.key === "+" || e.key === "=" ? D.value?.zoomIn() : e.key === "-" || e.key === "_" ? D.value?.zoomOut() : e.key === "0" && D.value?.fit());
32
+ }, R = s(() => O.value?.kind === "video"), z = s(() => !R.value && k.value.length > 1), B = y("zoomImg"), V = g(""), H = g(!1), U = g("");
33
+ function W(e) {
34
+ e.key === "Escape" ? N() : R.value || (e.key === "ArrowLeft" ? P() : e.key === "ArrowRight" ? F() : e.key === "+" || e.key === "=" ? B.value?.zoomIn() : e.key === "-" || e.key === "_" ? B.value?.zoomOut() : e.key === "0" && B.value?.fit());
30
35
  }
31
- function M() {
32
- O.value &&= (URL.revokeObjectURL(O.value), "");
36
+ function G() {
37
+ V.value &&= (URL.revokeObjectURL(V.value), "");
33
38
  }
34
- async function N(t) {
35
- k.value = !0, A.value = "";
39
+ async function K(t) {
40
+ H.value = !0, U.value = "";
36
41
  try {
37
42
  let n = await fetch(t.src);
38
43
  if (!n.ok) throw Error(`HTTP ${n.status}`);
39
- let r = await n.blob(), i = T[e(t.alt || "")] || "video/mp4", a = URL.createObjectURL(new Blob([r], { type: i }));
40
- C.value === t ? O.value = a : URL.revokeObjectURL(a);
44
+ let r = await n.blob(), i = L[e(t.alt || "")] || "video/mp4", a = URL.createObjectURL(new Blob([r], { type: i }));
45
+ O.value === t ? V.value = a : URL.revokeObjectURL(a);
41
46
  } catch (e) {
42
- C.value === t && (A.value = e instanceof Error ? e.message : String(e));
47
+ O.value === t && (U.value = e instanceof Error ? e.message : String(e));
43
48
  } finally {
44
- C.value === t && (k.value = !1);
49
+ O.value === t && (H.value = !1);
45
50
  }
46
51
  }
47
- return v(C, (e) => {
48
- typeof window > "u" || (e ? (window.addEventListener("keydown", j), document.body.style.overflow = "hidden") : (window.removeEventListener("keydown", j), document.body.style.overflow = ""), M(), A.value = "", k.value = !1, e?.kind === "video" && N(e));
49
- }), p(() => {
50
- typeof window < "u" && (window.removeEventListener("keydown", j), document.body.style.overflow = ""), M();
51
- }), (e, n) => (m(), s(a, { to: "body" }, [_(C) ? (m(), l("div", y, [u("button", {
52
- type: "button",
53
- class: "lightbox__close",
54
- title: "关闭 (Esc)",
55
- "aria-label": "关闭",
56
- onClick: n[0] ||= (...e) => _(w) && _(w)(...e)
57
- }, [d(t, {
58
- name: "close",
59
- size: 22,
60
- "stroke-width": 2.2
61
- })]), E.value ? (m(), l(i, { key: 0 }, [k.value ? (m(), l("p", b, "加载视频中…")) : A.value ? (m(), l("p", x, "视频加载失败:" + g(A.value), 1)) : O.value ? (m(), l("video", {
62
- key: 2,
63
- class: "lightbox__video",
64
- src: O.value,
65
- controls: "",
66
- autoplay: "",
67
- playsinline: ""
68
- }, null, 8, S)) : c("", !0)], 64)) : (m(), s(r, {
69
- key: 1,
70
- ref_key: "zoomImg",
71
- ref: D,
72
- class: "lightbox__zoom",
73
- src: _(C).src,
74
- alt: _(C).alt || "",
75
- long: _(C).long,
76
- onEmptyClick: _(w)
77
- }, null, 8, [
78
- "src",
79
- "alt",
80
- "long",
81
- "onEmptyClick"
82
- ]))])) : c("", !0)]));
52
+ return b(O, (e) => {
53
+ typeof window > "u" || (e ? (window.addEventListener("keydown", W), document.body.style.overflow = "hidden") : (window.removeEventListener("keydown", W), document.body.style.overflow = ""), G(), U.value = "", H.value = !1, e?.kind === "video" && K(e));
54
+ }), m(() => {
55
+ typeof window < "u" && (window.removeEventListener("keydown", W), document.body.style.overflow = ""), G();
56
+ }), (e, n) => (h(), c(o, { to: "body" }, [v(O) ? (h(), u("div", x, [
57
+ d("button", {
58
+ type: "button",
59
+ class: "lightbox__close",
60
+ title: "关闭 (Esc)",
61
+ "aria-label": "关闭",
62
+ onClick: n[0] ||= (...e) => v(N) && v(N)(...e)
63
+ }, [f(t, {
64
+ name: "close",
65
+ size: 22,
66
+ "stroke-width": 2.2
67
+ })]),
68
+ z.value ? (h(), u("div", S, [d("span", C, _(v(O).alt || "图片"), 1), d("span", w, _(v(A) + 1) + " / " + _(v(k).length), 1)])) : l("", !0),
69
+ v(j) ? (h(), u("button", {
70
+ key: 1,
71
+ type: "button",
72
+ class: "lightbox__nav lightbox__nav--prev",
73
+ title: "上一张 ()",
74
+ "aria-label": "上一张图片",
75
+ onClick: n[1] ||= (...e) => v(P) && v(P)(...e)
76
+ }, [f(t, {
77
+ name: "chevronLeft",
78
+ size: 24,
79
+ "stroke-width": 2.2
80
+ })])) : l("", !0),
81
+ v(M) ? (h(), u("button", {
82
+ key: 2,
83
+ type: "button",
84
+ class: "lightbox__nav lightbox__nav--next",
85
+ title: "下一张 (→)",
86
+ "aria-label": "下一张图片",
87
+ onClick: n[2] ||= (...e) => v(F) && v(F)(...e)
88
+ }, [f(t, {
89
+ name: "chevronRight",
90
+ size: 24,
91
+ "stroke-width": 2.2
92
+ })])) : l("", !0),
93
+ R.value ? (h(), u(a, { key: 3 }, [H.value ? (h(), u("p", T, "加载视频中…")) : U.value ? (h(), u("p", E, "视频加载失败:" + _(U.value), 1)) : V.value ? (h(), u("video", {
94
+ key: 2,
95
+ class: "lightbox__video",
96
+ src: V.value,
97
+ controls: "",
98
+ autoplay: "",
99
+ playsinline: ""
100
+ }, null, 8, D)) : l("", !0)], 64)) : (h(), c(r, {
101
+ key: v(O).src,
102
+ ref_key: "zoomImg",
103
+ ref: B,
104
+ class: "lightbox__zoom",
105
+ src: v(O).src,
106
+ alt: v(O).alt || "",
107
+ long: v(O).long,
108
+ "toolbar-bottom": z.value ? 108 : 14,
109
+ onEmptyClick: v(N)
110
+ }, null, 8, [
111
+ "src",
112
+ "alt",
113
+ "long",
114
+ "toolbar-bottom",
115
+ "onEmptyClick"
116
+ ])),
117
+ z.value ? (h(), c(i, {
118
+ key: 5,
119
+ class: "lightbox__gallery",
120
+ items: v(k),
121
+ "current-index": v(A),
122
+ onSelect: v(I)
123
+ }, null, 8, [
124
+ "items",
125
+ "current-index",
126
+ "onSelect"
127
+ ])) : l("", !0)
128
+ ])) : l("", !0)]));
83
129
  }
84
130
  });
85
131
  //#endregion
86
- export { C as default };
132
+ export { O as default };
87
133
 
88
134
  //# sourceMappingURL=ImageLightbox.vue_vue_type_script_setup_true_lang.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ImageLightbox.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/ImageLightbox.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { useLightbox } from '@/composables/useLightbox'\nimport { fileExt } from '@/lib/fileType'\nimport Icon from '@/components/icons/Icon.vue'\nimport ZoomImage from '@/components/ZoomImage.vue'\n\n/**\n * Full-screen media lightbox (the \"弹框预览\"). A single instance is mounted in App.vue and driven by the\n * `useLightbox` singleton — any thumbnail / file chip opens it. Teleported to <body> so it overlays everything\n * regardless of the message tree's stacking contexts. Close via the X button, the empty backdrop, or Esc.\n *\n * Images render in the shared {@link ZoomImage} stage (wheel-zoom, drag-pan, dbl-click fit↔2×, toolbar, and\n * long-image fill-width). +/-/0 keys drive it via the exposed methods; a no-drag click on empty area closes.\n * Videos are FETCHED and played from a blob URL: the download endpoint serves `octet-stream` + `nosniff`, so a\n * direct `<video src>` can't determine a codec — but a blob tagged with the right MIME does (no backend change;\n * the same trick ZipBrowser uses).\n */\nconst { current, close } = useLightbox()\n\n// Extension → a concrete video MIME so the blob decodes (the upstream bytes arrive as octet-stream).\nconst VIDEO_MIME: Record<string, string> = {\n mp4: 'video/mp4',\n m4v: 'video/mp4',\n webm: 'video/webm',\n ogv: 'video/ogg',\n mov: 'video/quicktime',\n mkv: 'video/x-matroska',\n avi: 'video/x-msvideo',\n}\n\nconst isVideo = computed(() => current.value?.kind === 'video')\nconst zoomImg = ref<InstanceType<typeof ZoomImage> | null>(null)\n\nconst videoUrl = ref('')\nconst videoLoading = ref(false)\nconst videoError = ref('')\n\nfunction onKey(e: KeyboardEvent): void {\n if (e.key === 'Escape') {\n close()\n } else if (!isVideo.value) {\n if (e.key === '+' || e.key === '=') zoomImg.value?.zoomIn()\n else if (e.key === '-' || e.key === '_') zoomImg.value?.zoomOut()\n else if (e.key === '0') zoomImg.value?.fit()\n }\n}\n\nfunction revokeVideo(): void {\n if (videoUrl.value) {\n URL.revokeObjectURL(videoUrl.value)\n videoUrl.value = ''\n }\n}\n\n// Fetch a video's bytes and wrap them in a typed blob the <video> element can actually decode.\nasync function loadVideo(media: NonNullable<typeof current.value>): Promise<void> {\n videoLoading.value = true\n videoError.value = ''\n try {\n const res = await fetch(media.src)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const raw = await res.blob()\n // Don't trust raw.type — the endpoint always serves octet-stream; derive from the filename extension.\n const type = VIDEO_MIME[fileExt(media.alt || '')] || 'video/mp4'\n const url = URL.createObjectURL(new Blob([raw], { type }))\n // Guard against the lightbox having closed / switched media while the fetch was in flight.\n if (current.value === media) videoUrl.value = url\n else URL.revokeObjectURL(url)\n } catch (e) {\n if (current.value === media) videoError.value = e instanceof Error ? e.message : String(e)\n } finally {\n if (current.value === media) videoLoading.value = false\n }\n}\n\n// Add the Esc listener and lock body scroll only while open; reset video state on every change and (re)load\n// when the new media is a video. Guarded for SSR.\nwatch(current, (media) => {\n if (typeof window === 'undefined') return\n if (media) {\n window.addEventListener('keydown', onKey)\n document.body.style.overflow = 'hidden'\n } else {\n window.removeEventListener('keydown', onKey)\n document.body.style.overflow = ''\n }\n revokeVideo()\n videoError.value = ''\n videoLoading.value = false\n if (media?.kind === 'video') void loadVideo(media)\n})\n\nonBeforeUnmount(() => {\n if (typeof window !== 'undefined') {\n window.removeEventListener('keydown', onKey)\n document.body.style.overflow = ''\n }\n revokeVideo()\n})\n</script>\n\n<template>\n <Teleport to=\"body\">\n <div v-if=\"current\" class=\"lightbox\">\n <button\n type=\"button\"\n class=\"lightbox__close\"\n title=\"关闭 (Esc)\"\n aria-label=\"关闭\"\n @click=\"close\"\n >\n <Icon name=\"close\" :size=\"22\" :stroke-width=\"2.2\" />\n </button>\n\n <template v-if=\"isVideo\">\n <p v-if=\"videoLoading\" class=\"lightbox__msg\">加载视频中…</p>\n <p v-else-if=\"videoError\" class=\"lightbox__msg\">视频加载失败:{{ videoError }}</p>\n <video\n v-else-if=\"videoUrl\"\n class=\"lightbox__video\"\n :src=\"videoUrl\"\n controls\n autoplay\n playsinline\n />\n </template>\n\n <ZoomImage\n v-else\n ref=\"zoomImg\"\n class=\"lightbox__zoom\"\n :src=\"current.src\"\n :alt=\"current.alt || ''\"\n :long=\"current.long\"\n @empty-click=\"close\"\n />\n </div>\n </Teleport>\n</template>\n\n<style scoped>\n.lightbox {\n position: fixed;\n inset: 0;\n z-index: 2000;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.82);\n animation: lightbox-in 0.12s ease-out;\n}\n@keyframes lightbox-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n/* The zoom/pan stage fills the overlay; its transparent background lets the dark backdrop show through. */\n.lightbox__zoom {\n position: absolute;\n inset: 0;\n}\n.lightbox__video {\n max-width: 94vw;\n max-height: 94vh;\n border-radius: 6px;\n background: #000;\n box-shadow: 0 16px 56px rgba(0, 0, 0, 0.55);\n}\n.lightbox__msg {\n color: #fff;\n font-size: 14px;\n}\n.lightbox__close {\n position: fixed;\n top: 16px;\n right: 16px;\n z-index: 1;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 40px;\n height: 40px;\n border: none;\n border-radius: 10px;\n background: rgba(255, 255, 255, 0.12);\n color: #fff;\n cursor: pointer;\n transition: background 0.15s ease;\n}\n.lightbox__close:hover {\n background: rgba(255, 255, 255, 0.24);\n}\n</style>\n"],"mappings":"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"}
1
+ {"version":3,"file":"ImageLightbox.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/ImageLightbox.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'\nimport { useLightbox } from '@/composables/useLightbox'\nimport { fileExt } from '@/lib/fileType'\nimport Icon from '@/components/icons/Icon.vue'\nimport LightboxGalleryStrip from '@/components/LightboxGalleryStrip.vue'\nimport ZoomImage from '@/components/ZoomImage.vue'\n\n/**\n * Full-screen media lightbox (the \"弹框预览\"). A single instance is mounted in App.vue and driven by the\n * `useLightbox` singleton — any thumbnail / file chip opens it. Teleported to <body> so it overlays everything\n * regardless of the message tree's stacking contexts. Close via the X button, the empty backdrop, or Esc.\n *\n * Images render in the shared {@link ZoomImage} stage (wheel-zoom, drag-pan, dbl-click fit↔2×, toolbar, and\n * long-image fill-width). +/-/0 keys drive it via the exposed methods; a no-drag click on empty area closes.\n * Videos are FETCHED and played from a blob URL: the download endpoint serves `octet-stream` + `nosniff`, so a\n * direct `<video src>` can't determine a codec — but a blob tagged with the right MIME does (no backend change;\n * the same trick ZipBrowser uses).\n */\nconst { current, gallery, currentIndex, hasPrevious, hasNext, close, previous, next, select } =\n useLightbox()\n\n// Extension → a concrete video MIME so the blob decodes (the upstream bytes arrive as octet-stream).\nconst VIDEO_MIME: Record<string, string> = {\n mp4: 'video/mp4',\n m4v: 'video/mp4',\n webm: 'video/webm',\n ogv: 'video/ogg',\n mov: 'video/quicktime',\n mkv: 'video/x-matroska',\n avi: 'video/x-msvideo',\n}\n\nconst isVideo = computed(() => current.value?.kind === 'video')\nconst hasGallery = computed(() => !isVideo.value && gallery.value.length > 1)\nconst zoomImg = useTemplateRef<InstanceType<typeof ZoomImage>>('zoomImg')\n\nconst videoUrl = shallowRef('')\nconst videoLoading = shallowRef(false)\nconst videoError = shallowRef('')\n\nfunction onKey(e: KeyboardEvent): void {\n if (e.key === 'Escape') {\n close()\n } else if (!isVideo.value) {\n if (e.key === 'ArrowLeft') previous()\n else if (e.key === 'ArrowRight') next()\n else if (e.key === '+' || e.key === '=') zoomImg.value?.zoomIn()\n else if (e.key === '-' || e.key === '_') zoomImg.value?.zoomOut()\n else if (e.key === '0') zoomImg.value?.fit()\n }\n}\n\nfunction revokeVideo(): void {\n if (videoUrl.value) {\n URL.revokeObjectURL(videoUrl.value)\n videoUrl.value = ''\n }\n}\n\n// Fetch a video's bytes and wrap them in a typed blob the <video> element can actually decode.\nasync function loadVideo(media: NonNullable<typeof current.value>): Promise<void> {\n videoLoading.value = true\n videoError.value = ''\n try {\n const res = await fetch(media.src)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const raw = await res.blob()\n // Don't trust raw.type — the endpoint always serves octet-stream; derive from the filename extension.\n const type = VIDEO_MIME[fileExt(media.alt || '')] || 'video/mp4'\n const url = URL.createObjectURL(new Blob([raw], { type }))\n // Guard against the lightbox having closed / switched media while the fetch was in flight.\n if (current.value === media) videoUrl.value = url\n else URL.revokeObjectURL(url)\n } catch (e) {\n if (current.value === media) videoError.value = e instanceof Error ? e.message : String(e)\n } finally {\n if (current.value === media) videoLoading.value = false\n }\n}\n\n// Add the Esc listener and lock body scroll only while open; reset video state on every change and (re)load\n// when the new media is a video. Guarded for SSR.\nwatch(current, (media) => {\n if (typeof window === 'undefined') return\n if (media) {\n window.addEventListener('keydown', onKey)\n document.body.style.overflow = 'hidden'\n } else {\n window.removeEventListener('keydown', onKey)\n document.body.style.overflow = ''\n }\n revokeVideo()\n videoError.value = ''\n videoLoading.value = false\n if (media?.kind === 'video') void loadVideo(media)\n})\n\nonBeforeUnmount(() => {\n if (typeof window !== 'undefined') {\n window.removeEventListener('keydown', onKey)\n document.body.style.overflow = ''\n }\n revokeVideo()\n})\n</script>\n\n<template>\n <Teleport to=\"body\">\n <div v-if=\"current\" class=\"lightbox\">\n <button\n type=\"button\"\n class=\"lightbox__close\"\n title=\"关闭 (Esc)\"\n aria-label=\"关闭\"\n @click=\"close\"\n >\n <Icon name=\"close\" :size=\"22\" :stroke-width=\"2.2\" />\n </button>\n\n <div v-if=\"hasGallery\" class=\"lightbox__meta\" aria-live=\"polite\">\n <span class=\"lightbox__name\">{{ current.alt || '图片' }}</span>\n <span class=\"lightbox__count\">{{ currentIndex + 1 }} / {{ gallery.length }}</span>\n </div>\n\n <button\n v-if=\"hasPrevious\"\n type=\"button\"\n class=\"lightbox__nav lightbox__nav--prev\"\n title=\"上一张 (←)\"\n aria-label=\"上一张图片\"\n @click=\"previous\"\n >\n <Icon name=\"chevronLeft\" :size=\"24\" :stroke-width=\"2.2\" />\n </button>\n <button\n v-if=\"hasNext\"\n type=\"button\"\n class=\"lightbox__nav lightbox__nav--next\"\n title=\"下一张 (→)\"\n aria-label=\"下一张图片\"\n @click=\"next\"\n >\n <Icon name=\"chevronRight\" :size=\"24\" :stroke-width=\"2.2\" />\n </button>\n\n <template v-if=\"isVideo\">\n <p v-if=\"videoLoading\" class=\"lightbox__msg\">加载视频中…</p>\n <p v-else-if=\"videoError\" class=\"lightbox__msg\">视频加载失败:{{ videoError }}</p>\n <video\n v-else-if=\"videoUrl\"\n class=\"lightbox__video\"\n :src=\"videoUrl\"\n controls\n autoplay\n playsinline\n />\n </template>\n\n <ZoomImage\n v-else\n :key=\"current.src\"\n ref=\"zoomImg\"\n class=\"lightbox__zoom\"\n :src=\"current.src\"\n :alt=\"current.alt || ''\"\n :long=\"current.long\"\n :toolbar-bottom=\"hasGallery ? 108 : 14\"\n @empty-click=\"close\"\n />\n\n <LightboxGalleryStrip\n v-if=\"hasGallery\"\n class=\"lightbox__gallery\"\n :items=\"gallery\"\n :current-index=\"currentIndex\"\n @select=\"select\"\n />\n </div>\n </Teleport>\n</template>\n\n<style scoped>\n.lightbox {\n position: fixed;\n inset: 0;\n z-index: 2000;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(4, 5, 9, 0.86);\n backdrop-filter: blur(3px);\n animation: lightbox-in 0.12s ease-out;\n}\n@keyframes lightbox-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n/* The zoom/pan stage fills the overlay; its transparent background lets the dark backdrop show through. */\n.lightbox__zoom {\n position: absolute;\n inset: 0;\n}\n.lightbox__meta {\n position: fixed;\n top: 16px;\n left: 50%;\n z-index: 2;\n display: flex;\n align-items: center;\n gap: 9px;\n max-width: min(560px, calc(100vw - 160px));\n min-height: 38px;\n padding: 7px 13px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 12px;\n background: rgba(24, 25, 30, 0.52);\n color: #fff;\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.24);\n backdrop-filter: blur(16px) saturate(1.2);\n transform: translateX(-50%);\n}\n.lightbox__name {\n overflow: hidden;\n font-size: 13px;\n font-weight: 500;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.lightbox__count {\n flex: 0 0 auto;\n color: rgba(255, 255, 255, 0.62);\n font-size: 11.5px;\n font-variant-numeric: tabular-nums;\n}\n.lightbox__nav {\n position: fixed;\n top: 50%;\n z-index: 2;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 48px;\n height: 64px;\n border: none;\n background: transparent;\n color: rgba(255, 255, 255, 0.78);\n filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.72));\n opacity: 0.82;\n cursor: pointer;\n transform: translateY(-50%);\n transition:\n color 0.16s ease,\n filter 0.16s ease,\n opacity 0.16s ease,\n transform 0.16s ease;\n}\n.lightbox__nav:hover {\n background: transparent;\n color: #fff;\n filter: drop-shadow(0 3px 8px rgba(0, 0, 0, 0.82));\n opacity: 1;\n transform: translateY(-50%) scale(1.12);\n}\n.lightbox__nav:focus-visible {\n outline: 2px solid #fff;\n outline-offset: 3px;\n}\n.lightbox__nav--prev {\n left: 18px;\n}\n.lightbox__nav--next {\n right: 18px;\n}\n.lightbox__gallery {\n position: fixed;\n bottom: 16px;\n left: 50%;\n z-index: 2;\n transform: translateX(-50%);\n}\n.lightbox__video {\n max-width: 94vw;\n max-height: 94vh;\n border-radius: 6px;\n background: #000;\n box-shadow: 0 16px 56px rgba(0, 0, 0, 0.55);\n}\n.lightbox__msg {\n color: #fff;\n font-size: 14px;\n}\n.lightbox__close {\n position: fixed;\n top: 16px;\n right: 16px;\n z-index: 3;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 40px;\n height: 40px;\n border: none;\n border-radius: 50%;\n background: transparent;\n color: rgba(255, 255, 255, 0.82);\n filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.68));\n cursor: pointer;\n transition:\n background 0.15s ease,\n color 0.15s ease,\n transform 0.15s ease;\n}\n.lightbox__close:hover {\n background: rgba(255, 255, 255, 0.18);\n color: #fff;\n transform: scale(1.06);\n}\n.lightbox__close:focus-visible {\n outline: 2px solid #fff;\n outline-offset: 3px;\n}\n\n@media (max-width: 640px) {\n .lightbox__meta {\n top: 12px;\n left: 12px;\n max-width: calc(100vw - 76px);\n transform: none;\n }\n .lightbox__close {\n top: 12px;\n right: 12px;\n }\n .lightbox__nav {\n width: 40px;\n height: 54px;\n border-radius: 13px;\n }\n .lightbox__nav--prev {\n left: 8px;\n }\n .lightbox__nav--next {\n right: 8px;\n }\n .lightbox__gallery {\n bottom: 12px;\n }\n}\n</style>\n"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import e from "../_virtual/_plugin-vue_export-helper.js";
2
+ import t from "./LightboxGalleryStrip.vue_vue_type_script_setup_true_lang.js";
3
+ /* empty css */
4
+ //#region src/components/LightboxGalleryStrip.vue
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-6c325dbc"]]);
6
+ //#endregion
7
+ export { n as default };
8
+
9
+ //# sourceMappingURL=LightboxGalleryStrip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LightboxGalleryStrip.js","names":[],"sources":["../../src/components/LightboxGalleryStrip.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { LightboxImage } from '@/composables/useLightbox'\nimport Icon from '@/components/icons/Icon.vue'\n\nconst props = defineProps<{\n items: readonly LightboxImage[]\n currentIndex: number\n}>()\n\nconst emit = defineEmits<{\n select: [index: number]\n}>()\n\ninterface VisibleItem {\n image: LightboxImage\n index: number\n}\n\n// Keep the dock compact even for image-heavy conversations: show the current image and its nearest neighbours.\nconst MAX_VISIBLE = 7\nconst visibleItems = computed<VisibleItem[]>(() => {\n const count = props.items.length\n const maxStart = Math.max(0, count - MAX_VISIBLE)\n const preferredStart = props.currentIndex - Math.floor(MAX_VISIBLE / 2)\n const start = Math.min(maxStart, Math.max(0, preferredStart))\n return props.items\n .slice(start, start + MAX_VISIBLE)\n .map((image, offset) => ({ image, index: start + offset }))\n})\n\nfunction hideBrokenImage(event: Event): void {\n const image = event.currentTarget as HTMLImageElement\n image.hidden = true\n}\n</script>\n\n<template>\n <nav class=\"gallery-strip\" aria-label=\"会话图片画廊\">\n <button\n v-for=\"item in visibleItems\"\n :key=\"item.image.id || item.image.src\"\n type=\"button\"\n class=\"gallery-strip__item\"\n :class=\"{ 'gallery-strip__item--active': item.index === currentIndex }\"\n :aria-label=\"`查看第 ${item.index + 1} 张图片:${item.image.alt || '图片'}`\"\n :aria-current=\"item.index === currentIndex ? 'true' : undefined\"\n :title=\"item.image.alt || `第 ${item.index + 1} 张图片`\"\n @click=\"emit('select', item.index)\"\n >\n <span\n class=\"gallery-strip__media\"\n :style=\"item.image.color ? { backgroundColor: item.image.color } : undefined\"\n >\n <Icon class=\"gallery-strip__fallback\" name=\"image\" :size=\"18\" />\n <img\n class=\"gallery-strip__img\"\n :src=\"item.image.thumbSrc || item.image.src\"\n :alt=\"item.image.alt || ''\"\n loading=\"eager\"\n draggable=\"false\"\n @error=\"hideBrokenImage\"\n @dragstart.prevent\n />\n </span>\n </button>\n </nav>\n</template>\n\n<style scoped>\n.gallery-strip {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 7px;\n max-width: min(720px, calc(100vw - 112px));\n padding: 8px 10px;\n overflow-x: auto;\n scrollbar-width: none;\n border: 1px solid rgba(255, 255, 255, 0.16);\n border-radius: 18px;\n background: rgba(24, 25, 30, 0.58);\n box-shadow:\n 0 16px 44px rgba(0, 0, 0, 0.38),\n inset 0 1px 0 rgba(255, 255, 255, 0.08);\n backdrop-filter: blur(18px) saturate(1.25);\n}\n.gallery-strip::-webkit-scrollbar {\n display: none;\n}\n.gallery-strip__item {\n flex: 0 0 auto;\n width: 58px;\n height: 58px;\n padding: 3px;\n border: 1px solid transparent;\n border-radius: 13px;\n background: rgba(255, 255, 255, 0.06);\n opacity: 0.68;\n cursor: pointer;\n transform: scale(0.94);\n transition:\n opacity 0.18s ease,\n transform 0.18s ease,\n border-color 0.18s ease,\n background 0.18s ease,\n box-shadow 0.18s ease;\n}\n.gallery-strip__item:hover {\n opacity: 0.94;\n transform: scale(1);\n background: rgba(255, 255, 255, 0.12);\n}\n.gallery-strip__item--active {\n border-color: rgba(255, 255, 255, 0.9);\n background: rgba(255, 255, 255, 0.18);\n box-shadow: 0 5px 18px rgba(0, 0, 0, 0.3);\n opacity: 1;\n transform: scale(1.06);\n}\n.gallery-strip__item:focus-visible {\n outline: 2px solid #fff;\n outline-offset: 2px;\n}\n.gallery-strip__media {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 100%;\n overflow: hidden;\n border-radius: 9px;\n background: rgba(255, 255, 255, 0.08);\n color: rgba(255, 255, 255, 0.5);\n}\n.gallery-strip__fallback {\n position: absolute;\n}\n.gallery-strip__img {\n position: relative;\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n user-select: none;\n -webkit-user-drag: none;\n}\n\n@media (max-width: 640px) {\n .gallery-strip {\n gap: 5px;\n max-width: calc(100vw - 28px);\n padding: 7px 8px;\n border-radius: 16px;\n }\n .gallery-strip__item {\n width: 50px;\n height: 50px;\n border-radius: 11px;\n }\n}\n</style>\n"],"mappings":""}
@@ -0,0 +1,12 @@
1
+ import type { LightboxImage } from '@/composables/useLightbox';
2
+ type __VLS_Props = {
3
+ items: readonly LightboxImage[];
4
+ currentIndex: number;
5
+ };
6
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
7
+ select: (index: number) => any;
8
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
9
+ onSelect?: ((index: number) => any) | undefined;
10
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
11
+ declare const _default: typeof __VLS_export;
12
+ export default _default;
@@ -0,0 +1,60 @@
1
+ import e from "./icons/Icon.js";
2
+ import { Fragment as t, computed as n, createElementBlock as r, createElementVNode as i, createVNode as a, defineComponent as o, normalizeClass as s, normalizeStyle as c, openBlock as l, renderList as u, withModifiers as d } from "vue";
3
+ //#region src/components/LightboxGalleryStrip.vue?vue&type=script&setup=true&lang.ts
4
+ var f = {
5
+ class: "gallery-strip",
6
+ "aria-label": "会话图片画廊"
7
+ }, p = [
8
+ "aria-label",
9
+ "aria-current",
10
+ "title",
11
+ "onClick"
12
+ ], m = ["src", "alt"], h = 7, g = /*@__PURE__*/ o({
13
+ __name: "LightboxGalleryStrip",
14
+ props: {
15
+ items: {},
16
+ currentIndex: {}
17
+ },
18
+ emits: ["select"],
19
+ setup(o, { emit: g }) {
20
+ let _ = o, v = g, y = n(() => {
21
+ let e = _.items.length, t = Math.max(0, e - h), n = _.currentIndex - Math.floor(h / 2), r = Math.min(t, Math.max(0, n));
22
+ return _.items.slice(r, r + h).map((e, t) => ({
23
+ image: e,
24
+ index: r + t
25
+ }));
26
+ });
27
+ function b(e) {
28
+ let t = e.currentTarget;
29
+ t.hidden = !0;
30
+ }
31
+ return (n, h) => (l(), r("nav", f, [(l(!0), r(t, null, u(y.value, (t) => (l(), r("button", {
32
+ key: t.image.id || t.image.src,
33
+ type: "button",
34
+ class: s(["gallery-strip__item", { "gallery-strip__item--active": t.index === o.currentIndex }]),
35
+ "aria-label": `查看第 ${t.index + 1} 张图片:${t.image.alt || "图片"}`,
36
+ "aria-current": t.index === o.currentIndex ? "true" : void 0,
37
+ title: t.image.alt || `第 ${t.index + 1} 张图片`,
38
+ onClick: (e) => v("select", t.index)
39
+ }, [i("span", {
40
+ class: "gallery-strip__media",
41
+ style: c(t.image.color ? { backgroundColor: t.image.color } : void 0)
42
+ }, [a(e, {
43
+ class: "gallery-strip__fallback",
44
+ name: "image",
45
+ size: 18
46
+ }), i("img", {
47
+ class: "gallery-strip__img",
48
+ src: t.image.thumbSrc || t.image.src,
49
+ alt: t.image.alt || "",
50
+ loading: "eager",
51
+ draggable: "false",
52
+ onError: b,
53
+ onDragstart: h[0] ||= d(() => {}, ["prevent"])
54
+ }, null, 40, m)], 4)], 10, p))), 128))]));
55
+ }
56
+ });
57
+ //#endregion
58
+ export { g as default };
59
+
60
+ //# sourceMappingURL=LightboxGalleryStrip.vue_vue_type_script_setup_true_lang.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LightboxGalleryStrip.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/LightboxGalleryStrip.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { LightboxImage } from '@/composables/useLightbox'\nimport Icon from '@/components/icons/Icon.vue'\n\nconst props = defineProps<{\n items: readonly LightboxImage[]\n currentIndex: number\n}>()\n\nconst emit = defineEmits<{\n select: [index: number]\n}>()\n\ninterface VisibleItem {\n image: LightboxImage\n index: number\n}\n\n// Keep the dock compact even for image-heavy conversations: show the current image and its nearest neighbours.\nconst MAX_VISIBLE = 7\nconst visibleItems = computed<VisibleItem[]>(() => {\n const count = props.items.length\n const maxStart = Math.max(0, count - MAX_VISIBLE)\n const preferredStart = props.currentIndex - Math.floor(MAX_VISIBLE / 2)\n const start = Math.min(maxStart, Math.max(0, preferredStart))\n return props.items\n .slice(start, start + MAX_VISIBLE)\n .map((image, offset) => ({ image, index: start + offset }))\n})\n\nfunction hideBrokenImage(event: Event): void {\n const image = event.currentTarget as HTMLImageElement\n image.hidden = true\n}\n</script>\n\n<template>\n <nav class=\"gallery-strip\" aria-label=\"会话图片画廊\">\n <button\n v-for=\"item in visibleItems\"\n :key=\"item.image.id || item.image.src\"\n type=\"button\"\n class=\"gallery-strip__item\"\n :class=\"{ 'gallery-strip__item--active': item.index === currentIndex }\"\n :aria-label=\"`查看第 ${item.index + 1} 张图片:${item.image.alt || '图片'}`\"\n :aria-current=\"item.index === currentIndex ? 'true' : undefined\"\n :title=\"item.image.alt || `第 ${item.index + 1} 张图片`\"\n @click=\"emit('select', item.index)\"\n >\n <span\n class=\"gallery-strip__media\"\n :style=\"item.image.color ? { backgroundColor: item.image.color } : undefined\"\n >\n <Icon class=\"gallery-strip__fallback\" name=\"image\" :size=\"18\" />\n <img\n class=\"gallery-strip__img\"\n :src=\"item.image.thumbSrc || item.image.src\"\n :alt=\"item.image.alt || ''\"\n loading=\"eager\"\n draggable=\"false\"\n @error=\"hideBrokenImage\"\n @dragstart.prevent\n />\n </span>\n </button>\n </nav>\n</template>\n\n<style scoped>\n.gallery-strip {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 7px;\n max-width: min(720px, calc(100vw - 112px));\n padding: 8px 10px;\n overflow-x: auto;\n scrollbar-width: none;\n border: 1px solid rgba(255, 255, 255, 0.16);\n border-radius: 18px;\n background: rgba(24, 25, 30, 0.58);\n box-shadow:\n 0 16px 44px rgba(0, 0, 0, 0.38),\n inset 0 1px 0 rgba(255, 255, 255, 0.08);\n backdrop-filter: blur(18px) saturate(1.25);\n}\n.gallery-strip::-webkit-scrollbar {\n display: none;\n}\n.gallery-strip__item {\n flex: 0 0 auto;\n width: 58px;\n height: 58px;\n padding: 3px;\n border: 1px solid transparent;\n border-radius: 13px;\n background: rgba(255, 255, 255, 0.06);\n opacity: 0.68;\n cursor: pointer;\n transform: scale(0.94);\n transition:\n opacity 0.18s ease,\n transform 0.18s ease,\n border-color 0.18s ease,\n background 0.18s ease,\n box-shadow 0.18s ease;\n}\n.gallery-strip__item:hover {\n opacity: 0.94;\n transform: scale(1);\n background: rgba(255, 255, 255, 0.12);\n}\n.gallery-strip__item--active {\n border-color: rgba(255, 255, 255, 0.9);\n background: rgba(255, 255, 255, 0.18);\n box-shadow: 0 5px 18px rgba(0, 0, 0, 0.3);\n opacity: 1;\n transform: scale(1.06);\n}\n.gallery-strip__item:focus-visible {\n outline: 2px solid #fff;\n outline-offset: 2px;\n}\n.gallery-strip__media {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 100%;\n overflow: hidden;\n border-radius: 9px;\n background: rgba(255, 255, 255, 0.08);\n color: rgba(255, 255, 255, 0.5);\n}\n.gallery-strip__fallback {\n position: absolute;\n}\n.gallery-strip__img {\n position: relative;\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n user-select: none;\n -webkit-user-drag: none;\n}\n\n@media (max-width: 640px) {\n .gallery-strip {\n gap: 5px;\n max-width: calc(100vw - 28px);\n padding: 7px 8px;\n border-radius: 16px;\n }\n .gallery-strip__item {\n width: 50px;\n height: 50px;\n border-radius: 11px;\n }\n}\n</style>\n"],"mappings":";;;;;;;;;;;uBAoBM,IAAc;;;;;;;;EAfpB,IAAM,IAAQ,GAKR,IAAO,GAWP,IAAe,QAA8B;GACjD,IAAM,IAAQ,EAAM,MAAM,QACpB,IAAW,KAAK,IAAI,GAAG,IAAQ,CAAW,GAC1C,IAAiB,EAAM,eAAe,KAAK,MAAM,IAAc,CAAC,GAChE,IAAQ,KAAK,IAAI,GAAU,KAAK,IAAI,GAAG,CAAc,CAAC;GAC5D,OAAO,EAAM,MACV,MAAM,GAAO,IAAQ,CAAW,CAAA,CAChC,KAAK,GAAO,OAAY;IAAE;IAAO,OAAO,IAAQ;GAAO,EAAE;EAC9D,CAAC;EAED,SAAS,EAAgB,GAAoB;GAC3C,IAAM,IAAQ,EAAM;GACpB,EAAM,SAAS;EACjB;yBAIE,EA4BM,OA5BN,GA4BM,EAAA,EAAA,EAAA,GA3BJ,EA0BS,GAAA,MAAA,EAzBQ,EAAA,QAAR,YADT,EA0BS,UAAA;GAxBN,KAAK,EAAK,MAAM,MAAM,EAAK,MAAM;GAClC,MAAK;GACL,OAAK,EAAA,CAAC,uBAAqB,EAAA,+BACc,EAAK,UAAU,EAAA,aAAY,CAAA,CAAA;GACnE,cAAU,OAAS,EAAK,QAAK,EAAA,OAAY,EAAK,MAAM,OAAG;GACvD,gBAAc,EAAK,UAAU,EAAA,eAAY,SAAY,KAAA;GACrD,OAAO,EAAK,MAAM,OAAG,KAAS,EAAK,QAAK,EAAA;GACxC,UAAK,MAAE,EAAI,UAAW,EAAK,KAAK;MAEjC,EAcO,QAAA;GAbL,OAAM;GACL,OAAK,EAAE,EAAK,MAAM,QAAK,EAAA,iBAAsB,EAAK,MAAM,MAAK,IAAK,KAAA,CAAS;MAE5E,EAAgE,GAAA;GAA1D,OAAM;GAA0B,MAAK;GAAS,MAAM;MAC1D,EAQE,OAAA;GAPA,OAAM;GACL,KAAK,EAAK,MAAM,YAAY,EAAK,MAAM;GACvC,KAAK,EAAK,MAAM,OAAG;GACpB,SAAQ;GACR,WAAU;GACT,SAAO;GACP,aAAS,AAAA,EAAA,OAAA,QAAV,CAAA,GAAkB,CAAA,SAAA,CAAA"}
@@ -2,7 +2,7 @@ import e from "../_virtual/_plugin-vue_export-helper.js";
2
2
  import t from "./LogViewer.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/LogViewer.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-b19d397d"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-3ecea7f7"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8