@l1yp/file-viewer 0.1.4 → 0.1.6

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 (177) hide show
  1. package/README.md +57 -6
  2. package/dist/assets/step.worker-CfDTYOf_.js.map +1 -0
  3. package/dist/components/AnsiText.d.ts +47 -0
  4. package/dist/components/AnsiText.js +41 -0
  5. package/dist/components/AnsiText.js.map +1 -0
  6. package/dist/components/CertViewer.js +1 -1
  7. package/dist/components/CertViewer.js.map +1 -1
  8. package/dist/components/CertViewer.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/DocxNavigationPane.js +1 -1
  19. package/dist/components/DocxNavigationPane.js.map +1 -1
  20. package/dist/components/DocxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  21. package/dist/components/DocxViewer.js +1 -1
  22. package/dist/components/DocxViewer.js.map +1 -1
  23. package/dist/components/DocxViewer.vue.d.ts +1 -1
  24. package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js +182 -50
  25. package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  26. package/dist/components/EplViewer.js +1 -1
  27. package/dist/components/EplViewer.js.map +1 -1
  28. package/dist/components/EplViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  29. package/dist/components/FileViewerBody.js +1 -1
  30. package/dist/components/FileViewerBody.js.map +1 -1
  31. package/dist/components/FileViewerBody.vue.d.ts +1 -0
  32. package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js +292 -281
  33. package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js.map +1 -1
  34. package/dist/components/FileViewerPanel.js +1 -1
  35. package/dist/components/FileViewerPanel.js.map +1 -1
  36. package/dist/components/FileViewerPanel.vue.d.ts +6 -1
  37. package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js +53 -51
  38. package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js.map +1 -1
  39. package/dist/components/HarViewer.js +1 -1
  40. package/dist/components/HarViewer.js.map +1 -1
  41. package/dist/components/HarViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  42. package/dist/components/JksViewer.js +1 -1
  43. package/dist/components/JksViewer.js.map +1 -1
  44. package/dist/components/JksViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  45. package/dist/components/LogViewer.js +1 -1
  46. package/dist/components/LogViewer.js.map +1 -1
  47. package/dist/components/LogViewer.vue.d.ts +3 -1
  48. package/dist/components/LogViewer.vue_vue_type_script_setup_true_lang.js +284 -280
  49. package/dist/components/LogViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  50. package/dist/components/ModelViewModes.js +9 -0
  51. package/dist/components/ModelViewModes.js.map +1 -0
  52. package/dist/components/ModelViewModes.vue.d.ts +11 -0
  53. package/dist/components/ModelViewModes.vue_vue_type_script_setup_true_lang.js +49 -0
  54. package/dist/components/ModelViewModes.vue_vue_type_script_setup_true_lang.js.map +1 -0
  55. package/dist/components/ModelViewer.js +1 -1
  56. package/dist/components/ModelViewer.js.map +1 -1
  57. package/dist/components/ModelViewer.vue.d.ts +2 -1
  58. package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js +183 -96
  59. package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  60. package/dist/components/PdfNavigationPane.js +1 -1
  61. package/dist/components/PdfNavigationPane.js.map +1 -1
  62. package/dist/components/PdfNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  63. package/dist/components/PdfViewer.js +1 -1
  64. package/dist/components/PdfViewer.js.map +1 -1
  65. package/dist/components/PdfViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  66. package/dist/components/PptxNavigationPane.js +1 -1
  67. package/dist/components/PptxNavigationPane.js.map +1 -1
  68. package/dist/components/PptxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  69. package/dist/components/PsdViewer.js +1 -1
  70. package/dist/components/PsdViewer.js.map +1 -1
  71. package/dist/components/PsdViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  72. package/dist/components/SqliteViewer.js +1 -1
  73. package/dist/components/SqliteViewer.js.map +1 -1
  74. package/dist/components/SqliteViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  75. package/dist/components/ViewerLoadError.js +9 -0
  76. package/dist/components/ViewerLoadError.js.map +1 -0
  77. package/dist/components/ViewerLoadError.vue.d.ts +17 -0
  78. package/dist/components/ViewerLoadError.vue_vue_type_script_setup_true_lang.js +58 -0
  79. package/dist/components/ViewerLoadError.vue_vue_type_script_setup_true_lang.js.map +1 -0
  80. package/dist/components/XlsxChart.js +1 -1
  81. package/dist/components/XlsxChart.js.map +1 -1
  82. package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js +9 -9
  83. package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js.map +1 -1
  84. package/dist/components/XlsxImageLayer.js +9 -0
  85. package/dist/components/XlsxImageLayer.js.map +1 -0
  86. package/dist/components/XlsxImageLayer.vue.d.ts +9 -0
  87. package/dist/components/XlsxImageLayer.vue_vue_type_script_setup_true_lang.js +46 -0
  88. package/dist/components/XlsxImageLayer.vue_vue_type_script_setup_true_lang.js.map +1 -0
  89. package/dist/components/XlsxViewer.js +1 -1
  90. package/dist/components/XlsxViewer.js.map +1 -1
  91. package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js +230 -216
  92. package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  93. package/dist/components/epl/EplResource.js +1 -1
  94. package/dist/components/epl/EplResource.js.map +1 -1
  95. package/dist/components/epl/EplResource.vue_vue_type_script_setup_true_lang.js.map +1 -1
  96. package/dist/components/optionalViewer.d.ts +3 -2
  97. package/dist/components/optionalViewer.js +6 -3
  98. package/dist/components/optionalViewer.js.map +1 -1
  99. package/dist/composables/useFileViewer.d.ts +11 -3
  100. package/dist/composables/useFileViewer.js.map +1 -1
  101. package/dist/composables/useTeleportTheme.d.ts +7 -0
  102. package/dist/composables/useTeleportTheme.js +42 -0
  103. package/dist/composables/useTeleportTheme.js.map +1 -0
  104. package/dist/index.d.ts +1 -0
  105. package/dist/index.js +29 -28
  106. package/dist/lib/ansi.d.ts +44 -0
  107. package/dist/lib/ansi.js +224 -0
  108. package/dist/lib/ansi.js.map +1 -0
  109. package/dist/lib/cfb.d.ts +37 -0
  110. package/dist/lib/cfb.js +112 -0
  111. package/dist/lib/cfb.js.map +1 -0
  112. package/dist/lib/docxEmbeddedDom.d.ts +22 -0
  113. package/dist/lib/docxEmbeddedDom.js +128 -0
  114. package/dist/lib/docxEmbeddedDom.js.map +1 -0
  115. package/dist/lib/docxEmbeddings.d.ts +41 -0
  116. package/dist/lib/docxEmbeddings.js +226 -0
  117. package/dist/lib/docxEmbeddings.js.map +1 -0
  118. package/dist/lib/docxTheme.d.ts +6 -0
  119. package/dist/lib/docxTheme.js +64 -0
  120. package/dist/lib/docxTheme.js.map +1 -0
  121. package/dist/lib/epl/code/structures.js +82 -0
  122. package/dist/lib/epl/code/structures.js.map +1 -0
  123. package/dist/lib/epl/code/variable.js +16 -0
  124. package/dist/lib/epl/code/variable.js.map +1 -0
  125. package/dist/lib/epl/eplDocument.js +28 -22
  126. package/dist/lib/epl/eplDocument.js.map +1 -1
  127. package/dist/lib/epl/index.d.ts +2 -1
  128. package/dist/lib/epl/index.js.map +1 -1
  129. package/dist/lib/epl/sections/code.js +37 -0
  130. package/dist/lib/epl/sections/code.js.map +1 -0
  131. package/dist/lib/epl/sections/esystemInfo.js +29 -0
  132. package/dist/lib/epl/sections/esystemInfo.js.map +1 -0
  133. package/dist/lib/epl/sections/losable.js +33 -0
  134. package/dist/lib/epl/sections/losable.js.map +1 -0
  135. package/dist/lib/epl/sections/projectConfig.js +39 -0
  136. package/dist/lib/epl/sections/projectConfig.js.map +1 -0
  137. package/dist/lib/epl/sections/resource.js +84 -0
  138. package/dist/lib/epl/sections/resource.js.map +1 -0
  139. package/dist/lib/fileType.d.ts +3 -1
  140. package/dist/lib/fileType.js +150 -137
  141. package/dist/lib/fileType.js.map +1 -1
  142. package/dist/lib/modelAppearance.d.ts +7 -0
  143. package/dist/lib/modelAppearance.js +41 -0
  144. package/dist/lib/modelAppearance.js.map +1 -0
  145. package/dist/lib/msi.js +62 -154
  146. package/dist/lib/msi.js.map +1 -1
  147. package/dist/lib/olePackage.d.ts +8 -0
  148. package/dist/lib/olePackage.js +37 -0
  149. package/dist/lib/olePackage.js.map +1 -0
  150. package/dist/lib/pptxFirstSlide.d.ts +11 -0
  151. package/dist/lib/pptxFirstSlide.js +34 -0
  152. package/dist/lib/pptxFirstSlide.js.map +1 -0
  153. package/dist/lib/step.d.ts +10 -0
  154. package/dist/lib/step.js +35 -0
  155. package/dist/lib/step.js.map +1 -0
  156. package/dist/lib/step.worker.d.ts +1 -0
  157. package/dist/lib/step.worker.js +18 -0
  158. package/dist/lib/step.worker.js.map +1 -0
  159. package/dist/lib/stepObject.d.ts +4 -0
  160. package/dist/lib/stepObject.js +52 -0
  161. package/dist/lib/stepObject.js.map +1 -0
  162. package/dist/lib/stepTypes.d.ts +60 -0
  163. package/dist/lib/viewerKind.js +10 -5
  164. package/dist/lib/viewerKind.js.map +1 -1
  165. package/dist/lib/xlsx.d.ts +1 -1
  166. package/dist/lib/xlsx.js +106 -89
  167. package/dist/lib/xlsx.js.map +1 -1
  168. package/dist/lib/xlsxCharts.js +71 -119
  169. package/dist/lib/xlsxCharts.js.map +1 -1
  170. package/dist/lib/xlsxImages.d.ts +43 -0
  171. package/dist/lib/xlsxImages.js +114 -0
  172. package/dist/lib/xlsxImages.js.map +1 -0
  173. package/dist/lib/xlsxParts.d.ts +17 -0
  174. package/dist/lib/xlsxParts.js +86 -0
  175. package/dist/lib/xlsxParts.js.map +1 -0
  176. package/dist/style.css +1 -1
  177. package/package.json +8 -3
@@ -1 +1 @@
1
- {"version":3,"file":"PdfViewer.js","names":[],"sources":["../../src/components/PdfViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch } from 'vue'\nimport * as pdfjsLib from 'pdfjs-dist'\nimport type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'\n// pdfjs runs its parser/rasterizer in a web worker. Vite's `?url` emits the real worker asset and returns a\n// servable URL (root-relative in the same-origin prod build, a dev URL under `pnpm dev`).\nimport pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'\n\nimport PdfNavigationPane from './PdfNavigationPane.vue'\nimport { loadPdfNavigation, type PdfNavigationItem } from '../lib/pdfNavigation'\n\npdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl\n\nconst props = defineProps<{\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'ready' | 'error'\nconst status = shallowRef<Status>('loading')\nconst errorMsg = shallowRef('')\nconst pageCount = shallowRef(0)\nconst activePage = shallowRef(1)\nconst outlineItems = shallowRef<PdfNavigationItem[]>([])\nconst pageLabels = shallowRef<string[]>([])\nconst pageThumbnails = shallowRef<Array<string | null>>([])\nconst navigationCollapsed = shallowRef(false)\nconst navigationView = shallowRef<'outline' | 'pages'>('outline')\nconst docEl = useTemplateRef<HTMLElement>('docEl')\n\nlet pdfDoc: PDFDocumentProxy | null = null\nlet renderTasks: RenderTask[] = []\nlet renderToken = 0\nlet resizeTimer: ReturnType<typeof setTimeout> | undefined\nlet lastSize = ''\nlet resizeObserver: ResizeObserver | null = null\nlet scrollFrame = 0\nconst MAX_SCALE = 2\nconst PAGE_GUTTER = 24\nconst THUMBNAIL_WIDTH = 104\n\nfunction cancelRenders(): void {\n for (const task of renderTasks) {\n try {\n task.cancel()\n } catch {\n // The task may already have settled.\n }\n }\n renderTasks = []\n}\n\nfunction disposeDoc(): void {\n cancelRenders()\n if (!pdfDoc) return\n try {\n pdfDoc.destroy()\n } catch {\n // The worker may already have been torn down.\n }\n pdfDoc = null\n}\n\nfunction createPageThumbnail(canvas: HTMLCanvasElement): string | null {\n if (!canvas.width || !canvas.height) return null\n try {\n const thumbnail = document.createElement('canvas')\n thumbnail.width = THUMBNAIL_WIDTH\n thumbnail.height = Math.max(1, Math.round(THUMBNAIL_WIDTH * (canvas.height / canvas.width)))\n const context = thumbnail.getContext('2d')\n if (!context) return null\n context.fillStyle = '#fff'\n context.fillRect(0, 0, thumbnail.width, thumbnail.height)\n context.drawImage(canvas, 0, 0, thumbnail.width, thumbnail.height)\n return thumbnail.toDataURL('image/jpeg', 0.78)\n } catch {\n return null\n }\n}\n\nfunction scrollToPage(pageNumber: number, behavior: ScrollBehavior = 'smooth'): void {\n const el = docEl.value\n const target = el?.querySelector<HTMLElement>(`[data-page-number=\"${pageNumber}\"]`)\n if (!el || !target) return\n activePage.value = pageNumber\n const rootRect = el.getBoundingClientRect()\n const targetRect = target.getBoundingClientRect()\n const paddingTop = Number.parseFloat(getComputedStyle(el).paddingTop)\n el.scrollTo({\n top: el.scrollTop + targetRect.top - rootRect.top - paddingTop,\n behavior,\n })\n}\n\nfunction syncActivePage(): void {\n if (scrollFrame) return\n scrollFrame = window.requestAnimationFrame(() => {\n scrollFrame = 0\n const el = docEl.value\n if (!el) return\n const rootRect = el.getBoundingClientRect()\n const targetY = rootRect.top + Math.min(el.clientHeight * 0.36, 220)\n let nearestPage = activePage.value\n let nearestDistance = Number.POSITIVE_INFINITY\n for (const page of el.querySelectorAll<HTMLElement>('[data-page-number]')) {\n const rect = page.getBoundingClientRect()\n const pageTarget = Math.min(Math.max(targetY, rect.top), rect.bottom)\n const distance = Math.abs(targetY - pageTarget)\n if (distance >= nearestDistance) continue\n nearestDistance = distance\n nearestPage = Number(page.dataset.pageNumber) || nearestPage\n }\n activePage.value = nearestPage\n })\n}\n\nasync function renderPages(): Promise<void> {\n const el = docEl.value\n const doc = pdfDoc\n if (!el || !doc) return\n const token = ++renderToken\n const preservedPage = activePage.value\n const preserveScroll = el.scrollTop > 0\n cancelRenders()\n el.innerHTML = ''\n\n const style = getComputedStyle(el)\n const padX = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight)\n const padY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom)\n const availableWidth = Math.max(120, el.clientWidth - padX - PAGE_GUTTER)\n const availableHeight = Math.max(160, el.clientHeight - padY - PAGE_GUTTER)\n const outputScale = Math.min(window.devicePixelRatio || 1, MAX_SCALE)\n const thumbnails = Array<string | null>(doc.numPages).fill(null)\n\n for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber += 1) {\n let page\n try {\n page = await doc.getPage(pageNumber)\n } catch {\n if (token !== renderToken) return\n continue\n }\n if (token !== renderToken) return\n\n const base = page.getViewport({ scale: 1 })\n const cssScale = Math.min(availableWidth / base.width, availableHeight / base.height)\n const cssWidth = Math.round(base.width * cssScale)\n const cssHeight = Math.round(base.height * cssScale)\n const viewport = page.getViewport({ scale: cssScale * outputScale })\n\n const pageWrap = document.createElement('div')\n pageWrap.className = 'pf__page-wrap'\n pageWrap.dataset.pageNumber = `${pageNumber}`\n const canvas = document.createElement('canvas')\n canvas.className = 'pf__page'\n canvas.width = Math.ceil(viewport.width)\n canvas.height = Math.ceil(viewport.height)\n canvas.style.width = `${cssWidth}px`\n canvas.style.height = `${cssHeight}px`\n canvas.setAttribute('aria-label', `PDF 第 ${pageNumber} 页`)\n if (!canvas.getContext('2d')) continue\n pageWrap.appendChild(canvas)\n el.appendChild(pageWrap)\n\n const task = page.render({ canvas, viewport })\n renderTasks.push(task)\n let rendered = true\n try {\n await task.promise\n } catch {\n if (token !== renderToken) return\n rendered = false\n }\n if (token !== renderToken) return\n if (rendered) thumbnails[pageNumber - 1] = createPageThumbnail(canvas)\n }\n\n pageThumbnails.value = thumbnails\n if (preserveScroll) scrollToPage(preservedPage, 'auto')\n syncActivePage()\n}\n\nasync function load(): Promise<void> {\n const el = docEl.value\n if (!props.url || !el) return\n status.value = 'loading'\n errorMsg.value = ''\n outlineItems.value = []\n pageLabels.value = []\n pageThumbnails.value = []\n disposeDoc()\n el.innerHTML = ''\n const url = props.url\n\n try {\n const response = await fetch(url)\n if (!response.ok) throw new Error(`HTTP ${response.status}`)\n const bytes = new Uint8Array(await response.arrayBuffer())\n if (props.url !== url || docEl.value !== el) return\n\n const document = await pdfjsLib.getDocument({ data: bytes }).promise\n if (props.url !== url || docEl.value !== el) {\n document.destroy()\n return\n }\n pdfDoc = document\n pageCount.value = document.numPages\n activePage.value = 1\n const navigation = await loadPdfNavigation(document)\n if (props.url !== url || docEl.value !== el) return\n outlineItems.value = navigation.outline\n pageLabels.value = navigation.pageLabels\n navigationView.value = navigation.outline.length ? 'outline' : 'pages'\n await renderPages()\n if (props.url !== url || docEl.value !== el) return\n status.value = 'ready'\n } catch (cause) {\n errorMsg.value = cause instanceof Error ? cause.message : String(cause)\n status.value = 'error'\n }\n}\n\nfunction onResize(): void {\n const el = docEl.value\n if (!el || !pdfDoc) return\n const size = `${el.clientWidth}x${el.clientHeight}`\n if (!el.clientWidth || !el.clientHeight || size === lastSize) return\n lastSize = size\n clearTimeout(resizeTimer)\n resizeTimer = setTimeout(() => {\n if (pdfDoc) void renderPages()\n }, 180)\n}\n\nonMounted(() => {\n void load()\n if (typeof ResizeObserver !== 'undefined' && docEl.value) {\n lastSize = `${docEl.value.clientWidth}x${docEl.value.clientHeight}`\n resizeObserver = new ResizeObserver(onResize)\n resizeObserver.observe(docEl.value)\n }\n})\nwatch(() => props.url, load)\nonBeforeUnmount(() => {\n clearTimeout(resizeTimer)\n resizeObserver?.disconnect()\n if (scrollFrame) window.cancelAnimationFrame(scrollFrame)\n renderToken += 1\n disposeDoc()\n if (docEl.value) docEl.value.innerHTML = ''\n})\n</script>\n\n<template>\n <div class=\"pf\">\n <PdfNavigationPane\n v-if=\"status === 'ready'\"\n v-model:collapsed=\"navigationCollapsed\"\n v-model:view=\"navigationView\"\n :outline-items=\"outlineItems\"\n :page-count=\"pageCount\"\n :page-labels=\"pageLabels\"\n :page-thumbnails=\"pageThumbnails\"\n :active-page=\"activePage\"\n @navigate=\"scrollToPage\"\n />\n <div class=\"pf__stage\">\n <div ref=\"docEl\" class=\"pf__doc\" @scroll.passive=\"syncActivePage\" />\n <div v-if=\"status === 'loading'\" class=\"pf__overlay\">\n <p class=\"pf__msg\">正在渲染 PDF…</p>\n </div>\n <div v-else-if=\"status === 'error'\" class=\"pf__overlay\">\n <div class=\"pf__msg pf__msg--error\">\n <p>渲染失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"pf__retry\" @click=\"load\">重试</button>\n </div>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.pf {\n position: relative;\n min-width: 0;\n min-height: 0;\n display: flex;\n flex: 1 1 auto;\n overflow: hidden;\n container-type: inline-size;\n background: var(--surface);\n}\n\n.pf__stage {\n position: relative;\n min-width: 0;\n min-height: 0;\n display: flex;\n flex: 1 1 0;\n overflow: hidden;\n}\n\n.pf__doc {\n width: 100%;\n min-width: 0;\n min-height: 0;\n flex: 1 1 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 24px 20px 42px;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n box-sizing: border-box;\n background: var(--surface-2);\n scrollbar-color: #555961 transparent;\n scrollbar-width: thin;\n}\n\n.pf__doc :deep(.pf__page-wrap) {\n max-width: 100%;\n display: flex;\n flex: none;\n justify-content: center;\n}\n\n.pf__doc :deep(.pf__page) {\n max-width: 100%;\n display: block;\n background: #fff;\n border-radius: 2px;\n box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);\n}\n\n.pf__overlay {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: flex-start;\n justify-content: flex-start;\n background: var(--surface);\n}\n\n.pf__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n\n.pf__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n\n.pf__retry {\n padding: 5px 12px;\n color: var(--text);\n font-family: inherit;\n font-size: 12.5px;\n cursor: pointer;\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n}\n\n.pf__retry:hover {\n color: var(--accent);\n border-color: var(--accent);\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"PdfViewer.js","names":[],"sources":["../../src/components/PdfViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch } from 'vue'\nimport * as pdfjsLib from 'pdfjs-dist'\nimport type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'\n// pdfjs runs its parser/rasterizer in a web worker. Vite's `?url` emits the real worker asset and returns a\n// servable URL (root-relative in the same-origin prod build, a dev URL under `pnpm dev`).\nimport pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'\n\nimport PdfNavigationPane from './PdfNavigationPane.vue'\nimport { loadPdfNavigation, type PdfNavigationItem } from '../lib/pdfNavigation'\n\npdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl\n\nconst props = defineProps<{\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'ready' | 'error'\nconst status = shallowRef<Status>('loading')\nconst errorMsg = shallowRef('')\nconst pageCount = shallowRef(0)\nconst activePage = shallowRef(1)\nconst outlineItems = shallowRef<PdfNavigationItem[]>([])\nconst pageLabels = shallowRef<string[]>([])\nconst pageThumbnails = shallowRef<Array<string | null>>([])\nconst navigationCollapsed = shallowRef(false)\nconst navigationView = shallowRef<'outline' | 'pages'>('outline')\nconst docEl = useTemplateRef<HTMLElement>('docEl')\n\nlet pdfDoc: PDFDocumentProxy | null = null\nlet renderTasks: RenderTask[] = []\nlet renderToken = 0\nlet resizeTimer: ReturnType<typeof setTimeout> | undefined\nlet lastSize = ''\nlet resizeObserver: ResizeObserver | null = null\nlet scrollFrame = 0\nconst MAX_SCALE = 2\nconst PAGE_GUTTER = 24\nconst THUMBNAIL_WIDTH = 104\n\nfunction cancelRenders(): void {\n for (const task of renderTasks) {\n try {\n task.cancel()\n } catch {\n // The task may already have settled.\n }\n }\n renderTasks = []\n}\n\nfunction disposeDoc(): void {\n cancelRenders()\n if (!pdfDoc) return\n try {\n pdfDoc.destroy()\n } catch {\n // The worker may already have been torn down.\n }\n pdfDoc = null\n}\n\nfunction createPageThumbnail(canvas: HTMLCanvasElement): string | null {\n if (!canvas.width || !canvas.height) return null\n try {\n const thumbnail = document.createElement('canvas')\n thumbnail.width = THUMBNAIL_WIDTH\n thumbnail.height = Math.max(1, Math.round(THUMBNAIL_WIDTH * (canvas.height / canvas.width)))\n const context = thumbnail.getContext('2d')\n if (!context) return null\n context.fillStyle = '#fff'\n context.fillRect(0, 0, thumbnail.width, thumbnail.height)\n context.drawImage(canvas, 0, 0, thumbnail.width, thumbnail.height)\n return thumbnail.toDataURL('image/jpeg', 0.78)\n } catch {\n return null\n }\n}\n\nfunction scrollToPage(pageNumber: number, behavior: ScrollBehavior = 'smooth'): void {\n const el = docEl.value\n const target = el?.querySelector<HTMLElement>(`[data-page-number=\"${pageNumber}\"]`)\n if (!el || !target) return\n activePage.value = pageNumber\n const rootRect = el.getBoundingClientRect()\n const targetRect = target.getBoundingClientRect()\n const paddingTop = Number.parseFloat(getComputedStyle(el).paddingTop)\n el.scrollTo({\n top: el.scrollTop + targetRect.top - rootRect.top - paddingTop,\n behavior,\n })\n}\n\nfunction syncActivePage(): void {\n if (scrollFrame) return\n scrollFrame = window.requestAnimationFrame(() => {\n scrollFrame = 0\n const el = docEl.value\n if (!el) return\n const rootRect = el.getBoundingClientRect()\n const targetY = rootRect.top + Math.min(el.clientHeight * 0.36, 220)\n let nearestPage = activePage.value\n let nearestDistance = Number.POSITIVE_INFINITY\n for (const page of el.querySelectorAll<HTMLElement>('[data-page-number]')) {\n const rect = page.getBoundingClientRect()\n const pageTarget = Math.min(Math.max(targetY, rect.top), rect.bottom)\n const distance = Math.abs(targetY - pageTarget)\n if (distance >= nearestDistance) continue\n nearestDistance = distance\n nearestPage = Number(page.dataset.pageNumber) || nearestPage\n }\n activePage.value = nearestPage\n })\n}\n\nasync function renderPages(): Promise<void> {\n const el = docEl.value\n const doc = pdfDoc\n if (!el || !doc) return\n const token = ++renderToken\n const preservedPage = activePage.value\n const preserveScroll = el.scrollTop > 0\n cancelRenders()\n el.innerHTML = ''\n\n const style = getComputedStyle(el)\n const padX = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight)\n const padY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom)\n const availableWidth = Math.max(120, el.clientWidth - padX - PAGE_GUTTER)\n const availableHeight = Math.max(160, el.clientHeight - padY - PAGE_GUTTER)\n const outputScale = Math.min(window.devicePixelRatio || 1, MAX_SCALE)\n const thumbnails = Array<string | null>(doc.numPages).fill(null)\n\n for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber += 1) {\n let page\n try {\n page = await doc.getPage(pageNumber)\n } catch {\n if (token !== renderToken) return\n continue\n }\n if (token !== renderToken) return\n\n const base = page.getViewport({ scale: 1 })\n const cssScale = Math.min(availableWidth / base.width, availableHeight / base.height)\n const cssWidth = Math.round(base.width * cssScale)\n const cssHeight = Math.round(base.height * cssScale)\n const viewport = page.getViewport({ scale: cssScale * outputScale })\n\n const pageWrap = document.createElement('div')\n pageWrap.className = 'pf__page-wrap'\n pageWrap.dataset.pageNumber = `${pageNumber}`\n const canvas = document.createElement('canvas')\n canvas.className = 'pf__page'\n canvas.width = Math.ceil(viewport.width)\n canvas.height = Math.ceil(viewport.height)\n canvas.style.width = `${cssWidth}px`\n canvas.style.height = `${cssHeight}px`\n canvas.setAttribute('aria-label', `PDF 第 ${pageNumber} 页`)\n if (!canvas.getContext('2d')) continue\n pageWrap.appendChild(canvas)\n el.appendChild(pageWrap)\n\n const task = page.render({ canvas, viewport })\n renderTasks.push(task)\n let rendered = true\n try {\n await task.promise\n } catch {\n if (token !== renderToken) return\n rendered = false\n }\n if (token !== renderToken) return\n if (rendered) thumbnails[pageNumber - 1] = createPageThumbnail(canvas)\n }\n\n pageThumbnails.value = thumbnails\n if (preserveScroll) scrollToPage(preservedPage, 'auto')\n syncActivePage()\n}\n\nasync function load(): Promise<void> {\n const el = docEl.value\n if (!props.url || !el) return\n status.value = 'loading'\n errorMsg.value = ''\n outlineItems.value = []\n pageLabels.value = []\n pageThumbnails.value = []\n disposeDoc()\n el.innerHTML = ''\n const url = props.url\n\n try {\n const response = await fetch(url)\n if (!response.ok) throw new Error(`HTTP ${response.status}`)\n const bytes = new Uint8Array(await response.arrayBuffer())\n if (props.url !== url || docEl.value !== el) return\n\n const document = await pdfjsLib.getDocument({ data: bytes }).promise\n if (props.url !== url || docEl.value !== el) {\n document.destroy()\n return\n }\n pdfDoc = document\n pageCount.value = document.numPages\n activePage.value = 1\n const navigation = await loadPdfNavigation(document)\n if (props.url !== url || docEl.value !== el) return\n outlineItems.value = navigation.outline\n pageLabels.value = navigation.pageLabels\n navigationView.value = navigation.outline.length ? 'outline' : 'pages'\n await renderPages()\n if (props.url !== url || docEl.value !== el) return\n status.value = 'ready'\n } catch (cause) {\n errorMsg.value = cause instanceof Error ? cause.message : String(cause)\n status.value = 'error'\n }\n}\n\nfunction onResize(): void {\n const el = docEl.value\n if (!el || !pdfDoc) return\n const size = `${el.clientWidth}x${el.clientHeight}`\n if (!el.clientWidth || !el.clientHeight || size === lastSize) return\n lastSize = size\n clearTimeout(resizeTimer)\n resizeTimer = setTimeout(() => {\n if (pdfDoc) void renderPages()\n }, 180)\n}\n\nonMounted(() => {\n void load()\n if (typeof ResizeObserver !== 'undefined' && docEl.value) {\n lastSize = `${docEl.value.clientWidth}x${docEl.value.clientHeight}`\n resizeObserver = new ResizeObserver(onResize)\n resizeObserver.observe(docEl.value)\n }\n})\nwatch(() => props.url, load)\nonBeforeUnmount(() => {\n clearTimeout(resizeTimer)\n resizeObserver?.disconnect()\n if (scrollFrame) window.cancelAnimationFrame(scrollFrame)\n renderToken += 1\n disposeDoc()\n if (docEl.value) docEl.value.innerHTML = ''\n})\n</script>\n\n<template>\n <div class=\"pf\">\n <PdfNavigationPane\n v-if=\"status === 'ready'\"\n v-model:collapsed=\"navigationCollapsed\"\n v-model:view=\"navigationView\"\n :outline-items=\"outlineItems\"\n :page-count=\"pageCount\"\n :page-labels=\"pageLabels\"\n :page-thumbnails=\"pageThumbnails\"\n :active-page=\"activePage\"\n @navigate=\"scrollToPage\"\n />\n <div class=\"pf__stage\">\n <div ref=\"docEl\" class=\"pf__doc\" @scroll.passive=\"syncActivePage\" />\n <div v-if=\"status === 'loading'\" class=\"pf__overlay\">\n <p class=\"pf__msg\">正在渲染 PDF…</p>\n </div>\n <div v-else-if=\"status === 'error'\" class=\"pf__overlay\">\n <div class=\"pf__msg pf__msg--error\">\n <p>渲染失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"pf__retry\" @click=\"load\">重试</button>\n </div>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.pf {\n position: relative;\n min-width: 0;\n min-height: 0;\n display: flex;\n flex: 1 1 auto;\n overflow: hidden;\n container-type: inline-size;\n background: var(--surface);\n}\n\n.pf__stage {\n position: relative;\n min-width: 0;\n min-height: 0;\n display: flex;\n flex: 1 1 0;\n overflow: hidden;\n}\n\n.pf__doc {\n width: 100%;\n min-width: 0;\n min-height: 0;\n flex: 1 1 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 24px 20px 42px;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n box-sizing: border-box;\n background: var(--surface-2);\n scrollbar-color: var(--border-2) transparent;\n scrollbar-width: thin;\n}\n\n.pf__doc :deep(.pf__page-wrap) {\n max-width: 100%;\n display: flex;\n flex: none;\n justify-content: center;\n}\n\n.pf__doc :deep(.pf__page) {\n max-width: 100%;\n display: block;\n background: #fff;\n border-radius: 2px;\n box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);\n}\n\n.pf__overlay {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: flex-start;\n justify-content: flex-start;\n background: var(--surface);\n}\n\n.pf__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n\n.pf__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n\n.pf__retry {\n padding: 5px 12px;\n color: var(--text);\n font-family: inherit;\n font-size: 12.5px;\n cursor: pointer;\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n}\n\n.pf__retry:hover {\n color: var(--accent);\n border-color: var(--accent);\n}\n</style>\n"],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"PdfViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/PdfViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch } from 'vue'\nimport * as pdfjsLib from 'pdfjs-dist'\nimport type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'\n// pdfjs runs its parser/rasterizer in a web worker. Vite's `?url` emits the real worker asset and returns a\n// servable URL (root-relative in the same-origin prod build, a dev URL under `pnpm dev`).\nimport pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'\n\nimport PdfNavigationPane from './PdfNavigationPane.vue'\nimport { loadPdfNavigation, type PdfNavigationItem } from '../lib/pdfNavigation'\n\npdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl\n\nconst props = defineProps<{\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'ready' | 'error'\nconst status = shallowRef<Status>('loading')\nconst errorMsg = shallowRef('')\nconst pageCount = shallowRef(0)\nconst activePage = shallowRef(1)\nconst outlineItems = shallowRef<PdfNavigationItem[]>([])\nconst pageLabels = shallowRef<string[]>([])\nconst pageThumbnails = shallowRef<Array<string | null>>([])\nconst navigationCollapsed = shallowRef(false)\nconst navigationView = shallowRef<'outline' | 'pages'>('outline')\nconst docEl = useTemplateRef<HTMLElement>('docEl')\n\nlet pdfDoc: PDFDocumentProxy | null = null\nlet renderTasks: RenderTask[] = []\nlet renderToken = 0\nlet resizeTimer: ReturnType<typeof setTimeout> | undefined\nlet lastSize = ''\nlet resizeObserver: ResizeObserver | null = null\nlet scrollFrame = 0\nconst MAX_SCALE = 2\nconst PAGE_GUTTER = 24\nconst THUMBNAIL_WIDTH = 104\n\nfunction cancelRenders(): void {\n for (const task of renderTasks) {\n try {\n task.cancel()\n } catch {\n // The task may already have settled.\n }\n }\n renderTasks = []\n}\n\nfunction disposeDoc(): void {\n cancelRenders()\n if (!pdfDoc) return\n try {\n pdfDoc.destroy()\n } catch {\n // The worker may already have been torn down.\n }\n pdfDoc = null\n}\n\nfunction createPageThumbnail(canvas: HTMLCanvasElement): string | null {\n if (!canvas.width || !canvas.height) return null\n try {\n const thumbnail = document.createElement('canvas')\n thumbnail.width = THUMBNAIL_WIDTH\n thumbnail.height = Math.max(1, Math.round(THUMBNAIL_WIDTH * (canvas.height / canvas.width)))\n const context = thumbnail.getContext('2d')\n if (!context) return null\n context.fillStyle = '#fff'\n context.fillRect(0, 0, thumbnail.width, thumbnail.height)\n context.drawImage(canvas, 0, 0, thumbnail.width, thumbnail.height)\n return thumbnail.toDataURL('image/jpeg', 0.78)\n } catch {\n return null\n }\n}\n\nfunction scrollToPage(pageNumber: number, behavior: ScrollBehavior = 'smooth'): void {\n const el = docEl.value\n const target = el?.querySelector<HTMLElement>(`[data-page-number=\"${pageNumber}\"]`)\n if (!el || !target) return\n activePage.value = pageNumber\n const rootRect = el.getBoundingClientRect()\n const targetRect = target.getBoundingClientRect()\n const paddingTop = Number.parseFloat(getComputedStyle(el).paddingTop)\n el.scrollTo({\n top: el.scrollTop + targetRect.top - rootRect.top - paddingTop,\n behavior,\n })\n}\n\nfunction syncActivePage(): void {\n if (scrollFrame) return\n scrollFrame = window.requestAnimationFrame(() => {\n scrollFrame = 0\n const el = docEl.value\n if (!el) return\n const rootRect = el.getBoundingClientRect()\n const targetY = rootRect.top + Math.min(el.clientHeight * 0.36, 220)\n let nearestPage = activePage.value\n let nearestDistance = Number.POSITIVE_INFINITY\n for (const page of el.querySelectorAll<HTMLElement>('[data-page-number]')) {\n const rect = page.getBoundingClientRect()\n const pageTarget = Math.min(Math.max(targetY, rect.top), rect.bottom)\n const distance = Math.abs(targetY - pageTarget)\n if (distance >= nearestDistance) continue\n nearestDistance = distance\n nearestPage = Number(page.dataset.pageNumber) || nearestPage\n }\n activePage.value = nearestPage\n })\n}\n\nasync function renderPages(): Promise<void> {\n const el = docEl.value\n const doc = pdfDoc\n if (!el || !doc) return\n const token = ++renderToken\n const preservedPage = activePage.value\n const preserveScroll = el.scrollTop > 0\n cancelRenders()\n el.innerHTML = ''\n\n const style = getComputedStyle(el)\n const padX = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight)\n const padY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom)\n const availableWidth = Math.max(120, el.clientWidth - padX - PAGE_GUTTER)\n const availableHeight = Math.max(160, el.clientHeight - padY - PAGE_GUTTER)\n const outputScale = Math.min(window.devicePixelRatio || 1, MAX_SCALE)\n const thumbnails = Array<string | null>(doc.numPages).fill(null)\n\n for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber += 1) {\n let page\n try {\n page = await doc.getPage(pageNumber)\n } catch {\n if (token !== renderToken) return\n continue\n }\n if (token !== renderToken) return\n\n const base = page.getViewport({ scale: 1 })\n const cssScale = Math.min(availableWidth / base.width, availableHeight / base.height)\n const cssWidth = Math.round(base.width * cssScale)\n const cssHeight = Math.round(base.height * cssScale)\n const viewport = page.getViewport({ scale: cssScale * outputScale })\n\n const pageWrap = document.createElement('div')\n pageWrap.className = 'pf__page-wrap'\n pageWrap.dataset.pageNumber = `${pageNumber}`\n const canvas = document.createElement('canvas')\n canvas.className = 'pf__page'\n canvas.width = Math.ceil(viewport.width)\n canvas.height = Math.ceil(viewport.height)\n canvas.style.width = `${cssWidth}px`\n canvas.style.height = `${cssHeight}px`\n canvas.setAttribute('aria-label', `PDF 第 ${pageNumber} 页`)\n if (!canvas.getContext('2d')) continue\n pageWrap.appendChild(canvas)\n el.appendChild(pageWrap)\n\n const task = page.render({ canvas, viewport })\n renderTasks.push(task)\n let rendered = true\n try {\n await task.promise\n } catch {\n if (token !== renderToken) return\n rendered = false\n }\n if (token !== renderToken) return\n if (rendered) thumbnails[pageNumber - 1] = createPageThumbnail(canvas)\n }\n\n pageThumbnails.value = thumbnails\n if (preserveScroll) scrollToPage(preservedPage, 'auto')\n syncActivePage()\n}\n\nasync function load(): Promise<void> {\n const el = docEl.value\n if (!props.url || !el) return\n status.value = 'loading'\n errorMsg.value = ''\n outlineItems.value = []\n pageLabels.value = []\n pageThumbnails.value = []\n disposeDoc()\n el.innerHTML = ''\n const url = props.url\n\n try {\n const response = await fetch(url)\n if (!response.ok) throw new Error(`HTTP ${response.status}`)\n const bytes = new Uint8Array(await response.arrayBuffer())\n if (props.url !== url || docEl.value !== el) return\n\n const document = await pdfjsLib.getDocument({ data: bytes }).promise\n if (props.url !== url || docEl.value !== el) {\n document.destroy()\n return\n }\n pdfDoc = document\n pageCount.value = document.numPages\n activePage.value = 1\n const navigation = await loadPdfNavigation(document)\n if (props.url !== url || docEl.value !== el) return\n outlineItems.value = navigation.outline\n pageLabels.value = navigation.pageLabels\n navigationView.value = navigation.outline.length ? 'outline' : 'pages'\n await renderPages()\n if (props.url !== url || docEl.value !== el) return\n status.value = 'ready'\n } catch (cause) {\n errorMsg.value = cause instanceof Error ? cause.message : String(cause)\n status.value = 'error'\n }\n}\n\nfunction onResize(): void {\n const el = docEl.value\n if (!el || !pdfDoc) return\n const size = `${el.clientWidth}x${el.clientHeight}`\n if (!el.clientWidth || !el.clientHeight || size === lastSize) return\n lastSize = size\n clearTimeout(resizeTimer)\n resizeTimer = setTimeout(() => {\n if (pdfDoc) void renderPages()\n }, 180)\n}\n\nonMounted(() => {\n void load()\n if (typeof ResizeObserver !== 'undefined' && docEl.value) {\n lastSize = `${docEl.value.clientWidth}x${docEl.value.clientHeight}`\n resizeObserver = new ResizeObserver(onResize)\n resizeObserver.observe(docEl.value)\n }\n})\nwatch(() => props.url, load)\nonBeforeUnmount(() => {\n clearTimeout(resizeTimer)\n resizeObserver?.disconnect()\n if (scrollFrame) window.cancelAnimationFrame(scrollFrame)\n renderToken += 1\n disposeDoc()\n if (docEl.value) docEl.value.innerHTML = ''\n})\n</script>\n\n<template>\n <div class=\"pf\">\n <PdfNavigationPane\n v-if=\"status === 'ready'\"\n v-model:collapsed=\"navigationCollapsed\"\n v-model:view=\"navigationView\"\n :outline-items=\"outlineItems\"\n :page-count=\"pageCount\"\n :page-labels=\"pageLabels\"\n :page-thumbnails=\"pageThumbnails\"\n :active-page=\"activePage\"\n @navigate=\"scrollToPage\"\n />\n <div class=\"pf__stage\">\n <div ref=\"docEl\" class=\"pf__doc\" @scroll.passive=\"syncActivePage\" />\n <div v-if=\"status === 'loading'\" class=\"pf__overlay\">\n <p class=\"pf__msg\">正在渲染 PDF…</p>\n </div>\n <div v-else-if=\"status === 'error'\" class=\"pf__overlay\">\n <div class=\"pf__msg pf__msg--error\">\n <p>渲染失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"pf__retry\" @click=\"load\">重试</button>\n </div>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.pf {\n position: relative;\n min-width: 0;\n min-height: 0;\n display: flex;\n flex: 1 1 auto;\n overflow: hidden;\n container-type: inline-size;\n background: var(--surface);\n}\n\n.pf__stage {\n position: relative;\n min-width: 0;\n min-height: 0;\n display: flex;\n flex: 1 1 0;\n overflow: hidden;\n}\n\n.pf__doc {\n width: 100%;\n min-width: 0;\n min-height: 0;\n flex: 1 1 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 24px 20px 42px;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n box-sizing: border-box;\n background: var(--surface-2);\n scrollbar-color: #555961 transparent;\n scrollbar-width: thin;\n}\n\n.pf__doc :deep(.pf__page-wrap) {\n max-width: 100%;\n display: flex;\n flex: none;\n justify-content: center;\n}\n\n.pf__doc :deep(.pf__page) {\n max-width: 100%;\n display: block;\n background: #fff;\n border-radius: 2px;\n box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);\n}\n\n.pf__overlay {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: flex-start;\n justify-content: flex-start;\n background: var(--surface);\n}\n\n.pf__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n\n.pf__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n\n.pf__retry {\n padding: 5px 12px;\n color: var(--text);\n font-family: inherit;\n font-size: 12.5px;\n cursor: pointer;\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n}\n\n.pf__retry:hover {\n color: var(--accent);\n border-color: var(--accent);\n}\n</style>\n"],"mappings":"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"}
1
+ {"version":3,"file":"PdfViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/PdfViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch } from 'vue'\nimport * as pdfjsLib from 'pdfjs-dist'\nimport type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'\n// pdfjs runs its parser/rasterizer in a web worker. Vite's `?url` emits the real worker asset and returns a\n// servable URL (root-relative in the same-origin prod build, a dev URL under `pnpm dev`).\nimport pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'\n\nimport PdfNavigationPane from './PdfNavigationPane.vue'\nimport { loadPdfNavigation, type PdfNavigationItem } from '../lib/pdfNavigation'\n\npdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl\n\nconst props = defineProps<{\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'ready' | 'error'\nconst status = shallowRef<Status>('loading')\nconst errorMsg = shallowRef('')\nconst pageCount = shallowRef(0)\nconst activePage = shallowRef(1)\nconst outlineItems = shallowRef<PdfNavigationItem[]>([])\nconst pageLabels = shallowRef<string[]>([])\nconst pageThumbnails = shallowRef<Array<string | null>>([])\nconst navigationCollapsed = shallowRef(false)\nconst navigationView = shallowRef<'outline' | 'pages'>('outline')\nconst docEl = useTemplateRef<HTMLElement>('docEl')\n\nlet pdfDoc: PDFDocumentProxy | null = null\nlet renderTasks: RenderTask[] = []\nlet renderToken = 0\nlet resizeTimer: ReturnType<typeof setTimeout> | undefined\nlet lastSize = ''\nlet resizeObserver: ResizeObserver | null = null\nlet scrollFrame = 0\nconst MAX_SCALE = 2\nconst PAGE_GUTTER = 24\nconst THUMBNAIL_WIDTH = 104\n\nfunction cancelRenders(): void {\n for (const task of renderTasks) {\n try {\n task.cancel()\n } catch {\n // The task may already have settled.\n }\n }\n renderTasks = []\n}\n\nfunction disposeDoc(): void {\n cancelRenders()\n if (!pdfDoc) return\n try {\n pdfDoc.destroy()\n } catch {\n // The worker may already have been torn down.\n }\n pdfDoc = null\n}\n\nfunction createPageThumbnail(canvas: HTMLCanvasElement): string | null {\n if (!canvas.width || !canvas.height) return null\n try {\n const thumbnail = document.createElement('canvas')\n thumbnail.width = THUMBNAIL_WIDTH\n thumbnail.height = Math.max(1, Math.round(THUMBNAIL_WIDTH * (canvas.height / canvas.width)))\n const context = thumbnail.getContext('2d')\n if (!context) return null\n context.fillStyle = '#fff'\n context.fillRect(0, 0, thumbnail.width, thumbnail.height)\n context.drawImage(canvas, 0, 0, thumbnail.width, thumbnail.height)\n return thumbnail.toDataURL('image/jpeg', 0.78)\n } catch {\n return null\n }\n}\n\nfunction scrollToPage(pageNumber: number, behavior: ScrollBehavior = 'smooth'): void {\n const el = docEl.value\n const target = el?.querySelector<HTMLElement>(`[data-page-number=\"${pageNumber}\"]`)\n if (!el || !target) return\n activePage.value = pageNumber\n const rootRect = el.getBoundingClientRect()\n const targetRect = target.getBoundingClientRect()\n const paddingTop = Number.parseFloat(getComputedStyle(el).paddingTop)\n el.scrollTo({\n top: el.scrollTop + targetRect.top - rootRect.top - paddingTop,\n behavior,\n })\n}\n\nfunction syncActivePage(): void {\n if (scrollFrame) return\n scrollFrame = window.requestAnimationFrame(() => {\n scrollFrame = 0\n const el = docEl.value\n if (!el) return\n const rootRect = el.getBoundingClientRect()\n const targetY = rootRect.top + Math.min(el.clientHeight * 0.36, 220)\n let nearestPage = activePage.value\n let nearestDistance = Number.POSITIVE_INFINITY\n for (const page of el.querySelectorAll<HTMLElement>('[data-page-number]')) {\n const rect = page.getBoundingClientRect()\n const pageTarget = Math.min(Math.max(targetY, rect.top), rect.bottom)\n const distance = Math.abs(targetY - pageTarget)\n if (distance >= nearestDistance) continue\n nearestDistance = distance\n nearestPage = Number(page.dataset.pageNumber) || nearestPage\n }\n activePage.value = nearestPage\n })\n}\n\nasync function renderPages(): Promise<void> {\n const el = docEl.value\n const doc = pdfDoc\n if (!el || !doc) return\n const token = ++renderToken\n const preservedPage = activePage.value\n const preserveScroll = el.scrollTop > 0\n cancelRenders()\n el.innerHTML = ''\n\n const style = getComputedStyle(el)\n const padX = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight)\n const padY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom)\n const availableWidth = Math.max(120, el.clientWidth - padX - PAGE_GUTTER)\n const availableHeight = Math.max(160, el.clientHeight - padY - PAGE_GUTTER)\n const outputScale = Math.min(window.devicePixelRatio || 1, MAX_SCALE)\n const thumbnails = Array<string | null>(doc.numPages).fill(null)\n\n for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber += 1) {\n let page\n try {\n page = await doc.getPage(pageNumber)\n } catch {\n if (token !== renderToken) return\n continue\n }\n if (token !== renderToken) return\n\n const base = page.getViewport({ scale: 1 })\n const cssScale = Math.min(availableWidth / base.width, availableHeight / base.height)\n const cssWidth = Math.round(base.width * cssScale)\n const cssHeight = Math.round(base.height * cssScale)\n const viewport = page.getViewport({ scale: cssScale * outputScale })\n\n const pageWrap = document.createElement('div')\n pageWrap.className = 'pf__page-wrap'\n pageWrap.dataset.pageNumber = `${pageNumber}`\n const canvas = document.createElement('canvas')\n canvas.className = 'pf__page'\n canvas.width = Math.ceil(viewport.width)\n canvas.height = Math.ceil(viewport.height)\n canvas.style.width = `${cssWidth}px`\n canvas.style.height = `${cssHeight}px`\n canvas.setAttribute('aria-label', `PDF 第 ${pageNumber} 页`)\n if (!canvas.getContext('2d')) continue\n pageWrap.appendChild(canvas)\n el.appendChild(pageWrap)\n\n const task = page.render({ canvas, viewport })\n renderTasks.push(task)\n let rendered = true\n try {\n await task.promise\n } catch {\n if (token !== renderToken) return\n rendered = false\n }\n if (token !== renderToken) return\n if (rendered) thumbnails[pageNumber - 1] = createPageThumbnail(canvas)\n }\n\n pageThumbnails.value = thumbnails\n if (preserveScroll) scrollToPage(preservedPage, 'auto')\n syncActivePage()\n}\n\nasync function load(): Promise<void> {\n const el = docEl.value\n if (!props.url || !el) return\n status.value = 'loading'\n errorMsg.value = ''\n outlineItems.value = []\n pageLabels.value = []\n pageThumbnails.value = []\n disposeDoc()\n el.innerHTML = ''\n const url = props.url\n\n try {\n const response = await fetch(url)\n if (!response.ok) throw new Error(`HTTP ${response.status}`)\n const bytes = new Uint8Array(await response.arrayBuffer())\n if (props.url !== url || docEl.value !== el) return\n\n const document = await pdfjsLib.getDocument({ data: bytes }).promise\n if (props.url !== url || docEl.value !== el) {\n document.destroy()\n return\n }\n pdfDoc = document\n pageCount.value = document.numPages\n activePage.value = 1\n const navigation = await loadPdfNavigation(document)\n if (props.url !== url || docEl.value !== el) return\n outlineItems.value = navigation.outline\n pageLabels.value = navigation.pageLabels\n navigationView.value = navigation.outline.length ? 'outline' : 'pages'\n await renderPages()\n if (props.url !== url || docEl.value !== el) return\n status.value = 'ready'\n } catch (cause) {\n errorMsg.value = cause instanceof Error ? cause.message : String(cause)\n status.value = 'error'\n }\n}\n\nfunction onResize(): void {\n const el = docEl.value\n if (!el || !pdfDoc) return\n const size = `${el.clientWidth}x${el.clientHeight}`\n if (!el.clientWidth || !el.clientHeight || size === lastSize) return\n lastSize = size\n clearTimeout(resizeTimer)\n resizeTimer = setTimeout(() => {\n if (pdfDoc) void renderPages()\n }, 180)\n}\n\nonMounted(() => {\n void load()\n if (typeof ResizeObserver !== 'undefined' && docEl.value) {\n lastSize = `${docEl.value.clientWidth}x${docEl.value.clientHeight}`\n resizeObserver = new ResizeObserver(onResize)\n resizeObserver.observe(docEl.value)\n }\n})\nwatch(() => props.url, load)\nonBeforeUnmount(() => {\n clearTimeout(resizeTimer)\n resizeObserver?.disconnect()\n if (scrollFrame) window.cancelAnimationFrame(scrollFrame)\n renderToken += 1\n disposeDoc()\n if (docEl.value) docEl.value.innerHTML = ''\n})\n</script>\n\n<template>\n <div class=\"pf\">\n <PdfNavigationPane\n v-if=\"status === 'ready'\"\n v-model:collapsed=\"navigationCollapsed\"\n v-model:view=\"navigationView\"\n :outline-items=\"outlineItems\"\n :page-count=\"pageCount\"\n :page-labels=\"pageLabels\"\n :page-thumbnails=\"pageThumbnails\"\n :active-page=\"activePage\"\n @navigate=\"scrollToPage\"\n />\n <div class=\"pf__stage\">\n <div ref=\"docEl\" class=\"pf__doc\" @scroll.passive=\"syncActivePage\" />\n <div v-if=\"status === 'loading'\" class=\"pf__overlay\">\n <p class=\"pf__msg\">正在渲染 PDF…</p>\n </div>\n <div v-else-if=\"status === 'error'\" class=\"pf__overlay\">\n <div class=\"pf__msg pf__msg--error\">\n <p>渲染失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"pf__retry\" @click=\"load\">重试</button>\n </div>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.pf {\n position: relative;\n min-width: 0;\n min-height: 0;\n display: flex;\n flex: 1 1 auto;\n overflow: hidden;\n container-type: inline-size;\n background: var(--surface);\n}\n\n.pf__stage {\n position: relative;\n min-width: 0;\n min-height: 0;\n display: flex;\n flex: 1 1 0;\n overflow: hidden;\n}\n\n.pf__doc {\n width: 100%;\n min-width: 0;\n min-height: 0;\n flex: 1 1 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 24px 20px 42px;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n box-sizing: border-box;\n background: var(--surface-2);\n scrollbar-color: var(--border-2) transparent;\n scrollbar-width: thin;\n}\n\n.pf__doc :deep(.pf__page-wrap) {\n max-width: 100%;\n display: flex;\n flex: none;\n justify-content: center;\n}\n\n.pf__doc :deep(.pf__page) {\n max-width: 100%;\n display: block;\n background: #fff;\n border-radius: 2px;\n box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);\n}\n\n.pf__overlay {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: flex-start;\n justify-content: flex-start;\n background: var(--surface);\n}\n\n.pf__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n\n.pf__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n\n.pf__retry {\n padding: 5px 12px;\n color: var(--text);\n font-family: inherit;\n font-size: 12.5px;\n cursor: pointer;\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n}\n\n.pf__retry:hover {\n color: var(--accent);\n border-color: var(--accent);\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 "./PptxNavigationPane.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/PptxNavigationPane.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-1de8828c"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-1f6b520b"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8
 
@@ -1 +1 @@
1
- {"version":3,"file":"PptxNavigationPane.js","names":[],"sources":["../../src/components/PptxNavigationPane.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport {\n nextTick,\n onBeforeUnmount,\n onMounted,\n useTemplateRef,\n watch,\n type ComponentPublicInstance,\n} from 'vue'\nimport type { PptxViewer, SlideHandle } from '@aiden0z/pptx-renderer'\nimport Icon from '@/components/icons/Icon.vue'\nimport type { PptxNavigationItem } from '@/lib/pptxNavigation'\n\nconst props = defineProps<{\n viewer: PptxViewer\n items: readonly PptxNavigationItem[]\n activeIndex: number\n}>()\n\nconst emit = defineEmits<{ navigate: [index: number] }>()\nconst collapsed = defineModel<boolean>('collapsed', { default: false })\nconst listEl = useTemplateRef<HTMLElement>('list')\n\nconst thumbElements = new Map<number, HTMLElement>()\nconst thumbHandles = new Map<number, SlideHandle>()\nlet observer: IntersectionObserver | null = null\n\nfunction disposeThumbnails(): void {\n observer?.disconnect()\n observer = null\n for (const handle of thumbHandles.values()) handle.dispose()\n thumbHandles.clear()\n for (const element of thumbElements.values()) element.innerHTML = ''\n}\n\nfunction mountThumbnail(index: number, element: HTMLElement): void {\n if (thumbHandles.has(index)) return\n const handle = props.viewer.renderThumbnailToContainer(index, element, { width: 104 })\n if (!handle) return\n thumbHandles.set(index, handle)\n void handle.ready.catch(() => {\n if (thumbHandles.get(index) !== handle) return\n handle.dispose()\n thumbHandles.delete(index)\n element.innerHTML = ''\n })\n}\n\nasync function setupThumbnails(): Promise<void> {\n disposeThumbnails()\n if (collapsed.value) return\n await nextTick()\n const root = listEl.value\n if (!root) return\n\n if (typeof IntersectionObserver === 'undefined') {\n for (const [index, element] of thumbElements) mountThumbnail(index, element)\n return\n }\n\n observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue\n const element = entry.target as HTMLElement\n const index = Number(element.dataset.slideIndex)\n if (Number.isInteger(index)) mountThumbnail(index, element)\n observer?.unobserve(element)\n }\n },\n { root, rootMargin: '140px 0px' },\n )\n for (const element of thumbElements.values()) observer.observe(element)\n}\n\nfunction setThumbElement(index: number, value: Element | ComponentPublicInstance | null): void {\n const previous = thumbElements.get(index)\n if (previous && previous !== value) observer?.unobserve(previous)\n if (!(value instanceof HTMLElement)) {\n thumbElements.delete(index)\n return\n }\n thumbElements.set(index, value)\n observer?.observe(value)\n}\n\nwatch(() => props.viewer, setupThumbnails, { flush: 'post' })\nwatch(() => props.items, setupThumbnails, { flush: 'post' })\nwatch(collapsed, setupThumbnails, { flush: 'post' })\nwatch(\n () => props.activeIndex,\n async (index) => {\n await nextTick()\n listEl.value\n ?.querySelector<HTMLElement>(`[data-nav-slide-index=\"${index}\"]`)\n ?.scrollIntoView({ block: 'nearest' })\n },\n)\n\nonMounted(setupThumbnails)\nonBeforeUnmount(disposeThumbnails)\n</script>\n\n<template>\n <aside class=\"pn\" :class=\"{ 'pn--collapsed': collapsed }\" aria-label=\"幻灯片导航\">\n <button\n v-if=\"collapsed\"\n type=\"button\"\n class=\"pn__reopen\"\n aria-label=\"展开幻灯片导航\"\n title=\"展开幻灯片导航\"\n @click=\"collapsed = false\"\n >\n <Icon name=\"chevronRight\" :size=\"15\" />\n </button>\n\n <template v-else>\n <header class=\"pn__header\">\n <div>\n <strong class=\"pn__title\">幻灯片</strong>\n <span class=\"pn__count\">{{ items.length }} 张</span>\n </div>\n <button\n type=\"button\"\n class=\"pn__collapse\"\n aria-label=\"收起幻灯片导航\"\n title=\"收起幻灯片导航\"\n @click=\"collapsed = true\"\n >\n <Icon name=\"chevronLeft\" :size=\"15\" />\n </button>\n </header>\n\n <nav ref=\"list\" class=\"pn__list\" aria-label=\"幻灯片列表\">\n <button\n v-for=\"item in items\"\n :key=\"item.index\"\n type=\"button\"\n class=\"pn__item\"\n :class=\"{ 'pn__item--active': item.index === activeIndex }\"\n :data-nav-slide-index=\"item.index\"\n :aria-current=\"item.index === activeIndex ? 'page' : undefined\"\n :title=\"`${item.index + 1}. ${item.title}`\"\n @click=\"emit('navigate', item.index)\"\n >\n <span class=\"pn__number\">{{ item.index + 1 }}</span>\n <span class=\"pn__preview-wrap\">\n <span\n :ref=\"(value) => setThumbElement(item.index, value)\"\n class=\"pn__preview\"\n :data-slide-index=\"item.index\"\n aria-hidden=\"true\"\n />\n </span>\n <span class=\"pn__label\">{{ item.title }}</span>\n </button>\n </nav>\n </template>\n </aside>\n</template>\n\n<style scoped>\n.pn {\n position: relative;\n flex: 0 0 142px;\n width: 142px;\n min-width: 0;\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: color-mix(in srgb, var(--surface) 94%, var(--surface-2));\n transition: flex-basis 160ms ease;\n}\n.pn--collapsed {\n flex-basis: 0;\n width: 0;\n border-right: 0;\n}\n.pn__header {\n min-height: 45px;\n padding: 9px 8px 8px 12px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 6px;\n border-bottom: 1px solid var(--border);\n}\n.pn__title {\n display: block;\n color: var(--text-strong);\n font-size: 12.5px;\n font-weight: 650;\n}\n.pn__count {\n display: block;\n margin-top: 1px;\n color: var(--faint);\n font-size: 10.5px;\n}\n.pn__collapse,\n.pn__reopen {\n border: 0;\n color: var(--muted);\n background: transparent;\n cursor: pointer;\n}\n.pn__collapse {\n width: 27px;\n height: 27px;\n display: grid;\n place-items: center;\n border-radius: 7px;\n}\n.pn__collapse:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__reopen {\n position: absolute;\n z-index: 3;\n top: 12px;\n right: -28px;\n width: 28px;\n height: 36px;\n padding: 0;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 0 9px 9px 0;\n background: var(--surface);\n box-shadow: 2px 1px 6px rgba(0, 0, 0, 0.12);\n}\n.pn__reopen:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n padding: 8px 7px 22px;\n scrollbar-width: thin;\n}\n.pn__item {\n position: relative;\n width: 100%;\n min-width: 0;\n margin: 0 0 8px;\n padding: 5px;\n display: block;\n border: 1px solid transparent;\n border-radius: 8px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.pn__item:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__item--active {\n border-color: color-mix(in srgb, var(--accent) 70%, transparent);\n color: var(--text-strong);\n background: var(--accent-soft);\n}\n.pn__number {\n position: absolute;\n z-index: 2;\n top: 9px;\n left: 9px;\n min-width: 20px;\n height: 20px;\n padding: 0 5px;\n display: grid;\n place-items: center;\n border-radius: 5px;\n color: #fff;\n background: rgba(20, 20, 20, 0.68);\n font-size: 9.5px;\n font-weight: 650;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n backdrop-filter: blur(4px);\n}\n.pn__item--active .pn__number {\n color: #fff;\n background: var(--accent);\n}\n.pn__preview-wrap {\n height: 76px;\n min-width: 0;\n overflow: hidden;\n display: flex;\n align-items: center;\n justify-content: center;\n border: 1px solid var(--border-2);\n border-radius: 4px;\n background: #fff;\n box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);\n}\n.pn__item--active .pn__preview-wrap {\n border-color: var(--accent);\n box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);\n}\n.pn__preview {\n width: 104px;\n min-height: 1px;\n display: block;\n overflow: hidden;\n pointer-events: none;\n}\n.pn__label {\n min-width: 0;\n margin-top: 5px;\n overflow: hidden;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n font-size: 10.5px;\n line-height: 1.35;\n}\n\n@container (max-width: 500px) {\n .pn {\n flex-basis: 124px;\n width: 124px;\n }\n .pn--collapsed {\n flex-basis: 0;\n width: 0;\n }\n .pn__preview-wrap {\n height: 66px;\n }\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"PptxNavigationPane.js","names":[],"sources":["../../src/components/PptxNavigationPane.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport {\n nextTick,\n onBeforeUnmount,\n onMounted,\n useTemplateRef,\n watch,\n type ComponentPublicInstance,\n} from 'vue'\nimport type { PptxViewer, SlideHandle } from '@aiden0z/pptx-renderer'\nimport Icon from '@/components/icons/Icon.vue'\nimport type { PptxNavigationItem } from '@/lib/pptxNavigation'\n\nconst props = defineProps<{\n viewer: PptxViewer\n items: readonly PptxNavigationItem[]\n activeIndex: number\n}>()\n\nconst emit = defineEmits<{ navigate: [index: number] }>()\nconst collapsed = defineModel<boolean>('collapsed', { default: false })\nconst listEl = useTemplateRef<HTMLElement>('list')\n\nconst thumbElements = new Map<number, HTMLElement>()\nconst thumbHandles = new Map<number, SlideHandle>()\nlet observer: IntersectionObserver | null = null\n\nfunction disposeThumbnails(): void {\n observer?.disconnect()\n observer = null\n for (const handle of thumbHandles.values()) handle.dispose()\n thumbHandles.clear()\n for (const element of thumbElements.values()) element.innerHTML = ''\n}\n\nfunction mountThumbnail(index: number, element: HTMLElement): void {\n if (thumbHandles.has(index)) return\n const handle = props.viewer.renderThumbnailToContainer(index, element, { width: 104 })\n if (!handle) return\n thumbHandles.set(index, handle)\n void handle.ready.catch(() => {\n if (thumbHandles.get(index) !== handle) return\n handle.dispose()\n thumbHandles.delete(index)\n element.innerHTML = ''\n })\n}\n\nasync function setupThumbnails(): Promise<void> {\n disposeThumbnails()\n if (collapsed.value) return\n await nextTick()\n const root = listEl.value\n if (!root) return\n\n if (typeof IntersectionObserver === 'undefined') {\n for (const [index, element] of thumbElements) mountThumbnail(index, element)\n return\n }\n\n observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue\n const element = entry.target as HTMLElement\n const index = Number(element.dataset.slideIndex)\n if (Number.isInteger(index)) mountThumbnail(index, element)\n observer?.unobserve(element)\n }\n },\n { root, rootMargin: '140px 0px' },\n )\n for (const element of thumbElements.values()) observer.observe(element)\n}\n\nfunction setThumbElement(index: number, value: Element | ComponentPublicInstance | null): void {\n const previous = thumbElements.get(index)\n if (previous && previous !== value) observer?.unobserve(previous)\n if (!(value instanceof HTMLElement)) {\n thumbElements.delete(index)\n return\n }\n thumbElements.set(index, value)\n observer?.observe(value)\n}\n\nwatch(() => props.viewer, setupThumbnails, { flush: 'post' })\nwatch(() => props.items, setupThumbnails, { flush: 'post' })\nwatch(collapsed, setupThumbnails, { flush: 'post' })\nwatch(\n () => props.activeIndex,\n async (index) => {\n await nextTick()\n listEl.value\n ?.querySelector<HTMLElement>(`[data-nav-slide-index=\"${index}\"]`)\n ?.scrollIntoView({ block: 'nearest' })\n },\n)\n\nonMounted(setupThumbnails)\nonBeforeUnmount(disposeThumbnails)\n</script>\n\n<template>\n <aside class=\"pn\" :class=\"{ 'pn--collapsed': collapsed }\" aria-label=\"幻灯片导航\">\n <button\n v-if=\"collapsed\"\n type=\"button\"\n class=\"pn__reopen\"\n aria-label=\"展开幻灯片导航\"\n title=\"展开幻灯片导航\"\n @click=\"collapsed = false\"\n >\n <Icon name=\"chevronRight\" :size=\"15\" />\n </button>\n\n <template v-else>\n <header class=\"pn__header\">\n <div>\n <strong class=\"pn__title\">幻灯片</strong>\n <span class=\"pn__count\">{{ items.length }} 张</span>\n </div>\n <button\n type=\"button\"\n class=\"pn__collapse\"\n aria-label=\"收起幻灯片导航\"\n title=\"收起幻灯片导航\"\n @click=\"collapsed = true\"\n >\n <Icon name=\"chevronLeft\" :size=\"15\" />\n </button>\n </header>\n\n <nav ref=\"list\" class=\"pn__list\" aria-label=\"幻灯片列表\">\n <button\n v-for=\"item in items\"\n :key=\"item.index\"\n type=\"button\"\n class=\"pn__item\"\n :class=\"{ 'pn__item--active': item.index === activeIndex }\"\n :data-nav-slide-index=\"item.index\"\n :aria-current=\"item.index === activeIndex ? 'page' : undefined\"\n :title=\"`${item.index + 1}. ${item.title}`\"\n @click=\"emit('navigate', item.index)\"\n >\n <span class=\"pn__number\">{{ item.index + 1 }}</span>\n <span class=\"pn__preview-wrap\">\n <span\n :ref=\"(value) => setThumbElement(item.index, value)\"\n class=\"pn__preview\"\n :data-slide-index=\"item.index\"\n aria-hidden=\"true\"\n />\n </span>\n <span class=\"pn__label\">{{ item.title }}</span>\n </button>\n </nav>\n </template>\n </aside>\n</template>\n\n<style scoped>\n.pn {\n position: relative;\n flex: 0 0 142px;\n width: 142px;\n min-width: 0;\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: color-mix(in srgb, var(--surface) 94%, var(--surface-2));\n transition: flex-basis 160ms ease;\n}\n.pn--collapsed {\n flex-basis: 0;\n width: 0;\n border-right: 0;\n}\n.pn__header {\n min-height: 45px;\n padding: 9px 8px 8px 12px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 6px;\n border-bottom: 1px solid var(--border);\n}\n.pn__title {\n display: block;\n color: var(--text-strong);\n font-size: 12.5px;\n font-weight: 650;\n}\n.pn__count {\n display: block;\n margin-top: 1px;\n color: var(--faint);\n font-size: 10.5px;\n}\n.pn__collapse,\n.pn__reopen {\n border: 0;\n color: var(--muted);\n background: transparent;\n cursor: pointer;\n}\n.pn__collapse {\n width: 27px;\n height: 27px;\n display: grid;\n place-items: center;\n border-radius: 7px;\n}\n.pn__collapse:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__reopen {\n position: absolute;\n z-index: 3;\n top: 12px;\n right: -28px;\n width: 28px;\n height: 36px;\n padding: 0;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 0 9px 9px 0;\n background: var(--surface);\n box-shadow: 2px 1px 6px rgba(0, 0, 0, 0.12);\n}\n.pn__reopen:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n padding: 8px 7px 22px;\n scrollbar-width: thin;\n}\n.pn__item {\n position: relative;\n width: 100%;\n min-width: 0;\n margin: 0 0 8px;\n padding: 5px;\n display: block;\n border: 1px solid transparent;\n border-radius: 8px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.pn__item:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__item--active {\n border-color: color-mix(in srgb, var(--accent) 70%, transparent);\n color: var(--text-strong);\n background: var(--accent-soft);\n}\n.pn__number {\n position: absolute;\n z-index: 2;\n top: 9px;\n left: 9px;\n min-width: 20px;\n height: 20px;\n padding: 0 5px;\n display: grid;\n place-items: center;\n border-radius: 5px;\n color: var(--text-strong);\n background: var(--surface);\n border: 1px solid var(--border-2);\n font-size: 9.5px;\n font-weight: 650;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n backdrop-filter: blur(4px);\n}\n.pn__item--active .pn__number {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 16%, var(--surface));\n border-color: var(--accent);\n}\n.pn__preview-wrap {\n height: 76px;\n min-width: 0;\n overflow: hidden;\n display: flex;\n align-items: center;\n justify-content: center;\n border: 1px solid var(--border-2);\n border-radius: 4px;\n background: var(--surface-2);\n box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);\n}\n.pn__item--active .pn__preview-wrap {\n border-color: var(--accent);\n box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);\n}\n.pn__preview {\n width: 104px;\n min-height: 1px;\n display: block;\n overflow: hidden;\n pointer-events: none;\n}\n.pn__label {\n min-width: 0;\n margin-top: 5px;\n overflow: hidden;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n font-size: 10.5px;\n line-height: 1.35;\n}\n\n@container (max-width: 500px) {\n .pn {\n flex-basis: 124px;\n width: 124px;\n }\n .pn--collapsed {\n flex-basis: 0;\n width: 0;\n }\n .pn__preview-wrap {\n height: 66px;\n }\n}\n</style>\n"],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"PptxNavigationPane.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/PptxNavigationPane.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport {\n nextTick,\n onBeforeUnmount,\n onMounted,\n useTemplateRef,\n watch,\n type ComponentPublicInstance,\n} from 'vue'\nimport type { PptxViewer, SlideHandle } from '@aiden0z/pptx-renderer'\nimport Icon from '@/components/icons/Icon.vue'\nimport type { PptxNavigationItem } from '@/lib/pptxNavigation'\n\nconst props = defineProps<{\n viewer: PptxViewer\n items: readonly PptxNavigationItem[]\n activeIndex: number\n}>()\n\nconst emit = defineEmits<{ navigate: [index: number] }>()\nconst collapsed = defineModel<boolean>('collapsed', { default: false })\nconst listEl = useTemplateRef<HTMLElement>('list')\n\nconst thumbElements = new Map<number, HTMLElement>()\nconst thumbHandles = new Map<number, SlideHandle>()\nlet observer: IntersectionObserver | null = null\n\nfunction disposeThumbnails(): void {\n observer?.disconnect()\n observer = null\n for (const handle of thumbHandles.values()) handle.dispose()\n thumbHandles.clear()\n for (const element of thumbElements.values()) element.innerHTML = ''\n}\n\nfunction mountThumbnail(index: number, element: HTMLElement): void {\n if (thumbHandles.has(index)) return\n const handle = props.viewer.renderThumbnailToContainer(index, element, { width: 104 })\n if (!handle) return\n thumbHandles.set(index, handle)\n void handle.ready.catch(() => {\n if (thumbHandles.get(index) !== handle) return\n handle.dispose()\n thumbHandles.delete(index)\n element.innerHTML = ''\n })\n}\n\nasync function setupThumbnails(): Promise<void> {\n disposeThumbnails()\n if (collapsed.value) return\n await nextTick()\n const root = listEl.value\n if (!root) return\n\n if (typeof IntersectionObserver === 'undefined') {\n for (const [index, element] of thumbElements) mountThumbnail(index, element)\n return\n }\n\n observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue\n const element = entry.target as HTMLElement\n const index = Number(element.dataset.slideIndex)\n if (Number.isInteger(index)) mountThumbnail(index, element)\n observer?.unobserve(element)\n }\n },\n { root, rootMargin: '140px 0px' },\n )\n for (const element of thumbElements.values()) observer.observe(element)\n}\n\nfunction setThumbElement(index: number, value: Element | ComponentPublicInstance | null): void {\n const previous = thumbElements.get(index)\n if (previous && previous !== value) observer?.unobserve(previous)\n if (!(value instanceof HTMLElement)) {\n thumbElements.delete(index)\n return\n }\n thumbElements.set(index, value)\n observer?.observe(value)\n}\n\nwatch(() => props.viewer, setupThumbnails, { flush: 'post' })\nwatch(() => props.items, setupThumbnails, { flush: 'post' })\nwatch(collapsed, setupThumbnails, { flush: 'post' })\nwatch(\n () => props.activeIndex,\n async (index) => {\n await nextTick()\n listEl.value\n ?.querySelector<HTMLElement>(`[data-nav-slide-index=\"${index}\"]`)\n ?.scrollIntoView({ block: 'nearest' })\n },\n)\n\nonMounted(setupThumbnails)\nonBeforeUnmount(disposeThumbnails)\n</script>\n\n<template>\n <aside class=\"pn\" :class=\"{ 'pn--collapsed': collapsed }\" aria-label=\"幻灯片导航\">\n <button\n v-if=\"collapsed\"\n type=\"button\"\n class=\"pn__reopen\"\n aria-label=\"展开幻灯片导航\"\n title=\"展开幻灯片导航\"\n @click=\"collapsed = false\"\n >\n <Icon name=\"chevronRight\" :size=\"15\" />\n </button>\n\n <template v-else>\n <header class=\"pn__header\">\n <div>\n <strong class=\"pn__title\">幻灯片</strong>\n <span class=\"pn__count\">{{ items.length }} 张</span>\n </div>\n <button\n type=\"button\"\n class=\"pn__collapse\"\n aria-label=\"收起幻灯片导航\"\n title=\"收起幻灯片导航\"\n @click=\"collapsed = true\"\n >\n <Icon name=\"chevronLeft\" :size=\"15\" />\n </button>\n </header>\n\n <nav ref=\"list\" class=\"pn__list\" aria-label=\"幻灯片列表\">\n <button\n v-for=\"item in items\"\n :key=\"item.index\"\n type=\"button\"\n class=\"pn__item\"\n :class=\"{ 'pn__item--active': item.index === activeIndex }\"\n :data-nav-slide-index=\"item.index\"\n :aria-current=\"item.index === activeIndex ? 'page' : undefined\"\n :title=\"`${item.index + 1}. ${item.title}`\"\n @click=\"emit('navigate', item.index)\"\n >\n <span class=\"pn__number\">{{ item.index + 1 }}</span>\n <span class=\"pn__preview-wrap\">\n <span\n :ref=\"(value) => setThumbElement(item.index, value)\"\n class=\"pn__preview\"\n :data-slide-index=\"item.index\"\n aria-hidden=\"true\"\n />\n </span>\n <span class=\"pn__label\">{{ item.title }}</span>\n </button>\n </nav>\n </template>\n </aside>\n</template>\n\n<style scoped>\n.pn {\n position: relative;\n flex: 0 0 142px;\n width: 142px;\n min-width: 0;\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: color-mix(in srgb, var(--surface) 94%, var(--surface-2));\n transition: flex-basis 160ms ease;\n}\n.pn--collapsed {\n flex-basis: 0;\n width: 0;\n border-right: 0;\n}\n.pn__header {\n min-height: 45px;\n padding: 9px 8px 8px 12px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 6px;\n border-bottom: 1px solid var(--border);\n}\n.pn__title {\n display: block;\n color: var(--text-strong);\n font-size: 12.5px;\n font-weight: 650;\n}\n.pn__count {\n display: block;\n margin-top: 1px;\n color: var(--faint);\n font-size: 10.5px;\n}\n.pn__collapse,\n.pn__reopen {\n border: 0;\n color: var(--muted);\n background: transparent;\n cursor: pointer;\n}\n.pn__collapse {\n width: 27px;\n height: 27px;\n display: grid;\n place-items: center;\n border-radius: 7px;\n}\n.pn__collapse:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__reopen {\n position: absolute;\n z-index: 3;\n top: 12px;\n right: -28px;\n width: 28px;\n height: 36px;\n padding: 0;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 0 9px 9px 0;\n background: var(--surface);\n box-shadow: 2px 1px 6px rgba(0, 0, 0, 0.12);\n}\n.pn__reopen:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n padding: 8px 7px 22px;\n scrollbar-width: thin;\n}\n.pn__item {\n position: relative;\n width: 100%;\n min-width: 0;\n margin: 0 0 8px;\n padding: 5px;\n display: block;\n border: 1px solid transparent;\n border-radius: 8px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.pn__item:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__item--active {\n border-color: color-mix(in srgb, var(--accent) 70%, transparent);\n color: var(--text-strong);\n background: var(--accent-soft);\n}\n.pn__number {\n position: absolute;\n z-index: 2;\n top: 9px;\n left: 9px;\n min-width: 20px;\n height: 20px;\n padding: 0 5px;\n display: grid;\n place-items: center;\n border-radius: 5px;\n color: #fff;\n background: rgba(20, 20, 20, 0.68);\n font-size: 9.5px;\n font-weight: 650;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n backdrop-filter: blur(4px);\n}\n.pn__item--active .pn__number {\n color: #fff;\n background: var(--accent);\n}\n.pn__preview-wrap {\n height: 76px;\n min-width: 0;\n overflow: hidden;\n display: flex;\n align-items: center;\n justify-content: center;\n border: 1px solid var(--border-2);\n border-radius: 4px;\n background: #fff;\n box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);\n}\n.pn__item--active .pn__preview-wrap {\n border-color: var(--accent);\n box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);\n}\n.pn__preview {\n width: 104px;\n min-height: 1px;\n display: block;\n overflow: hidden;\n pointer-events: none;\n}\n.pn__label {\n min-width: 0;\n margin-top: 5px;\n overflow: hidden;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n font-size: 10.5px;\n line-height: 1.35;\n}\n\n@container (max-width: 500px) {\n .pn {\n flex-basis: 124px;\n width: 124px;\n }\n .pn--collapsed {\n flex-basis: 0;\n width: 0;\n }\n .pn__preview-wrap {\n height: 66px;\n }\n}\n</style>\n"],"mappings":"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"}
1
+ {"version":3,"file":"PptxNavigationPane.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/PptxNavigationPane.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport {\n nextTick,\n onBeforeUnmount,\n onMounted,\n useTemplateRef,\n watch,\n type ComponentPublicInstance,\n} from 'vue'\nimport type { PptxViewer, SlideHandle } from '@aiden0z/pptx-renderer'\nimport Icon from '@/components/icons/Icon.vue'\nimport type { PptxNavigationItem } from '@/lib/pptxNavigation'\n\nconst props = defineProps<{\n viewer: PptxViewer\n items: readonly PptxNavigationItem[]\n activeIndex: number\n}>()\n\nconst emit = defineEmits<{ navigate: [index: number] }>()\nconst collapsed = defineModel<boolean>('collapsed', { default: false })\nconst listEl = useTemplateRef<HTMLElement>('list')\n\nconst thumbElements = new Map<number, HTMLElement>()\nconst thumbHandles = new Map<number, SlideHandle>()\nlet observer: IntersectionObserver | null = null\n\nfunction disposeThumbnails(): void {\n observer?.disconnect()\n observer = null\n for (const handle of thumbHandles.values()) handle.dispose()\n thumbHandles.clear()\n for (const element of thumbElements.values()) element.innerHTML = ''\n}\n\nfunction mountThumbnail(index: number, element: HTMLElement): void {\n if (thumbHandles.has(index)) return\n const handle = props.viewer.renderThumbnailToContainer(index, element, { width: 104 })\n if (!handle) return\n thumbHandles.set(index, handle)\n void handle.ready.catch(() => {\n if (thumbHandles.get(index) !== handle) return\n handle.dispose()\n thumbHandles.delete(index)\n element.innerHTML = ''\n })\n}\n\nasync function setupThumbnails(): Promise<void> {\n disposeThumbnails()\n if (collapsed.value) return\n await nextTick()\n const root = listEl.value\n if (!root) return\n\n if (typeof IntersectionObserver === 'undefined') {\n for (const [index, element] of thumbElements) mountThumbnail(index, element)\n return\n }\n\n observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue\n const element = entry.target as HTMLElement\n const index = Number(element.dataset.slideIndex)\n if (Number.isInteger(index)) mountThumbnail(index, element)\n observer?.unobserve(element)\n }\n },\n { root, rootMargin: '140px 0px' },\n )\n for (const element of thumbElements.values()) observer.observe(element)\n}\n\nfunction setThumbElement(index: number, value: Element | ComponentPublicInstance | null): void {\n const previous = thumbElements.get(index)\n if (previous && previous !== value) observer?.unobserve(previous)\n if (!(value instanceof HTMLElement)) {\n thumbElements.delete(index)\n return\n }\n thumbElements.set(index, value)\n observer?.observe(value)\n}\n\nwatch(() => props.viewer, setupThumbnails, { flush: 'post' })\nwatch(() => props.items, setupThumbnails, { flush: 'post' })\nwatch(collapsed, setupThumbnails, { flush: 'post' })\nwatch(\n () => props.activeIndex,\n async (index) => {\n await nextTick()\n listEl.value\n ?.querySelector<HTMLElement>(`[data-nav-slide-index=\"${index}\"]`)\n ?.scrollIntoView({ block: 'nearest' })\n },\n)\n\nonMounted(setupThumbnails)\nonBeforeUnmount(disposeThumbnails)\n</script>\n\n<template>\n <aside class=\"pn\" :class=\"{ 'pn--collapsed': collapsed }\" aria-label=\"幻灯片导航\">\n <button\n v-if=\"collapsed\"\n type=\"button\"\n class=\"pn__reopen\"\n aria-label=\"展开幻灯片导航\"\n title=\"展开幻灯片导航\"\n @click=\"collapsed = false\"\n >\n <Icon name=\"chevronRight\" :size=\"15\" />\n </button>\n\n <template v-else>\n <header class=\"pn__header\">\n <div>\n <strong class=\"pn__title\">幻灯片</strong>\n <span class=\"pn__count\">{{ items.length }} 张</span>\n </div>\n <button\n type=\"button\"\n class=\"pn__collapse\"\n aria-label=\"收起幻灯片导航\"\n title=\"收起幻灯片导航\"\n @click=\"collapsed = true\"\n >\n <Icon name=\"chevronLeft\" :size=\"15\" />\n </button>\n </header>\n\n <nav ref=\"list\" class=\"pn__list\" aria-label=\"幻灯片列表\">\n <button\n v-for=\"item in items\"\n :key=\"item.index\"\n type=\"button\"\n class=\"pn__item\"\n :class=\"{ 'pn__item--active': item.index === activeIndex }\"\n :data-nav-slide-index=\"item.index\"\n :aria-current=\"item.index === activeIndex ? 'page' : undefined\"\n :title=\"`${item.index + 1}. ${item.title}`\"\n @click=\"emit('navigate', item.index)\"\n >\n <span class=\"pn__number\">{{ item.index + 1 }}</span>\n <span class=\"pn__preview-wrap\">\n <span\n :ref=\"(value) => setThumbElement(item.index, value)\"\n class=\"pn__preview\"\n :data-slide-index=\"item.index\"\n aria-hidden=\"true\"\n />\n </span>\n <span class=\"pn__label\">{{ item.title }}</span>\n </button>\n </nav>\n </template>\n </aside>\n</template>\n\n<style scoped>\n.pn {\n position: relative;\n flex: 0 0 142px;\n width: 142px;\n min-width: 0;\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: color-mix(in srgb, var(--surface) 94%, var(--surface-2));\n transition: flex-basis 160ms ease;\n}\n.pn--collapsed {\n flex-basis: 0;\n width: 0;\n border-right: 0;\n}\n.pn__header {\n min-height: 45px;\n padding: 9px 8px 8px 12px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 6px;\n border-bottom: 1px solid var(--border);\n}\n.pn__title {\n display: block;\n color: var(--text-strong);\n font-size: 12.5px;\n font-weight: 650;\n}\n.pn__count {\n display: block;\n margin-top: 1px;\n color: var(--faint);\n font-size: 10.5px;\n}\n.pn__collapse,\n.pn__reopen {\n border: 0;\n color: var(--muted);\n background: transparent;\n cursor: pointer;\n}\n.pn__collapse {\n width: 27px;\n height: 27px;\n display: grid;\n place-items: center;\n border-radius: 7px;\n}\n.pn__collapse:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__reopen {\n position: absolute;\n z-index: 3;\n top: 12px;\n right: -28px;\n width: 28px;\n height: 36px;\n padding: 0;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 0 9px 9px 0;\n background: var(--surface);\n box-shadow: 2px 1px 6px rgba(0, 0, 0, 0.12);\n}\n.pn__reopen:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n padding: 8px 7px 22px;\n scrollbar-width: thin;\n}\n.pn__item {\n position: relative;\n width: 100%;\n min-width: 0;\n margin: 0 0 8px;\n padding: 5px;\n display: block;\n border: 1px solid transparent;\n border-radius: 8px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.pn__item:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.pn__item--active {\n border-color: color-mix(in srgb, var(--accent) 70%, transparent);\n color: var(--text-strong);\n background: var(--accent-soft);\n}\n.pn__number {\n position: absolute;\n z-index: 2;\n top: 9px;\n left: 9px;\n min-width: 20px;\n height: 20px;\n padding: 0 5px;\n display: grid;\n place-items: center;\n border-radius: 5px;\n color: var(--text-strong);\n background: var(--surface);\n border: 1px solid var(--border-2);\n font-size: 9.5px;\n font-weight: 650;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n backdrop-filter: blur(4px);\n}\n.pn__item--active .pn__number {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 16%, var(--surface));\n border-color: var(--accent);\n}\n.pn__preview-wrap {\n height: 76px;\n min-width: 0;\n overflow: hidden;\n display: flex;\n align-items: center;\n justify-content: center;\n border: 1px solid var(--border-2);\n border-radius: 4px;\n background: var(--surface-2);\n box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);\n}\n.pn__item--active .pn__preview-wrap {\n border-color: var(--accent);\n box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);\n}\n.pn__preview {\n width: 104px;\n min-height: 1px;\n display: block;\n overflow: hidden;\n pointer-events: none;\n}\n.pn__label {\n min-width: 0;\n margin-top: 5px;\n overflow: hidden;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n font-size: 10.5px;\n line-height: 1.35;\n}\n\n@container (max-width: 500px) {\n .pn {\n flex-basis: 124px;\n width: 124px;\n }\n .pn--collapsed {\n flex-basis: 0;\n width: 0;\n }\n .pn__preview-wrap {\n height: 66px;\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 "./PsdViewer.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/PsdViewer.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-5fbd3b9c"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-6686d0d5"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8
 
@@ -1 +1 @@
1
- {"version":3,"file":"PsdViewer.js","names":[],"sources":["../../src/components/PsdViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { onBeforeUnmount, ref, shallowRef, watch } from 'vue'\nimport type { Layer, Psd } from 'ag-psd'\nimport { formatBytes } from '@/lib/format'\nimport { isMissingModule } from '@/lib/optionalDep'\nimport Icon from '@/components/icons/Icon.vue'\nimport MissingDependency from '@/components/MissingDependency.vue'\n\n/**\n * Side-viewer for Photoshop documents (.psd/.psb). Fetches the bytes and parses them in-browser with ag-psd\n * (dynamically imported), then — unlike the generic RasterViewer which only shows the flattened composite —\n * exposes the LAYER TREE: a panel of every layer/group with a thumbnail and a visibility toggle, and it\n * RE-COMPOSITES live onto a canvas as you toggle layers (honouring per-layer opacity and blend mode; group\n * visibility cascades to its children). A flattened PSD (no layers) falls back to ag-psd's composite.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\n// Set when the PSD engine (ag-psd, an optional peer) isn't installed → show the install card, not a raw error.\nconst missingDeps = ref<string[] | null>(null)\nconst fit = ref(true)\n\n// Reading a PSD decodes EVERY layer's pixels (needed for the layer panel), so gate very large files behind a\n// confirm the way RasterViewer does — a huge multi-layer PSD would otherwise lock the tab while decoding.\nconst SIZE_LIMIT = 60 * 1024 * 1024\n\ninterface PsdNode {\n id: number\n name: string\n depth: number\n isGroup: boolean\n opacity: number\n blend: string\n left: number\n top: number\n canvas?: HTMLCanvasElement // leaf pixels\n thumb?: string // leaf thumbnail data URL\n parentId: number\n children: PsdNode[]\n}\n\nlet width = 0\nlet height = 0\nconst drawOrder = shallowRef<PsdNode[]>([]) // leaves, bottom → top (compositing order)\nconst panelRows = shallowRef<PsdNode[]>([]) // groups + leaves, top → bottom (Photoshop panel order)\nconst nodeById = new Map<number, PsdNode>()\nconst hidden = ref(new Set<number>()) // ids the user (or the file) has hidden\nlet fallbackCanvas: HTMLCanvasElement | undefined // flattened composite, when there are no layers\nconst stage = ref<HTMLCanvasElement>()\n\nconst layerCount = ref(0)\n\n// Photoshop blend mode → canvas globalCompositeOperation (unknown → normal).\nconst BLEND: Record<string, GlobalCompositeOperation> = {\n normal: 'source-over',\n multiply: 'multiply',\n screen: 'screen',\n overlay: 'overlay',\n darken: 'darken',\n lighten: 'lighten',\n 'color dodge': 'color-dodge',\n 'linear dodge': 'lighter',\n 'color burn': 'color-burn',\n 'hard light': 'hard-light',\n 'soft light': 'soft-light',\n difference: 'difference',\n exclusion: 'exclusion',\n hue: 'hue',\n saturation: 'saturation',\n color: 'color',\n luminosity: 'luminosity',\n}\n\nfunction makeThumb(src: HTMLCanvasElement): string | undefined {\n if (!src.width || !src.height) return undefined\n const S = 34\n const c = document.createElement('canvas')\n c.width = S\n c.height = S\n const ctx = c.getContext('2d')\n if (!ctx) return undefined\n const s = Math.min(S / src.width, S / src.height)\n const w = src.width * s\n const h = src.height * s\n ctx.drawImage(src, (S - w) / 2, (S - h) / 2, w, h)\n return c.toDataURL()\n}\n\nfunction build(psd: Psd): void {\n nodeById.clear()\n const hid = new Set<number>()\n let id = 0\n function rec(layers: Layer[] | undefined, depth: number, parentId: number): PsdNode[] {\n const out: PsdNode[] = []\n for (const l of layers ?? []) {\n const isGroup = Array.isArray(l.children)\n const n: PsdNode = {\n id: id++,\n name: l.name || (isGroup ? '组' : '图层'),\n depth,\n isGroup,\n opacity: typeof l.opacity === 'number' ? l.opacity : 1,\n blend: l.blendMode || 'normal',\n left: l.left ?? 0,\n top: l.top ?? 0,\n canvas: isGroup ? undefined : (l.canvas as HTMLCanvasElement | undefined),\n parentId,\n children: [],\n }\n nodeById.set(n.id, n)\n if (l.hidden) hid.add(n.id)\n if (n.canvas) n.thumb = makeThumb(n.canvas)\n if (isGroup) n.children = rec(l.children, depth + 1, n.id)\n out.push(n)\n }\n return out\n }\n const tree = rec(psd.children, 0, -1)\n\n // Compositing order: leaves in document (bottom → top) order.\n const leaves: PsdNode[] = []\n const collectDraw = (ns: PsdNode[]): void => {\n for (const n of ns) {\n if (n.isGroup) collectDraw(n.children)\n else if (n.canvas) leaves.push(n)\n }\n }\n collectDraw(tree)\n\n // Panel order: top → bottom (reverse each level), group header before its indented children.\n const rows: PsdNode[] = []\n const collectPanel = (ns: PsdNode[]): void => {\n for (const n of [...ns].reverse()) {\n rows.push(n)\n if (n.isGroup) collectPanel(n.children)\n }\n }\n collectPanel(tree)\n\n drawOrder.value = leaves\n panelRows.value = rows\n hidden.value = hid\n layerCount.value = leaves.length\n fallbackCanvas = leaves.length ? undefined : (psd.canvas as HTMLCanvasElement | undefined)\n}\n\n// A leaf is visible only if neither it nor any ancestor group is hidden.\nfunction visible(id: number): boolean {\n let n: PsdNode | undefined = nodeById.get(id)\n while (n) {\n if (hidden.value.has(n.id)) return false\n n = n.parentId >= 0 ? nodeById.get(n.parentId) : undefined\n }\n return true\n}\n\nfunction recomposite(): void {\n const cv = stage.value\n if (!cv) return\n cv.width = width || fallbackCanvas?.width || 1\n cv.height = height || fallbackCanvas?.height || 1\n const ctx = cv.getContext('2d')\n if (!ctx) return\n ctx.clearRect(0, 0, cv.width, cv.height)\n if (fallbackCanvas) {\n ctx.drawImage(fallbackCanvas, 0, 0)\n return\n }\n for (const leaf of drawOrder.value) {\n if (!leaf.canvas || !visible(leaf.id)) continue\n ctx.globalAlpha = Math.max(0, Math.min(1, leaf.opacity))\n ctx.globalCompositeOperation = BLEND[leaf.blend] ?? 'source-over'\n ctx.drawImage(leaf.canvas, leaf.left, leaf.top)\n }\n ctx.globalAlpha = 1\n ctx.globalCompositeOperation = 'source-over'\n}\n\nfunction toggle(n: PsdNode): void {\n const next = new Set(hidden.value)\n if (next.has(n.id)) next.delete(n.id)\n else next.add(n.id)\n hidden.value = next\n recomposite()\n}\n\nfunction load(): void {\n if (!props.url) return\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n missingDeps.value = null\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n const { readPsd } = await import('ag-psd')\n const psd = readPsd(buf, { skipThumbnail: true })\n width = psd.width\n height = psd.height\n build(psd)\n status.value = 'ready'\n // wait for the canvas element to mount, then paint\n requestAnimationFrame(recomposite)\n } catch (e) {\n if (isMissingModule(e)) missingDeps.value = ['ag-psd']\n else errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nwatch(() => props.url, load, { immediate: true })\nonBeforeUnmount(() => {\n drawOrder.value = []\n panelRows.value = []\n nodeById.clear()\n})\n</script>\n\n<template>\n <div class=\"psd\">\n <p v-if=\"status === 'loading'\" class=\"psd__msg\">正在解码 PSD…</p>\n <div v-else-if=\"status === 'gate'\" class=\"psd__msg psd__gate\">\n <p>该 PSD 较大({{ formatBytes(props.size) }}),将在浏览器内逐图层解码。是否继续?</p>\n <button type=\"button\" class=\"psd__btn\" @click=\"doLoad\">解码并预览</button>\n </div>\n <MissingDependency\n v-else-if=\"missingDeps\"\n :pkg=\"missingDeps\"\n label=\"PSD\"\n :download-url=\"props.url\"\n :download-name=\"props.name\"\n />\n <div v-else-if=\"status === 'error'\" class=\"psd__msg psd__msg--error\">\n <p>解码失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"psd__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else>\n <div class=\"psd__bar\">\n <span class=\"psd__badge\">PSD</span>\n <span class=\"psd__dim\">{{ width }}×{{ height }}</span>\n <span v-if=\"layerCount\" class=\"psd__dim\">{{ layerCount }} 图层</span>\n <span v-if=\"props.size\" class=\"psd__dim\">{{ formatBytes(props.size) }}</span>\n <span class=\"psd__spacer\" />\n <button\n type=\"button\"\n class=\"psd__toggle\"\n :class=\"{ 'psd__toggle--on': !fit }\"\n @click=\"fit = !fit\"\n >\n {{ fit ? '原始大小' : '适应' }}\n </button>\n </div>\n\n <div class=\"psd__stage\" :class=\"{ 'psd__stage--fit': fit }\">\n <canvas ref=\"stage\" class=\"psd__canvas\" :class=\"{ 'psd__canvas--fit': fit }\" />\n </div>\n\n <ul v-if=\"panelRows.length\" class=\"psd__layers\">\n <li\n v-for=\"n in panelRows\"\n :key=\"n.id\"\n class=\"psd__layer\"\n :class=\"{ 'psd__layer--group': n.isGroup, 'psd__layer--off': !visible(n.id) }\"\n :style=\"{ paddingLeft: 10 + n.depth * 14 + 'px' }\"\n >\n <button\n type=\"button\"\n class=\"psd__eye\"\n :class=\"{ 'psd__eye--off': hidden.has(n.id) }\"\n :title=\"hidden.has(n.id) ? '显示' : '隐藏'\"\n @click=\"toggle(n)\"\n >\n <Icon name=\"eye\" :size=\"14\" />\n </button>\n <span class=\"psd__thumb\">\n <img v-if=\"n.thumb\" :src=\"n.thumb\" alt=\"\" />\n <span v-else class=\"psd__thumb--group\" />\n </span>\n <span class=\"psd__name\">{{ n.name }}</span>\n <span v-if=\"n.blend !== 'normal'\" class=\"psd__blend\">{{ n.blend }}</span>\n <span v-if=\"n.opacity < 1\" class=\"psd__op\">{{ Math.round(n.opacity * 100) }}%</span>\n </li>\n </ul>\n </template>\n </div>\n</template>\n\n<style scoped>\n.psd {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n.psd__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.psd__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.psd__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.psd__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.psd__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.psd__bar {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n font-size: 12px;\n color: var(--muted);\n}\n.psd__badge {\n padding: 2px 9px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--accent);\n font-weight: 600;\n}\n.psd__dim {\n color: var(--faint);\n}\n.psd__spacer {\n flex: 1 1 auto;\n}\n.psd__toggle {\n min-width: 26px;\n height: 26px;\n padding: 0 10px;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n cursor: pointer;\n}\n.psd__toggle:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.psd__toggle--on {\n color: var(--accent);\n border-color: var(--accent);\n background: var(--surface-2);\n}\n\n/* Checkerboard so layer transparency reads clearly. */\n.psd__stage {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n --sq: 10px;\n background-color: #fff;\n background-image:\n linear-gradient(45deg, #e6e6e6 25%, transparent 25%),\n linear-gradient(-45deg, #e6e6e6 25%, transparent 25%),\n linear-gradient(45deg, transparent 75%, #e6e6e6 75%),\n linear-gradient(-45deg, transparent 75%, #e6e6e6 75%);\n background-size: calc(var(--sq) * 2) calc(var(--sq) * 2);\n background-position:\n 0 0,\n 0 var(--sq),\n var(--sq) calc(var(--sq) * -1),\n calc(var(--sq) * -1) 0;\n}\n.psd__stage--fit {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n}\n.psd__canvas {\n display: block;\n image-rendering: auto;\n}\n.psd__canvas--fit {\n max-width: 100%;\n max-height: 100%;\n object-fit: contain;\n}\n\n.psd__layers {\n list-style: none;\n margin: 0;\n padding: 4px 0;\n flex-shrink: 0;\n max-height: 40%;\n overflow: auto;\n overscroll-behavior: contain;\n border-top: 1px solid var(--border);\n background: var(--surface);\n}\n.psd__layer {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 12px 4px 0;\n font-size: 12.5px;\n color: var(--text);\n}\n.psd__layer--group {\n font-weight: 600;\n color: var(--muted);\n}\n.psd__layer--off {\n opacity: 0.45;\n}\n.psd__eye {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 22px;\n flex-shrink: 0;\n padding: 0;\n color: var(--accent);\n background: transparent;\n border: none;\n cursor: pointer;\n}\n.psd__eye--off {\n color: var(--faint);\n}\n.psd__thumb {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 30px;\n height: 30px;\n flex-shrink: 0;\n border: 1px solid var(--border);\n border-radius: 4px;\n background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 0 / 10px 10px;\n overflow: hidden;\n}\n.psd__thumb img {\n max-width: 100%;\n max-height: 100%;\n display: block;\n}\n.psd__thumb--group {\n width: 14px;\n height: 11px;\n border: 1.4px solid var(--faint);\n border-radius: 2px;\n background: transparent;\n}\n.psd__name {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.psd__blend {\n flex-shrink: 0;\n font-size: 10.5px;\n color: var(--accent);\n}\n.psd__op {\n flex-shrink: 0;\n font-family: var(--font-mono, monospace);\n font-size: 11px;\n color: var(--faint);\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"PsdViewer.js","names":[],"sources":["../../src/components/PsdViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { onBeforeUnmount, ref, shallowRef, watch } from 'vue'\nimport type { Layer, Psd } from 'ag-psd'\nimport { formatBytes } from '@/lib/format'\nimport { isMissingModule } from '@/lib/optionalDep'\nimport Icon from '@/components/icons/Icon.vue'\nimport MissingDependency from '@/components/MissingDependency.vue'\n\n/**\n * Side-viewer for Photoshop documents (.psd/.psb). Fetches the bytes and parses them in-browser with ag-psd\n * (dynamically imported), then — unlike the generic RasterViewer which only shows the flattened composite —\n * exposes the LAYER TREE: a panel of every layer/group with a thumbnail and a visibility toggle, and it\n * RE-COMPOSITES live onto a canvas as you toggle layers (honouring per-layer opacity and blend mode; group\n * visibility cascades to its children). A flattened PSD (no layers) falls back to ag-psd's composite.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\n// Set when the PSD engine (ag-psd, an optional peer) isn't installed → show the install card, not a raw error.\nconst missingDeps = ref<string[] | null>(null)\nconst fit = ref(true)\n\n// Reading a PSD decodes EVERY layer's pixels (needed for the layer panel), so gate very large files behind a\n// confirm the way RasterViewer does — a huge multi-layer PSD would otherwise lock the tab while decoding.\nconst SIZE_LIMIT = 60 * 1024 * 1024\n\ninterface PsdNode {\n id: number\n name: string\n depth: number\n isGroup: boolean\n opacity: number\n blend: string\n left: number\n top: number\n canvas?: HTMLCanvasElement // leaf pixels\n thumb?: string // leaf thumbnail data URL\n parentId: number\n children: PsdNode[]\n}\n\nlet width = 0\nlet height = 0\nconst drawOrder = shallowRef<PsdNode[]>([]) // leaves, bottom → top (compositing order)\nconst panelRows = shallowRef<PsdNode[]>([]) // groups + leaves, top → bottom (Photoshop panel order)\nconst nodeById = new Map<number, PsdNode>()\nconst hidden = ref(new Set<number>()) // ids the user (or the file) has hidden\nlet fallbackCanvas: HTMLCanvasElement | undefined // flattened composite, when there are no layers\nconst stage = ref<HTMLCanvasElement>()\n\nconst layerCount = ref(0)\n\n// Photoshop blend mode → canvas globalCompositeOperation (unknown → normal).\nconst BLEND: Record<string, GlobalCompositeOperation> = {\n normal: 'source-over',\n multiply: 'multiply',\n screen: 'screen',\n overlay: 'overlay',\n darken: 'darken',\n lighten: 'lighten',\n 'color dodge': 'color-dodge',\n 'linear dodge': 'lighter',\n 'color burn': 'color-burn',\n 'hard light': 'hard-light',\n 'soft light': 'soft-light',\n difference: 'difference',\n exclusion: 'exclusion',\n hue: 'hue',\n saturation: 'saturation',\n color: 'color',\n luminosity: 'luminosity',\n}\n\nfunction makeThumb(src: HTMLCanvasElement): string | undefined {\n if (!src.width || !src.height) return undefined\n const S = 34\n const c = document.createElement('canvas')\n c.width = S\n c.height = S\n const ctx = c.getContext('2d')\n if (!ctx) return undefined\n const s = Math.min(S / src.width, S / src.height)\n const w = src.width * s\n const h = src.height * s\n ctx.drawImage(src, (S - w) / 2, (S - h) / 2, w, h)\n return c.toDataURL()\n}\n\nfunction build(psd: Psd): void {\n nodeById.clear()\n const hid = new Set<number>()\n let id = 0\n function rec(layers: Layer[] | undefined, depth: number, parentId: number): PsdNode[] {\n const out: PsdNode[] = []\n for (const l of layers ?? []) {\n const isGroup = Array.isArray(l.children)\n const n: PsdNode = {\n id: id++,\n name: l.name || (isGroup ? '组' : '图层'),\n depth,\n isGroup,\n opacity: typeof l.opacity === 'number' ? l.opacity : 1,\n blend: l.blendMode || 'normal',\n left: l.left ?? 0,\n top: l.top ?? 0,\n canvas: isGroup ? undefined : (l.canvas as HTMLCanvasElement | undefined),\n parentId,\n children: [],\n }\n nodeById.set(n.id, n)\n if (l.hidden) hid.add(n.id)\n if (n.canvas) n.thumb = makeThumb(n.canvas)\n if (isGroup) n.children = rec(l.children, depth + 1, n.id)\n out.push(n)\n }\n return out\n }\n const tree = rec(psd.children, 0, -1)\n\n // Compositing order: leaves in document (bottom → top) order.\n const leaves: PsdNode[] = []\n const collectDraw = (ns: PsdNode[]): void => {\n for (const n of ns) {\n if (n.isGroup) collectDraw(n.children)\n else if (n.canvas) leaves.push(n)\n }\n }\n collectDraw(tree)\n\n // Panel order: top → bottom (reverse each level), group header before its indented children.\n const rows: PsdNode[] = []\n const collectPanel = (ns: PsdNode[]): void => {\n for (const n of [...ns].reverse()) {\n rows.push(n)\n if (n.isGroup) collectPanel(n.children)\n }\n }\n collectPanel(tree)\n\n drawOrder.value = leaves\n panelRows.value = rows\n hidden.value = hid\n layerCount.value = leaves.length\n fallbackCanvas = leaves.length ? undefined : (psd.canvas as HTMLCanvasElement | undefined)\n}\n\n// A leaf is visible only if neither it nor any ancestor group is hidden.\nfunction visible(id: number): boolean {\n let n: PsdNode | undefined = nodeById.get(id)\n while (n) {\n if (hidden.value.has(n.id)) return false\n n = n.parentId >= 0 ? nodeById.get(n.parentId) : undefined\n }\n return true\n}\n\nfunction recomposite(): void {\n const cv = stage.value\n if (!cv) return\n cv.width = width || fallbackCanvas?.width || 1\n cv.height = height || fallbackCanvas?.height || 1\n const ctx = cv.getContext('2d')\n if (!ctx) return\n ctx.clearRect(0, 0, cv.width, cv.height)\n if (fallbackCanvas) {\n ctx.drawImage(fallbackCanvas, 0, 0)\n return\n }\n for (const leaf of drawOrder.value) {\n if (!leaf.canvas || !visible(leaf.id)) continue\n ctx.globalAlpha = Math.max(0, Math.min(1, leaf.opacity))\n ctx.globalCompositeOperation = BLEND[leaf.blend] ?? 'source-over'\n ctx.drawImage(leaf.canvas, leaf.left, leaf.top)\n }\n ctx.globalAlpha = 1\n ctx.globalCompositeOperation = 'source-over'\n}\n\nfunction toggle(n: PsdNode): void {\n const next = new Set(hidden.value)\n if (next.has(n.id)) next.delete(n.id)\n else next.add(n.id)\n hidden.value = next\n recomposite()\n}\n\nfunction load(): void {\n if (!props.url) return\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n missingDeps.value = null\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n const { readPsd } = await import('ag-psd')\n const psd = readPsd(buf, { skipThumbnail: true })\n width = psd.width\n height = psd.height\n build(psd)\n status.value = 'ready'\n // wait for the canvas element to mount, then paint\n requestAnimationFrame(recomposite)\n } catch (e) {\n if (isMissingModule(e)) missingDeps.value = ['ag-psd']\n else errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nwatch(() => props.url, load, { immediate: true })\nonBeforeUnmount(() => {\n drawOrder.value = []\n panelRows.value = []\n nodeById.clear()\n})\n</script>\n\n<template>\n <div class=\"psd\">\n <p v-if=\"status === 'loading'\" class=\"psd__msg\">正在解码 PSD…</p>\n <div v-else-if=\"status === 'gate'\" class=\"psd__msg psd__gate\">\n <p>该 PSD 较大({{ formatBytes(props.size) }}),将在浏览器内逐图层解码。是否继续?</p>\n <button type=\"button\" class=\"psd__btn\" @click=\"doLoad\">解码并预览</button>\n </div>\n <MissingDependency\n v-else-if=\"missingDeps\"\n :pkg=\"missingDeps\"\n label=\"PSD\"\n :download-url=\"props.url\"\n :download-name=\"props.name\"\n />\n <div v-else-if=\"status === 'error'\" class=\"psd__msg psd__msg--error\">\n <p>解码失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"psd__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else>\n <div class=\"psd__bar\">\n <span class=\"psd__badge\">PSD</span>\n <span class=\"psd__dim\">{{ width }}×{{ height }}</span>\n <span v-if=\"layerCount\" class=\"psd__dim\">{{ layerCount }} 图层</span>\n <span v-if=\"props.size\" class=\"psd__dim\">{{ formatBytes(props.size) }}</span>\n <span class=\"psd__spacer\" />\n <button\n type=\"button\"\n class=\"psd__toggle\"\n :class=\"{ 'psd__toggle--on': !fit }\"\n @click=\"fit = !fit\"\n >\n {{ fit ? '原始大小' : '适应' }}\n </button>\n </div>\n\n <div class=\"psd__stage\" :class=\"{ 'psd__stage--fit': fit }\">\n <canvas ref=\"stage\" class=\"psd__canvas\" :class=\"{ 'psd__canvas--fit': fit }\" />\n </div>\n\n <ul v-if=\"panelRows.length\" class=\"psd__layers\">\n <li\n v-for=\"n in panelRows\"\n :key=\"n.id\"\n class=\"psd__layer\"\n :class=\"{ 'psd__layer--group': n.isGroup, 'psd__layer--off': !visible(n.id) }\"\n :style=\"{ paddingLeft: 10 + n.depth * 14 + 'px' }\"\n >\n <button\n type=\"button\"\n class=\"psd__eye\"\n :class=\"{ 'psd__eye--off': hidden.has(n.id) }\"\n :title=\"hidden.has(n.id) ? '显示' : '隐藏'\"\n @click=\"toggle(n)\"\n >\n <Icon name=\"eye\" :size=\"14\" />\n </button>\n <span class=\"psd__thumb\">\n <img v-if=\"n.thumb\" :src=\"n.thumb\" alt=\"\" />\n <span v-else class=\"psd__thumb--group\" />\n </span>\n <span class=\"psd__name\">{{ n.name }}</span>\n <span v-if=\"n.blend !== 'normal'\" class=\"psd__blend\">{{ n.blend }}</span>\n <span v-if=\"n.opacity < 1\" class=\"psd__op\">{{ Math.round(n.opacity * 100) }}%</span>\n </li>\n </ul>\n </template>\n </div>\n</template>\n\n<style scoped>\n.psd {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n.psd__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.psd__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.psd__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.psd__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.psd__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.psd__bar {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n font-size: 12px;\n color: var(--muted);\n}\n.psd__badge {\n padding: 2px 9px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--accent);\n font-weight: 600;\n}\n.psd__dim {\n color: var(--faint);\n}\n.psd__spacer {\n flex: 1 1 auto;\n}\n.psd__toggle {\n min-width: 26px;\n height: 26px;\n padding: 0 10px;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n cursor: pointer;\n}\n.psd__toggle:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.psd__toggle--on {\n color: var(--accent);\n border-color: var(--accent);\n background: var(--surface-2);\n}\n\n/* Checkerboard so layer transparency reads clearly. */\n.psd__stage {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n --sq: 10px;\n background-color: var(--surface);\n background-image:\n linear-gradient(45deg, var(--surface-2) 25%, transparent 25%),\n linear-gradient(-45deg, var(--surface-2) 25%, transparent 25%),\n linear-gradient(45deg, transparent 75%, var(--surface-2) 75%),\n linear-gradient(-45deg, transparent 75%, var(--surface-2) 75%);\n background-size: calc(var(--sq) * 2) calc(var(--sq) * 2);\n background-position:\n 0 0,\n 0 var(--sq),\n var(--sq) calc(var(--sq) * -1),\n calc(var(--sq) * -1) 0;\n}\n.psd__stage--fit {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n}\n.psd__canvas {\n display: block;\n image-rendering: auto;\n}\n.psd__canvas--fit {\n max-width: 100%;\n max-height: 100%;\n object-fit: contain;\n}\n\n.psd__layers {\n list-style: none;\n margin: 0;\n padding: 4px 0;\n flex-shrink: 0;\n max-height: 40%;\n overflow: auto;\n overscroll-behavior: contain;\n border-top: 1px solid var(--border);\n background: var(--surface);\n}\n.psd__layer {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 12px 4px 0;\n font-size: 12.5px;\n color: var(--text);\n}\n.psd__layer--group {\n font-weight: 600;\n color: var(--muted);\n}\n.psd__layer--off {\n opacity: 0.45;\n}\n.psd__eye {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 22px;\n flex-shrink: 0;\n padding: 0;\n color: var(--accent);\n background: transparent;\n border: none;\n cursor: pointer;\n}\n.psd__eye--off {\n color: var(--faint);\n}\n.psd__thumb {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 30px;\n height: 30px;\n flex-shrink: 0;\n border: 1px solid var(--border);\n border-radius: 4px;\n background: repeating-conic-gradient(var(--surface-2) 0% 25%, var(--surface) 0% 50%) 0 / 10px 10px;\n overflow: hidden;\n}\n.psd__thumb img {\n max-width: 100%;\n max-height: 100%;\n display: block;\n}\n.psd__thumb--group {\n width: 14px;\n height: 11px;\n border: 1.4px solid var(--faint);\n border-radius: 2px;\n background: transparent;\n}\n.psd__name {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.psd__blend {\n flex-shrink: 0;\n font-size: 10.5px;\n color: var(--accent);\n}\n.psd__op {\n flex-shrink: 0;\n font-family: var(--font-mono, monospace);\n font-size: 11px;\n color: var(--faint);\n}\n</style>\n"],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"PsdViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/PsdViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { onBeforeUnmount, ref, shallowRef, watch } from 'vue'\nimport type { Layer, Psd } from 'ag-psd'\nimport { formatBytes } from '@/lib/format'\nimport { isMissingModule } from '@/lib/optionalDep'\nimport Icon from '@/components/icons/Icon.vue'\nimport MissingDependency from '@/components/MissingDependency.vue'\n\n/**\n * Side-viewer for Photoshop documents (.psd/.psb). Fetches the bytes and parses them in-browser with ag-psd\n * (dynamically imported), then — unlike the generic RasterViewer which only shows the flattened composite —\n * exposes the LAYER TREE: a panel of every layer/group with a thumbnail and a visibility toggle, and it\n * RE-COMPOSITES live onto a canvas as you toggle layers (honouring per-layer opacity and blend mode; group\n * visibility cascades to its children). A flattened PSD (no layers) falls back to ag-psd's composite.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\n// Set when the PSD engine (ag-psd, an optional peer) isn't installed → show the install card, not a raw error.\nconst missingDeps = ref<string[] | null>(null)\nconst fit = ref(true)\n\n// Reading a PSD decodes EVERY layer's pixels (needed for the layer panel), so gate very large files behind a\n// confirm the way RasterViewer does — a huge multi-layer PSD would otherwise lock the tab while decoding.\nconst SIZE_LIMIT = 60 * 1024 * 1024\n\ninterface PsdNode {\n id: number\n name: string\n depth: number\n isGroup: boolean\n opacity: number\n blend: string\n left: number\n top: number\n canvas?: HTMLCanvasElement // leaf pixels\n thumb?: string // leaf thumbnail data URL\n parentId: number\n children: PsdNode[]\n}\n\nlet width = 0\nlet height = 0\nconst drawOrder = shallowRef<PsdNode[]>([]) // leaves, bottom → top (compositing order)\nconst panelRows = shallowRef<PsdNode[]>([]) // groups + leaves, top → bottom (Photoshop panel order)\nconst nodeById = new Map<number, PsdNode>()\nconst hidden = ref(new Set<number>()) // ids the user (or the file) has hidden\nlet fallbackCanvas: HTMLCanvasElement | undefined // flattened composite, when there are no layers\nconst stage = ref<HTMLCanvasElement>()\n\nconst layerCount = ref(0)\n\n// Photoshop blend mode → canvas globalCompositeOperation (unknown → normal).\nconst BLEND: Record<string, GlobalCompositeOperation> = {\n normal: 'source-over',\n multiply: 'multiply',\n screen: 'screen',\n overlay: 'overlay',\n darken: 'darken',\n lighten: 'lighten',\n 'color dodge': 'color-dodge',\n 'linear dodge': 'lighter',\n 'color burn': 'color-burn',\n 'hard light': 'hard-light',\n 'soft light': 'soft-light',\n difference: 'difference',\n exclusion: 'exclusion',\n hue: 'hue',\n saturation: 'saturation',\n color: 'color',\n luminosity: 'luminosity',\n}\n\nfunction makeThumb(src: HTMLCanvasElement): string | undefined {\n if (!src.width || !src.height) return undefined\n const S = 34\n const c = document.createElement('canvas')\n c.width = S\n c.height = S\n const ctx = c.getContext('2d')\n if (!ctx) return undefined\n const s = Math.min(S / src.width, S / src.height)\n const w = src.width * s\n const h = src.height * s\n ctx.drawImage(src, (S - w) / 2, (S - h) / 2, w, h)\n return c.toDataURL()\n}\n\nfunction build(psd: Psd): void {\n nodeById.clear()\n const hid = new Set<number>()\n let id = 0\n function rec(layers: Layer[] | undefined, depth: number, parentId: number): PsdNode[] {\n const out: PsdNode[] = []\n for (const l of layers ?? []) {\n const isGroup = Array.isArray(l.children)\n const n: PsdNode = {\n id: id++,\n name: l.name || (isGroup ? '组' : '图层'),\n depth,\n isGroup,\n opacity: typeof l.opacity === 'number' ? l.opacity : 1,\n blend: l.blendMode || 'normal',\n left: l.left ?? 0,\n top: l.top ?? 0,\n canvas: isGroup ? undefined : (l.canvas as HTMLCanvasElement | undefined),\n parentId,\n children: [],\n }\n nodeById.set(n.id, n)\n if (l.hidden) hid.add(n.id)\n if (n.canvas) n.thumb = makeThumb(n.canvas)\n if (isGroup) n.children = rec(l.children, depth + 1, n.id)\n out.push(n)\n }\n return out\n }\n const tree = rec(psd.children, 0, -1)\n\n // Compositing order: leaves in document (bottom → top) order.\n const leaves: PsdNode[] = []\n const collectDraw = (ns: PsdNode[]): void => {\n for (const n of ns) {\n if (n.isGroup) collectDraw(n.children)\n else if (n.canvas) leaves.push(n)\n }\n }\n collectDraw(tree)\n\n // Panel order: top → bottom (reverse each level), group header before its indented children.\n const rows: PsdNode[] = []\n const collectPanel = (ns: PsdNode[]): void => {\n for (const n of [...ns].reverse()) {\n rows.push(n)\n if (n.isGroup) collectPanel(n.children)\n }\n }\n collectPanel(tree)\n\n drawOrder.value = leaves\n panelRows.value = rows\n hidden.value = hid\n layerCount.value = leaves.length\n fallbackCanvas = leaves.length ? undefined : (psd.canvas as HTMLCanvasElement | undefined)\n}\n\n// A leaf is visible only if neither it nor any ancestor group is hidden.\nfunction visible(id: number): boolean {\n let n: PsdNode | undefined = nodeById.get(id)\n while (n) {\n if (hidden.value.has(n.id)) return false\n n = n.parentId >= 0 ? nodeById.get(n.parentId) : undefined\n }\n return true\n}\n\nfunction recomposite(): void {\n const cv = stage.value\n if (!cv) return\n cv.width = width || fallbackCanvas?.width || 1\n cv.height = height || fallbackCanvas?.height || 1\n const ctx = cv.getContext('2d')\n if (!ctx) return\n ctx.clearRect(0, 0, cv.width, cv.height)\n if (fallbackCanvas) {\n ctx.drawImage(fallbackCanvas, 0, 0)\n return\n }\n for (const leaf of drawOrder.value) {\n if (!leaf.canvas || !visible(leaf.id)) continue\n ctx.globalAlpha = Math.max(0, Math.min(1, leaf.opacity))\n ctx.globalCompositeOperation = BLEND[leaf.blend] ?? 'source-over'\n ctx.drawImage(leaf.canvas, leaf.left, leaf.top)\n }\n ctx.globalAlpha = 1\n ctx.globalCompositeOperation = 'source-over'\n}\n\nfunction toggle(n: PsdNode): void {\n const next = new Set(hidden.value)\n if (next.has(n.id)) next.delete(n.id)\n else next.add(n.id)\n hidden.value = next\n recomposite()\n}\n\nfunction load(): void {\n if (!props.url) return\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n missingDeps.value = null\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n const { readPsd } = await import('ag-psd')\n const psd = readPsd(buf, { skipThumbnail: true })\n width = psd.width\n height = psd.height\n build(psd)\n status.value = 'ready'\n // wait for the canvas element to mount, then paint\n requestAnimationFrame(recomposite)\n } catch (e) {\n if (isMissingModule(e)) missingDeps.value = ['ag-psd']\n else errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nwatch(() => props.url, load, { immediate: true })\nonBeforeUnmount(() => {\n drawOrder.value = []\n panelRows.value = []\n nodeById.clear()\n})\n</script>\n\n<template>\n <div class=\"psd\">\n <p v-if=\"status === 'loading'\" class=\"psd__msg\">正在解码 PSD…</p>\n <div v-else-if=\"status === 'gate'\" class=\"psd__msg psd__gate\">\n <p>该 PSD 较大({{ formatBytes(props.size) }}),将在浏览器内逐图层解码。是否继续?</p>\n <button type=\"button\" class=\"psd__btn\" @click=\"doLoad\">解码并预览</button>\n </div>\n <MissingDependency\n v-else-if=\"missingDeps\"\n :pkg=\"missingDeps\"\n label=\"PSD\"\n :download-url=\"props.url\"\n :download-name=\"props.name\"\n />\n <div v-else-if=\"status === 'error'\" class=\"psd__msg psd__msg--error\">\n <p>解码失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"psd__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else>\n <div class=\"psd__bar\">\n <span class=\"psd__badge\">PSD</span>\n <span class=\"psd__dim\">{{ width }}×{{ height }}</span>\n <span v-if=\"layerCount\" class=\"psd__dim\">{{ layerCount }} 图层</span>\n <span v-if=\"props.size\" class=\"psd__dim\">{{ formatBytes(props.size) }}</span>\n <span class=\"psd__spacer\" />\n <button\n type=\"button\"\n class=\"psd__toggle\"\n :class=\"{ 'psd__toggle--on': !fit }\"\n @click=\"fit = !fit\"\n >\n {{ fit ? '原始大小' : '适应' }}\n </button>\n </div>\n\n <div class=\"psd__stage\" :class=\"{ 'psd__stage--fit': fit }\">\n <canvas ref=\"stage\" class=\"psd__canvas\" :class=\"{ 'psd__canvas--fit': fit }\" />\n </div>\n\n <ul v-if=\"panelRows.length\" class=\"psd__layers\">\n <li\n v-for=\"n in panelRows\"\n :key=\"n.id\"\n class=\"psd__layer\"\n :class=\"{ 'psd__layer--group': n.isGroup, 'psd__layer--off': !visible(n.id) }\"\n :style=\"{ paddingLeft: 10 + n.depth * 14 + 'px' }\"\n >\n <button\n type=\"button\"\n class=\"psd__eye\"\n :class=\"{ 'psd__eye--off': hidden.has(n.id) }\"\n :title=\"hidden.has(n.id) ? '显示' : '隐藏'\"\n @click=\"toggle(n)\"\n >\n <Icon name=\"eye\" :size=\"14\" />\n </button>\n <span class=\"psd__thumb\">\n <img v-if=\"n.thumb\" :src=\"n.thumb\" alt=\"\" />\n <span v-else class=\"psd__thumb--group\" />\n </span>\n <span class=\"psd__name\">{{ n.name }}</span>\n <span v-if=\"n.blend !== 'normal'\" class=\"psd__blend\">{{ n.blend }}</span>\n <span v-if=\"n.opacity < 1\" class=\"psd__op\">{{ Math.round(n.opacity * 100) }}%</span>\n </li>\n </ul>\n </template>\n </div>\n</template>\n\n<style scoped>\n.psd {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n.psd__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.psd__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.psd__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.psd__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.psd__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.psd__bar {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n font-size: 12px;\n color: var(--muted);\n}\n.psd__badge {\n padding: 2px 9px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--accent);\n font-weight: 600;\n}\n.psd__dim {\n color: var(--faint);\n}\n.psd__spacer {\n flex: 1 1 auto;\n}\n.psd__toggle {\n min-width: 26px;\n height: 26px;\n padding: 0 10px;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n cursor: pointer;\n}\n.psd__toggle:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.psd__toggle--on {\n color: var(--accent);\n border-color: var(--accent);\n background: var(--surface-2);\n}\n\n/* Checkerboard so layer transparency reads clearly. */\n.psd__stage {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n --sq: 10px;\n background-color: #fff;\n background-image:\n linear-gradient(45deg, #e6e6e6 25%, transparent 25%),\n linear-gradient(-45deg, #e6e6e6 25%, transparent 25%),\n linear-gradient(45deg, transparent 75%, #e6e6e6 75%),\n linear-gradient(-45deg, transparent 75%, #e6e6e6 75%);\n background-size: calc(var(--sq) * 2) calc(var(--sq) * 2);\n background-position:\n 0 0,\n 0 var(--sq),\n var(--sq) calc(var(--sq) * -1),\n calc(var(--sq) * -1) 0;\n}\n.psd__stage--fit {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n}\n.psd__canvas {\n display: block;\n image-rendering: auto;\n}\n.psd__canvas--fit {\n max-width: 100%;\n max-height: 100%;\n object-fit: contain;\n}\n\n.psd__layers {\n list-style: none;\n margin: 0;\n padding: 4px 0;\n flex-shrink: 0;\n max-height: 40%;\n overflow: auto;\n overscroll-behavior: contain;\n border-top: 1px solid var(--border);\n background: var(--surface);\n}\n.psd__layer {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 12px 4px 0;\n font-size: 12.5px;\n color: var(--text);\n}\n.psd__layer--group {\n font-weight: 600;\n color: var(--muted);\n}\n.psd__layer--off {\n opacity: 0.45;\n}\n.psd__eye {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 22px;\n flex-shrink: 0;\n padding: 0;\n color: var(--accent);\n background: transparent;\n border: none;\n cursor: pointer;\n}\n.psd__eye--off {\n color: var(--faint);\n}\n.psd__thumb {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 30px;\n height: 30px;\n flex-shrink: 0;\n border: 1px solid var(--border);\n border-radius: 4px;\n background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 0 / 10px 10px;\n overflow: hidden;\n}\n.psd__thumb img {\n max-width: 100%;\n max-height: 100%;\n display: block;\n}\n.psd__thumb--group {\n width: 14px;\n height: 11px;\n border: 1.4px solid var(--faint);\n border-radius: 2px;\n background: transparent;\n}\n.psd__name {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.psd__blend {\n flex-shrink: 0;\n font-size: 10.5px;\n color: var(--accent);\n}\n.psd__op {\n flex-shrink: 0;\n font-family: var(--font-mono, monospace);\n font-size: 11px;\n color: var(--faint);\n}\n</style>\n"],"mappings":"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"}
1
+ {"version":3,"file":"PsdViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/PsdViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { onBeforeUnmount, ref, shallowRef, watch } from 'vue'\nimport type { Layer, Psd } from 'ag-psd'\nimport { formatBytes } from '@/lib/format'\nimport { isMissingModule } from '@/lib/optionalDep'\nimport Icon from '@/components/icons/Icon.vue'\nimport MissingDependency from '@/components/MissingDependency.vue'\n\n/**\n * Side-viewer for Photoshop documents (.psd/.psb). Fetches the bytes and parses them in-browser with ag-psd\n * (dynamically imported), then — unlike the generic RasterViewer which only shows the flattened composite —\n * exposes the LAYER TREE: a panel of every layer/group with a thumbnail and a visibility toggle, and it\n * RE-COMPOSITES live onto a canvas as you toggle layers (honouring per-layer opacity and blend mode; group\n * visibility cascades to its children). A flattened PSD (no layers) falls back to ag-psd's composite.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\n// Set when the PSD engine (ag-psd, an optional peer) isn't installed → show the install card, not a raw error.\nconst missingDeps = ref<string[] | null>(null)\nconst fit = ref(true)\n\n// Reading a PSD decodes EVERY layer's pixels (needed for the layer panel), so gate very large files behind a\n// confirm the way RasterViewer does — a huge multi-layer PSD would otherwise lock the tab while decoding.\nconst SIZE_LIMIT = 60 * 1024 * 1024\n\ninterface PsdNode {\n id: number\n name: string\n depth: number\n isGroup: boolean\n opacity: number\n blend: string\n left: number\n top: number\n canvas?: HTMLCanvasElement // leaf pixels\n thumb?: string // leaf thumbnail data URL\n parentId: number\n children: PsdNode[]\n}\n\nlet width = 0\nlet height = 0\nconst drawOrder = shallowRef<PsdNode[]>([]) // leaves, bottom → top (compositing order)\nconst panelRows = shallowRef<PsdNode[]>([]) // groups + leaves, top → bottom (Photoshop panel order)\nconst nodeById = new Map<number, PsdNode>()\nconst hidden = ref(new Set<number>()) // ids the user (or the file) has hidden\nlet fallbackCanvas: HTMLCanvasElement | undefined // flattened composite, when there are no layers\nconst stage = ref<HTMLCanvasElement>()\n\nconst layerCount = ref(0)\n\n// Photoshop blend mode → canvas globalCompositeOperation (unknown → normal).\nconst BLEND: Record<string, GlobalCompositeOperation> = {\n normal: 'source-over',\n multiply: 'multiply',\n screen: 'screen',\n overlay: 'overlay',\n darken: 'darken',\n lighten: 'lighten',\n 'color dodge': 'color-dodge',\n 'linear dodge': 'lighter',\n 'color burn': 'color-burn',\n 'hard light': 'hard-light',\n 'soft light': 'soft-light',\n difference: 'difference',\n exclusion: 'exclusion',\n hue: 'hue',\n saturation: 'saturation',\n color: 'color',\n luminosity: 'luminosity',\n}\n\nfunction makeThumb(src: HTMLCanvasElement): string | undefined {\n if (!src.width || !src.height) return undefined\n const S = 34\n const c = document.createElement('canvas')\n c.width = S\n c.height = S\n const ctx = c.getContext('2d')\n if (!ctx) return undefined\n const s = Math.min(S / src.width, S / src.height)\n const w = src.width * s\n const h = src.height * s\n ctx.drawImage(src, (S - w) / 2, (S - h) / 2, w, h)\n return c.toDataURL()\n}\n\nfunction build(psd: Psd): void {\n nodeById.clear()\n const hid = new Set<number>()\n let id = 0\n function rec(layers: Layer[] | undefined, depth: number, parentId: number): PsdNode[] {\n const out: PsdNode[] = []\n for (const l of layers ?? []) {\n const isGroup = Array.isArray(l.children)\n const n: PsdNode = {\n id: id++,\n name: l.name || (isGroup ? '组' : '图层'),\n depth,\n isGroup,\n opacity: typeof l.opacity === 'number' ? l.opacity : 1,\n blend: l.blendMode || 'normal',\n left: l.left ?? 0,\n top: l.top ?? 0,\n canvas: isGroup ? undefined : (l.canvas as HTMLCanvasElement | undefined),\n parentId,\n children: [],\n }\n nodeById.set(n.id, n)\n if (l.hidden) hid.add(n.id)\n if (n.canvas) n.thumb = makeThumb(n.canvas)\n if (isGroup) n.children = rec(l.children, depth + 1, n.id)\n out.push(n)\n }\n return out\n }\n const tree = rec(psd.children, 0, -1)\n\n // Compositing order: leaves in document (bottom → top) order.\n const leaves: PsdNode[] = []\n const collectDraw = (ns: PsdNode[]): void => {\n for (const n of ns) {\n if (n.isGroup) collectDraw(n.children)\n else if (n.canvas) leaves.push(n)\n }\n }\n collectDraw(tree)\n\n // Panel order: top → bottom (reverse each level), group header before its indented children.\n const rows: PsdNode[] = []\n const collectPanel = (ns: PsdNode[]): void => {\n for (const n of [...ns].reverse()) {\n rows.push(n)\n if (n.isGroup) collectPanel(n.children)\n }\n }\n collectPanel(tree)\n\n drawOrder.value = leaves\n panelRows.value = rows\n hidden.value = hid\n layerCount.value = leaves.length\n fallbackCanvas = leaves.length ? undefined : (psd.canvas as HTMLCanvasElement | undefined)\n}\n\n// A leaf is visible only if neither it nor any ancestor group is hidden.\nfunction visible(id: number): boolean {\n let n: PsdNode | undefined = nodeById.get(id)\n while (n) {\n if (hidden.value.has(n.id)) return false\n n = n.parentId >= 0 ? nodeById.get(n.parentId) : undefined\n }\n return true\n}\n\nfunction recomposite(): void {\n const cv = stage.value\n if (!cv) return\n cv.width = width || fallbackCanvas?.width || 1\n cv.height = height || fallbackCanvas?.height || 1\n const ctx = cv.getContext('2d')\n if (!ctx) return\n ctx.clearRect(0, 0, cv.width, cv.height)\n if (fallbackCanvas) {\n ctx.drawImage(fallbackCanvas, 0, 0)\n return\n }\n for (const leaf of drawOrder.value) {\n if (!leaf.canvas || !visible(leaf.id)) continue\n ctx.globalAlpha = Math.max(0, Math.min(1, leaf.opacity))\n ctx.globalCompositeOperation = BLEND[leaf.blend] ?? 'source-over'\n ctx.drawImage(leaf.canvas, leaf.left, leaf.top)\n }\n ctx.globalAlpha = 1\n ctx.globalCompositeOperation = 'source-over'\n}\n\nfunction toggle(n: PsdNode): void {\n const next = new Set(hidden.value)\n if (next.has(n.id)) next.delete(n.id)\n else next.add(n.id)\n hidden.value = next\n recomposite()\n}\n\nfunction load(): void {\n if (!props.url) return\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n missingDeps.value = null\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n const { readPsd } = await import('ag-psd')\n const psd = readPsd(buf, { skipThumbnail: true })\n width = psd.width\n height = psd.height\n build(psd)\n status.value = 'ready'\n // wait for the canvas element to mount, then paint\n requestAnimationFrame(recomposite)\n } catch (e) {\n if (isMissingModule(e)) missingDeps.value = ['ag-psd']\n else errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nwatch(() => props.url, load, { immediate: true })\nonBeforeUnmount(() => {\n drawOrder.value = []\n panelRows.value = []\n nodeById.clear()\n})\n</script>\n\n<template>\n <div class=\"psd\">\n <p v-if=\"status === 'loading'\" class=\"psd__msg\">正在解码 PSD…</p>\n <div v-else-if=\"status === 'gate'\" class=\"psd__msg psd__gate\">\n <p>该 PSD 较大({{ formatBytes(props.size) }}),将在浏览器内逐图层解码。是否继续?</p>\n <button type=\"button\" class=\"psd__btn\" @click=\"doLoad\">解码并预览</button>\n </div>\n <MissingDependency\n v-else-if=\"missingDeps\"\n :pkg=\"missingDeps\"\n label=\"PSD\"\n :download-url=\"props.url\"\n :download-name=\"props.name\"\n />\n <div v-else-if=\"status === 'error'\" class=\"psd__msg psd__msg--error\">\n <p>解码失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"psd__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else>\n <div class=\"psd__bar\">\n <span class=\"psd__badge\">PSD</span>\n <span class=\"psd__dim\">{{ width }}×{{ height }}</span>\n <span v-if=\"layerCount\" class=\"psd__dim\">{{ layerCount }} 图层</span>\n <span v-if=\"props.size\" class=\"psd__dim\">{{ formatBytes(props.size) }}</span>\n <span class=\"psd__spacer\" />\n <button\n type=\"button\"\n class=\"psd__toggle\"\n :class=\"{ 'psd__toggle--on': !fit }\"\n @click=\"fit = !fit\"\n >\n {{ fit ? '原始大小' : '适应' }}\n </button>\n </div>\n\n <div class=\"psd__stage\" :class=\"{ 'psd__stage--fit': fit }\">\n <canvas ref=\"stage\" class=\"psd__canvas\" :class=\"{ 'psd__canvas--fit': fit }\" />\n </div>\n\n <ul v-if=\"panelRows.length\" class=\"psd__layers\">\n <li\n v-for=\"n in panelRows\"\n :key=\"n.id\"\n class=\"psd__layer\"\n :class=\"{ 'psd__layer--group': n.isGroup, 'psd__layer--off': !visible(n.id) }\"\n :style=\"{ paddingLeft: 10 + n.depth * 14 + 'px' }\"\n >\n <button\n type=\"button\"\n class=\"psd__eye\"\n :class=\"{ 'psd__eye--off': hidden.has(n.id) }\"\n :title=\"hidden.has(n.id) ? '显示' : '隐藏'\"\n @click=\"toggle(n)\"\n >\n <Icon name=\"eye\" :size=\"14\" />\n </button>\n <span class=\"psd__thumb\">\n <img v-if=\"n.thumb\" :src=\"n.thumb\" alt=\"\" />\n <span v-else class=\"psd__thumb--group\" />\n </span>\n <span class=\"psd__name\">{{ n.name }}</span>\n <span v-if=\"n.blend !== 'normal'\" class=\"psd__blend\">{{ n.blend }}</span>\n <span v-if=\"n.opacity < 1\" class=\"psd__op\">{{ Math.round(n.opacity * 100) }}%</span>\n </li>\n </ul>\n </template>\n </div>\n</template>\n\n<style scoped>\n.psd {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n.psd__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.psd__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.psd__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.psd__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.psd__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.psd__bar {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n font-size: 12px;\n color: var(--muted);\n}\n.psd__badge {\n padding: 2px 9px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--accent);\n font-weight: 600;\n}\n.psd__dim {\n color: var(--faint);\n}\n.psd__spacer {\n flex: 1 1 auto;\n}\n.psd__toggle {\n min-width: 26px;\n height: 26px;\n padding: 0 10px;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n cursor: pointer;\n}\n.psd__toggle:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.psd__toggle--on {\n color: var(--accent);\n border-color: var(--accent);\n background: var(--surface-2);\n}\n\n/* Checkerboard so layer transparency reads clearly. */\n.psd__stage {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n --sq: 10px;\n background-color: var(--surface);\n background-image:\n linear-gradient(45deg, var(--surface-2) 25%, transparent 25%),\n linear-gradient(-45deg, var(--surface-2) 25%, transparent 25%),\n linear-gradient(45deg, transparent 75%, var(--surface-2) 75%),\n linear-gradient(-45deg, transparent 75%, var(--surface-2) 75%);\n background-size: calc(var(--sq) * 2) calc(var(--sq) * 2);\n background-position:\n 0 0,\n 0 var(--sq),\n var(--sq) calc(var(--sq) * -1),\n calc(var(--sq) * -1) 0;\n}\n.psd__stage--fit {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n}\n.psd__canvas {\n display: block;\n image-rendering: auto;\n}\n.psd__canvas--fit {\n max-width: 100%;\n max-height: 100%;\n object-fit: contain;\n}\n\n.psd__layers {\n list-style: none;\n margin: 0;\n padding: 4px 0;\n flex-shrink: 0;\n max-height: 40%;\n overflow: auto;\n overscroll-behavior: contain;\n border-top: 1px solid var(--border);\n background: var(--surface);\n}\n.psd__layer {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 12px 4px 0;\n font-size: 12.5px;\n color: var(--text);\n}\n.psd__layer--group {\n font-weight: 600;\n color: var(--muted);\n}\n.psd__layer--off {\n opacity: 0.45;\n}\n.psd__eye {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 22px;\n flex-shrink: 0;\n padding: 0;\n color: var(--accent);\n background: transparent;\n border: none;\n cursor: pointer;\n}\n.psd__eye--off {\n color: var(--faint);\n}\n.psd__thumb {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 30px;\n height: 30px;\n flex-shrink: 0;\n border: 1px solid var(--border);\n border-radius: 4px;\n background: repeating-conic-gradient(var(--surface-2) 0% 25%, var(--surface) 0% 50%) 0 / 10px 10px;\n overflow: hidden;\n}\n.psd__thumb img {\n max-width: 100%;\n max-height: 100%;\n display: block;\n}\n.psd__thumb--group {\n width: 14px;\n height: 11px;\n border: 1.4px solid var(--faint);\n border-radius: 2px;\n background: transparent;\n}\n.psd__name {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.psd__blend {\n flex-shrink: 0;\n font-size: 10.5px;\n color: var(--accent);\n}\n.psd__op {\n flex-shrink: 0;\n font-family: var(--font-mono, monospace);\n font-size: 11px;\n color: var(--faint);\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 "./SqliteViewer.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/SqliteViewer.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-b4d65354"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-7d0c3119"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8
 
@@ -1 +1 @@
1
- {"version":3,"file":"SqliteViewer.js","names":[],"sources":["../../src/components/SqliteViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport type { Database } from 'sql.js'\nimport {\n type CellValue,\n type Column,\n type QueryResult,\n type TableInfo,\n execSql,\n initSqlEngine,\n listTables,\n loadDatabase,\n readPage,\n} from '@/lib/sqlite'\nimport { formatBytes } from '@/lib/format'\n\n/**\n * Side-viewer SQLite (`.sqlite`/`.db`/`.sqlite3`) reader. Fetches the database bytes and opens them with sql.js\n * (WASM, lazily loaded — see lib/sqlite.ts), then browses it read-only: a table/view picker, a paginated data\n * grid (with PK/FK/IDX column badges), and a small SQL console for ad-hoc queries.\n *\n * Everything runs in-browser; sql.js holds the whole file in WASM memory, so very large databases are gated\n * behind a confirm before loading. The DB handle is closed on reload/unmount to free that memory. BLOB cells\n * render as a `[BLOB nB]` placeholder (never decoded into the grid).\n */\nconst props = defineProps<{\n /** Download URL of the database bytes. */\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\n\nlet db: Database | null = null\nconst tables = ref<TableInfo[]>([])\nconst selectedName = ref('')\n\ntype Mode = 'table' | 'sql'\nconst mode = ref<Mode>('table')\n\nconst PAGE_SIZE = 100\nconst page = ref(0)\nconst pageRows = ref<CellValue[][]>([])\n\nconst sqlText = ref('')\nconst queryResult = ref<QueryResult | null>(null)\n\n// sql.js loads the entire file into WASM memory; gate big databases behind a confirm.\nconst SIZE_LIMIT = 60 * 1024 * 1024\n\nconst selectedTable = computed(\n () => tables.value.find((t) => t.name === selectedName.value) ?? null,\n)\nconst totalPages = computed(() => {\n const t = selectedTable.value\n if (!t || t.rowCount <= 0) return 1\n return Math.max(1, Math.ceil(t.rowCount / PAGE_SIZE))\n})\n\nfunction closeDb(): void {\n try {\n db?.close()\n } catch {\n /* already closed — ignore */\n }\n db = null\n}\n\n/** Reload the current page of the selected table from the DB. */\nfunction loadPage(): void {\n if (!db || !selectedTable.value) {\n pageRows.value = []\n return\n }\n try {\n pageRows.value = readPage(db, selectedName.value, PAGE_SIZE, page.value * PAGE_SIZE)\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n pageRows.value = []\n }\n}\n\nfunction selectTable(name: string): void {\n selectedName.value = name\n page.value = 0\n mode.value = 'table'\n loadPage()\n}\n\nfunction prevPage(): void {\n if (page.value > 0) {\n page.value--\n loadPage()\n }\n}\nfunction nextPage(): void {\n if (page.value < totalPages.value - 1) {\n page.value++\n loadPage()\n }\n}\n\nfunction runSql(): void {\n if (!db) return\n const sql = sqlText.value.trim()\n if (!sql) return\n queryResult.value = execSql(db, sql)\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n closeDb()\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n const SQL = await initSqlEngine()\n db = loadDatabase(SQL, new Uint8Array(buf))\n tables.value = listTables(db)\n if (tables.value.length) {\n selectedName.value = tables.value[0].name\n page.value = 0\n loadPage()\n } else {\n selectedName.value = ''\n }\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n closeDb()\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nwatch(() => props.url, load, { immediate: true })\nonBeforeUnmount(closeDb)\n\n/* ===== grid rendering: shared between table view and query results ===== */\n\n// Column descriptors for the active grid. Table mode uses the rich Column (with badges); SQL mode\n// only knows column names, so it maps them to bare descriptors.\nconst gridColumns = computed<Column[]>(() => {\n if (mode.value === 'sql') {\n return (queryResult.value?.columns ?? []).map((name) => ({ name, type: '' }))\n }\n return selectedTable.value?.columns ?? []\n})\nconst gridRows = computed<CellValue[][]>(() =>\n mode.value === 'sql' ? (queryResult.value?.rows ?? []) : pageRows.value,\n)\n\nfunction colBadge(c: Column): string {\n if (c.pk) return 'PK'\n if (c.fk) return 'FK'\n if (c.idx) return 'IDX'\n return ''\n}\nfunction cellText(v: CellValue): string {\n return v == null ? '' : String(v)\n}\n</script>\n\n<template>\n <div class=\"sq\">\n <p v-if=\"status === 'loading'\" class=\"sq__msg\">正在加载数据库…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"sq__msg sq__gate\">\n <p>该数据库较大({{ formatBytes(props.size) }}),将整体载入浏览器内存。是否继续?</p>\n <button type=\"button\" class=\"sq__btn\" @click=\"doLoad\">加载并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"sq__msg sq__msg--error\">\n <p>加载失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"sq__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else-if=\"status === 'ready'\">\n <!-- toolbar: table picker + mode toggle -->\n <div class=\"sq__bar\">\n <select\n v-if=\"tables.length\"\n class=\"sq__select\"\n :value=\"selectedName\"\n @change=\"selectTable(($event.target as HTMLSelectElement).value)\"\n >\n <option v-for=\"t in tables\" :key=\"t.name\" :value=\"t.name\">\n {{ t.kind === 'view' ? '⊞ ' : '' }}{{ t.name }} ({{ t.rowCount }})\n </option>\n </select>\n <span v-else class=\"sq__notables\">(无用户表)</span>\n <div class=\"sq__modes\">\n <button\n type=\"button\"\n class=\"sq__mode\"\n :class=\"{ 'sq__mode--on': mode === 'table' }\"\n :disabled=\"!tables.length\"\n @click=\"mode = 'table'\"\n >\n 数据\n </button>\n <button\n type=\"button\"\n class=\"sq__mode\"\n :class=\"{ 'sq__mode--on': mode === 'sql' }\"\n @click=\"mode = 'sql'\"\n >\n SQL\n </button>\n </div>\n </div>\n\n <!-- SQL console -->\n <div v-if=\"mode === 'sql'\" class=\"sq__sqlbar\">\n <textarea\n v-model=\"sqlText\"\n class=\"sq__sqlinput\"\n rows=\"3\"\n placeholder=\"输入 SQL(如 SELECT * FROM … LIMIT 100),Ctrl+Enter 执行\"\n @keydown.ctrl.enter.prevent=\"runSql\"\n @keydown.meta.enter.prevent=\"runSql\"\n />\n <div class=\"sq__sqlrun\">\n <button type=\"button\" class=\"sq__btn\" @click=\"runSql\">执行</button>\n <span v-if=\"queryResult && !queryResult.error\" class=\"sq__stat\">\n {{ queryResult.rowCount }} 行 · {{ queryResult.elapsedMs.toFixed(1) }} ms\n </span>\n <span v-else-if=\"queryResult?.error\" class=\"sq__err\">{{ queryResult.error }}</span>\n </div>\n </div>\n\n <!-- data grid (table rows or query results) -->\n <div class=\"sq__gridwrap\">\n <table v-if=\"gridColumns.length\" class=\"sq__grid\">\n <thead>\n <tr>\n <th class=\"sq__rownum\">#</th>\n <th v-for=\"(c, ci) in gridColumns\" :key=\"ci\" class=\"sq__th\">\n <span class=\"sq__colname\">{{ c.name }}</span>\n <span\n v-if=\"colBadge(c)\"\n class=\"sq__badge\"\n :class=\"`sq__badge--${colBadge(c).toLowerCase()}`\"\n >{{ colBadge(c) }}</span\n >\n <span v-if=\"c.type\" class=\"sq__coltype\">{{ c.type }}</span>\n </th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"(row, ri) in gridRows\" :key=\"ri\">\n <td class=\"sq__rownum\">\n {{ mode === 'table' ? page * PAGE_SIZE + ri + 1 : ri + 1 }}\n </td>\n <td\n v-for=\"(v, ci) in row\"\n :key=\"ci\"\n class=\"sq__td\"\n :class=\"{ 'sq__td--null': v == null }\"\n >\n {{ v == null ? 'NULL' : cellText(v) }}\n </td>\n </tr>\n <tr v-if=\"!gridRows.length\">\n <td class=\"sq__emptycell\" :colspan=\"gridColumns.length + 1\">\n {{ mode === 'sql' ? '(无结果)' : '(空表)' }}\n </td>\n </tr>\n </tbody>\n </table>\n <p v-else class=\"sq__msg\">\n {{ mode === 'sql' ? '执行一条 SQL 查看结果。' : '该表无列。' }}\n </p>\n </div>\n\n <!-- pagination (table mode only) -->\n <div v-if=\"mode === 'table' && selectedTable\" class=\"sq__pager\">\n <button type=\"button\" class=\"sq__pgbtn\" :disabled=\"page === 0\" @click=\"prevPage\">‹</button>\n <span class=\"sq__pginfo\">\n 第 {{ page + 1 }} / {{ totalPages }} 页 · 共 {{ selectedTable.rowCount }} 行\n </span>\n <button\n type=\"button\"\n class=\"sq__pgbtn\"\n :disabled=\"page >= totalPages - 1\"\n @click=\"nextPage\"\n >\n ›\n </button>\n </div>\n </template>\n </div>\n</template>\n\n<style scoped>\n.sq {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.sq__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.sq__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.sq__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.sq__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.sq__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* toolbar */\n.sq__bar {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n}\n.sq__select {\n flex: 1 1 auto;\n min-width: 0;\n padding: 6px 10px;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface-2);\n border: 1px solid var(--border);\n border-radius: 7px;\n}\n.sq__select:focus {\n outline: none;\n border-color: var(--accent);\n}\n.sq__notables {\n flex: 1 1 auto;\n font-size: 12.5px;\n color: var(--faint);\n}\n.sq__modes {\n display: inline-flex;\n flex-shrink: 0;\n gap: 2px;\n padding: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.sq__mode {\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: transparent;\n border: none;\n border-radius: 6px;\n padding: 4px 12px;\n cursor: pointer;\n}\n.sq__mode:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n.sq__mode--on {\n color: var(--accent);\n background: var(--surface);\n box-shadow: var(--shadow);\n}\n\n/* SQL console */\n.sq__sqlbar {\n flex-shrink: 0;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n}\n.sq__sqlinput {\n width: 100%;\n resize: vertical;\n padding: 8px 10px;\n font-family: var(--font-mono);\n font-size: 12px;\n line-height: 1.5;\n color: var(--text);\n background: var(--code-bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n}\n.sq__sqlinput:focus {\n outline: none;\n border-color: var(--accent);\n}\n.sq__sqlrun {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-top: 8px;\n}\n.sq__stat {\n font-size: 11.5px;\n color: var(--faint);\n}\n.sq__err {\n font-size: 11.5px;\n color: var(--danger);\n word-break: break-word;\n}\n\n/* data grid */\n.sq__gridwrap {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n}\n.sq__grid {\n border-collapse: collapse;\n width: max-content;\n min-width: 100%;\n font-size: 12px;\n}\n.sq__grid th,\n.sq__grid td {\n border-right: 1px solid var(--border);\n border-bottom: 1px solid var(--border);\n padding: 5px 10px;\n text-align: left;\n vertical-align: top;\n white-space: nowrap;\n max-width: 360px;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.sq__th {\n position: sticky;\n top: 0;\n z-index: 1;\n background: var(--surface-2);\n white-space: nowrap;\n}\n.sq__colname {\n font-weight: 600;\n color: var(--text-strong);\n}\n.sq__badge {\n margin-left: 5px;\n padding: 0 4px;\n border-radius: 3px;\n font-size: 9.5px;\n font-weight: 700;\n letter-spacing: 0.03em;\n vertical-align: middle;\n}\n.sq__badge--pk {\n color: #b08900;\n background: rgba(240, 200, 0, 0.16);\n}\n.sq__badge--fk {\n color: #1c7ed6;\n background: rgba(28, 126, 214, 0.14);\n}\n.sq__badge--idx {\n color: #2f9e44;\n background: rgba(47, 158, 68, 0.14);\n}\n.sq__coltype {\n margin-left: 5px;\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--faint);\n}\n.sq__rownum {\n position: sticky;\n left: 0;\n z-index: 1;\n width: 1%;\n text-align: right;\n color: var(--faint);\n background: var(--surface-2);\n font-family: var(--font-mono);\n font-size: 11px;\n}\n.sq__td {\n color: var(--text);\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.sq__td--null {\n color: var(--faint);\n font-style: italic;\n}\n.sq__emptycell {\n padding: 16px;\n text-align: center;\n color: var(--faint);\n font-size: 12px;\n}\n\n/* pagination */\n.sq__pager {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 14px;\n padding: 8px 12px;\n border-top: 1px solid var(--border);\n}\n.sq__pgbtn {\n width: 30px;\n height: 26px;\n font-size: 16px;\n line-height: 1;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n cursor: pointer;\n}\n.sq__pgbtn:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n.sq__pgbtn:not(:disabled):hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.sq__pginfo {\n font-size: 11.5px;\n color: var(--muted);\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"SqliteViewer.js","names":[],"sources":["../../src/components/SqliteViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport type { Database } from 'sql.js'\nimport {\n type CellValue,\n type Column,\n type QueryResult,\n type TableInfo,\n execSql,\n initSqlEngine,\n listTables,\n loadDatabase,\n readPage,\n} from '@/lib/sqlite'\nimport { formatBytes } from '@/lib/format'\n\n/**\n * Side-viewer SQLite (`.sqlite`/`.db`/`.sqlite3`) reader. Fetches the database bytes and opens them with sql.js\n * (WASM, lazily loaded — see lib/sqlite.ts), then browses it read-only: a table/view picker, a paginated data\n * grid (with PK/FK/IDX column badges), and a small SQL console for ad-hoc queries.\n *\n * Everything runs in-browser; sql.js holds the whole file in WASM memory, so very large databases are gated\n * behind a confirm before loading. The DB handle is closed on reload/unmount to free that memory. BLOB cells\n * render as a `[BLOB nB]` placeholder (never decoded into the grid).\n */\nconst props = defineProps<{\n /** Download URL of the database bytes. */\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\n\nlet db: Database | null = null\nconst tables = ref<TableInfo[]>([])\nconst selectedName = ref('')\n\ntype Mode = 'table' | 'sql'\nconst mode = ref<Mode>('table')\n\nconst PAGE_SIZE = 100\nconst page = ref(0)\nconst pageRows = ref<CellValue[][]>([])\n\nconst sqlText = ref('')\nconst queryResult = ref<QueryResult | null>(null)\n\n// sql.js loads the entire file into WASM memory; gate big databases behind a confirm.\nconst SIZE_LIMIT = 60 * 1024 * 1024\n\nconst selectedTable = computed(\n () => tables.value.find((t) => t.name === selectedName.value) ?? null,\n)\nconst totalPages = computed(() => {\n const t = selectedTable.value\n if (!t || t.rowCount <= 0) return 1\n return Math.max(1, Math.ceil(t.rowCount / PAGE_SIZE))\n})\n\nfunction closeDb(): void {\n try {\n db?.close()\n } catch {\n /* already closed — ignore */\n }\n db = null\n}\n\n/** Reload the current page of the selected table from the DB. */\nfunction loadPage(): void {\n if (!db || !selectedTable.value) {\n pageRows.value = []\n return\n }\n try {\n pageRows.value = readPage(db, selectedName.value, PAGE_SIZE, page.value * PAGE_SIZE)\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n pageRows.value = []\n }\n}\n\nfunction selectTable(name: string): void {\n selectedName.value = name\n page.value = 0\n mode.value = 'table'\n loadPage()\n}\n\nfunction prevPage(): void {\n if (page.value > 0) {\n page.value--\n loadPage()\n }\n}\nfunction nextPage(): void {\n if (page.value < totalPages.value - 1) {\n page.value++\n loadPage()\n }\n}\n\nfunction runSql(): void {\n if (!db) return\n const sql = sqlText.value.trim()\n if (!sql) return\n queryResult.value = execSql(db, sql)\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n closeDb()\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n const SQL = await initSqlEngine()\n db = loadDatabase(SQL, new Uint8Array(buf))\n tables.value = listTables(db)\n if (tables.value.length) {\n selectedName.value = tables.value[0].name\n page.value = 0\n loadPage()\n } else {\n selectedName.value = ''\n }\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n closeDb()\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nwatch(() => props.url, load, { immediate: true })\nonBeforeUnmount(closeDb)\n\n/* ===== grid rendering: shared between table view and query results ===== */\n\n// Column descriptors for the active grid. Table mode uses the rich Column (with badges); SQL mode\n// only knows column names, so it maps them to bare descriptors.\nconst gridColumns = computed<Column[]>(() => {\n if (mode.value === 'sql') {\n return (queryResult.value?.columns ?? []).map((name) => ({ name, type: '' }))\n }\n return selectedTable.value?.columns ?? []\n})\nconst gridRows = computed<CellValue[][]>(() =>\n mode.value === 'sql' ? (queryResult.value?.rows ?? []) : pageRows.value,\n)\n\nfunction colBadge(c: Column): string {\n if (c.pk) return 'PK'\n if (c.fk) return 'FK'\n if (c.idx) return 'IDX'\n return ''\n}\nfunction cellText(v: CellValue): string {\n return v == null ? '' : String(v)\n}\n</script>\n\n<template>\n <div class=\"sq\">\n <p v-if=\"status === 'loading'\" class=\"sq__msg\">正在加载数据库…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"sq__msg sq__gate\">\n <p>该数据库较大({{ formatBytes(props.size) }}),将整体载入浏览器内存。是否继续?</p>\n <button type=\"button\" class=\"sq__btn\" @click=\"doLoad\">加载并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"sq__msg sq__msg--error\">\n <p>加载失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"sq__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else-if=\"status === 'ready'\">\n <!-- toolbar: table picker + mode toggle -->\n <div class=\"sq__bar\">\n <select\n v-if=\"tables.length\"\n class=\"sq__select\"\n :value=\"selectedName\"\n @change=\"selectTable(($event.target as HTMLSelectElement).value)\"\n >\n <option v-for=\"t in tables\" :key=\"t.name\" :value=\"t.name\">\n {{ t.kind === 'view' ? '⊞ ' : '' }}{{ t.name }} ({{ t.rowCount }})\n </option>\n </select>\n <span v-else class=\"sq__notables\">(无用户表)</span>\n <div class=\"sq__modes\">\n <button\n type=\"button\"\n class=\"sq__mode\"\n :class=\"{ 'sq__mode--on': mode === 'table' }\"\n :disabled=\"!tables.length\"\n @click=\"mode = 'table'\"\n >\n 数据\n </button>\n <button\n type=\"button\"\n class=\"sq__mode\"\n :class=\"{ 'sq__mode--on': mode === 'sql' }\"\n @click=\"mode = 'sql'\"\n >\n SQL\n </button>\n </div>\n </div>\n\n <!-- SQL console -->\n <div v-if=\"mode === 'sql'\" class=\"sq__sqlbar\">\n <textarea\n v-model=\"sqlText\"\n class=\"sq__sqlinput\"\n rows=\"3\"\n placeholder=\"输入 SQL(如 SELECT * FROM … LIMIT 100),Ctrl+Enter 执行\"\n @keydown.ctrl.enter.prevent=\"runSql\"\n @keydown.meta.enter.prevent=\"runSql\"\n />\n <div class=\"sq__sqlrun\">\n <button type=\"button\" class=\"sq__btn\" @click=\"runSql\">执行</button>\n <span v-if=\"queryResult && !queryResult.error\" class=\"sq__stat\">\n {{ queryResult.rowCount }} 行 · {{ queryResult.elapsedMs.toFixed(1) }} ms\n </span>\n <span v-else-if=\"queryResult?.error\" class=\"sq__err\">{{ queryResult.error }}</span>\n </div>\n </div>\n\n <!-- data grid (table rows or query results) -->\n <div class=\"sq__gridwrap\">\n <table v-if=\"gridColumns.length\" class=\"sq__grid\">\n <thead>\n <tr>\n <th class=\"sq__rownum\">#</th>\n <th v-for=\"(c, ci) in gridColumns\" :key=\"ci\" class=\"sq__th\">\n <span class=\"sq__colname\">{{ c.name }}</span>\n <span\n v-if=\"colBadge(c)\"\n class=\"sq__badge\"\n :class=\"`sq__badge--${colBadge(c).toLowerCase()}`\"\n >{{ colBadge(c) }}</span\n >\n <span v-if=\"c.type\" class=\"sq__coltype\">{{ c.type }}</span>\n </th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"(row, ri) in gridRows\" :key=\"ri\">\n <td class=\"sq__rownum\">\n {{ mode === 'table' ? page * PAGE_SIZE + ri + 1 : ri + 1 }}\n </td>\n <td\n v-for=\"(v, ci) in row\"\n :key=\"ci\"\n class=\"sq__td\"\n :class=\"{ 'sq__td--null': v == null }\"\n >\n {{ v == null ? 'NULL' : cellText(v) }}\n </td>\n </tr>\n <tr v-if=\"!gridRows.length\">\n <td class=\"sq__emptycell\" :colspan=\"gridColumns.length + 1\">\n {{ mode === 'sql' ? '(无结果)' : '(空表)' }}\n </td>\n </tr>\n </tbody>\n </table>\n <p v-else class=\"sq__msg\">\n {{ mode === 'sql' ? '执行一条 SQL 查看结果。' : '该表无列。' }}\n </p>\n </div>\n\n <!-- pagination (table mode only) -->\n <div v-if=\"mode === 'table' && selectedTable\" class=\"sq__pager\">\n <button type=\"button\" class=\"sq__pgbtn\" :disabled=\"page === 0\" @click=\"prevPage\">‹</button>\n <span class=\"sq__pginfo\">\n 第 {{ page + 1 }} / {{ totalPages }} 页 · 共 {{ selectedTable.rowCount }} 行\n </span>\n <button\n type=\"button\"\n class=\"sq__pgbtn\"\n :disabled=\"page >= totalPages - 1\"\n @click=\"nextPage\"\n >\n ›\n </button>\n </div>\n </template>\n </div>\n</template>\n\n<style scoped>\n.sq {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.sq__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.sq__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.sq__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.sq__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.sq__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* toolbar */\n.sq__bar {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n}\n.sq__select {\n flex: 1 1 auto;\n min-width: 0;\n padding: 6px 10px;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface-2);\n border: 1px solid var(--border);\n border-radius: 7px;\n}\n.sq__select:focus {\n outline: none;\n border-color: var(--accent);\n}\n.sq__notables {\n flex: 1 1 auto;\n font-size: 12.5px;\n color: var(--faint);\n}\n.sq__modes {\n display: inline-flex;\n flex-shrink: 0;\n gap: 2px;\n padding: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.sq__mode {\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: transparent;\n border: none;\n border-radius: 6px;\n padding: 4px 12px;\n cursor: pointer;\n}\n.sq__mode:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n.sq__mode--on {\n color: var(--accent);\n background: var(--surface);\n box-shadow: var(--shadow);\n}\n\n/* SQL console */\n.sq__sqlbar {\n flex-shrink: 0;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n}\n.sq__sqlinput {\n width: 100%;\n resize: vertical;\n padding: 8px 10px;\n font-family: var(--font-mono);\n font-size: 12px;\n line-height: 1.5;\n color: var(--text);\n background: var(--code-bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n}\n.sq__sqlinput:focus {\n outline: none;\n border-color: var(--accent);\n}\n.sq__sqlrun {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-top: 8px;\n}\n.sq__stat {\n font-size: 11.5px;\n color: var(--faint);\n}\n.sq__err {\n font-size: 11.5px;\n color: var(--danger);\n word-break: break-word;\n}\n\n/* data grid */\n.sq__gridwrap {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n}\n.sq__grid {\n border-collapse: collapse;\n width: max-content;\n min-width: 100%;\n font-size: 12px;\n}\n.sq__grid th,\n.sq__grid td {\n border-right: 1px solid var(--border);\n border-bottom: 1px solid var(--border);\n padding: 5px 10px;\n text-align: left;\n vertical-align: top;\n white-space: nowrap;\n max-width: 360px;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.sq__th {\n position: sticky;\n top: 0;\n z-index: 1;\n background: var(--surface-2);\n white-space: nowrap;\n}\n.sq__colname {\n font-weight: 600;\n color: var(--text-strong);\n}\n.sq__badge {\n margin-left: 5px;\n padding: 0 4px;\n border-radius: 3px;\n font-size: 9.5px;\n font-weight: 700;\n letter-spacing: 0.03em;\n vertical-align: middle;\n}\n.sq__badge--pk {\n color: var(--ansi-yellow);\n background: rgba(240, 200, 0, 0.16);\n}\n.sq__badge--fk {\n color: var(--hl-attr);\n background: rgba(28, 126, 214, 0.14);\n}\n.sq__badge--idx {\n color: var(--hl-name);\n background: rgba(47, 158, 68, 0.14);\n}\n.sq__coltype {\n margin-left: 5px;\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--faint);\n}\n.sq__rownum {\n position: sticky;\n left: 0;\n z-index: 1;\n width: 1%;\n text-align: right;\n color: var(--faint);\n background: var(--surface-2);\n font-family: var(--font-mono);\n font-size: 11px;\n}\n.sq__td {\n color: var(--text);\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.sq__td--null {\n color: var(--faint);\n font-style: italic;\n}\n.sq__emptycell {\n padding: 16px;\n text-align: center;\n color: var(--faint);\n font-size: 12px;\n}\n\n/* pagination */\n.sq__pager {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 14px;\n padding: 8px 12px;\n border-top: 1px solid var(--border);\n}\n.sq__pgbtn {\n width: 30px;\n height: 26px;\n font-size: 16px;\n line-height: 1;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n cursor: pointer;\n}\n.sq__pgbtn:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n.sq__pgbtn:not(:disabled):hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.sq__pginfo {\n font-size: 11.5px;\n color: var(--muted);\n}\n</style>\n"],"mappings":""}