@l1yp/file-viewer 0.1.1 → 0.1.3
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.
- package/README.md +1 -0
- package/dist/FileViewer.js +2 -0
- package/dist/FileViewer.js.map +1 -0
- package/dist/FileViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/FileViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/AppxViewer.js +2 -0
- package/dist/components/AppxViewer.js.map +1 -0
- package/dist/components/AppxViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/AppxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/ArchiveBrowser.js +2 -0
- package/dist/components/ArchiveBrowser.js.map +1 -0
- package/dist/components/ArchiveBrowser.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/ArchiveBrowser.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/AudioViewer.js +2 -0
- package/dist/components/AudioViewer.js.map +1 -0
- package/dist/components/AudioViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/AudioViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/BinObjBrowser.js +2 -0
- package/dist/components/BinObjBrowser.js.map +1 -0
- package/dist/components/BinObjBrowser.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/BinObjBrowser.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/CertViewer.js +2 -0
- package/dist/components/CertViewer.js.map +1 -0
- package/dist/components/CertViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/CertViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/ClassViewer.js +2 -0
- package/dist/components/ClassViewer.js.map +1 -0
- package/dist/components/ClassViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/ClassViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/CodeBlock.js +2 -0
- package/dist/components/CodeBlock.js.map +1 -0
- package/dist/components/CodeBlock.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/CodeBlock.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/CodeView.js +2 -0
- package/dist/components/CodeView.js.map +1 -0
- package/dist/components/CodeView.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/CodeView.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/CsvViewer.js +2 -0
- package/dist/components/CsvViewer.js.map +1 -0
- package/dist/components/CsvViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/CsvViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/DicomViewer.js +2 -0
- package/dist/components/DicomViewer.js.map +1 -0
- package/dist/components/DicomViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/DicomViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/DocxNavigationPane.js +2 -0
- package/dist/components/DocxNavigationPane.js.map +1 -0
- package/dist/components/DocxNavigationPane.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/DocxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/DocxViewer.js +2 -0
- package/dist/components/DocxViewer.js.map +1 -0
- package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/DrawioViewer.js +2 -0
- package/dist/components/DrawioViewer.js.map +1 -0
- package/dist/components/DrawioViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/DrawioViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/DxfViewer.js +2 -0
- package/dist/components/DxfViewer.js.map +1 -0
- package/dist/components/DxfViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/DxfViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/EbookViewer.js +2 -0
- package/dist/components/EbookViewer.js.map +1 -0
- package/dist/components/EbookViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/EbookViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/EmlViewer.js +2 -0
- package/dist/components/EmlViewer.js.map +1 -0
- package/dist/components/EmlViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/EmlViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/EplViewer.js +2 -0
- package/dist/components/EplViewer.js.map +1 -0
- package/dist/components/EplViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/EplViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/FileViewerBody.js +3 -1
- package/dist/components/FileViewerBody.js.map +1 -0
- package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/FileViewerPanel.js +2 -0
- package/dist/components/FileViewerPanel.js.map +1 -0
- package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/FontViewer.js +2 -0
- package/dist/components/FontViewer.js.map +1 -0
- package/dist/components/FontViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/FontViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/FrontmatterCard.js +2 -0
- package/dist/components/FrontmatterCard.js.map +1 -0
- package/dist/components/FrontmatterCard.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/FrontmatterCard.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/GeoViewer.js +2 -0
- package/dist/components/GeoViewer.js.map +1 -0
- package/dist/components/GeoViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/GeoViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/HarViewer.js +2 -0
- package/dist/components/HarViewer.js.map +1 -0
- package/dist/components/HarViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/HarViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/HexViewer.js +2 -0
- package/dist/components/HexViewer.js.map +1 -0
- package/dist/components/HexViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/HexViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/HprofViewer.js +2 -0
- package/dist/components/HprofViewer.js.map +1 -0
- package/dist/components/HprofViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/HprofViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/ImageLightbox.js +2 -0
- package/dist/components/ImageLightbox.js.map +1 -0
- package/dist/components/ImageLightbox.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/ImageLightbox.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/InlineText.js +2 -0
- package/dist/components/InlineText.js.map +1 -0
- package/dist/components/JksViewer.js +2 -0
- package/dist/components/JksViewer.js.map +1 -0
- package/dist/components/JksViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/JksViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/JsonlViewer.js +3 -1
- package/dist/components/JsonlViewer.js.map +1 -0
- package/dist/components/JsonlViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/JsonlViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/LogViewer.js +2 -0
- package/dist/components/LogViewer.js.map +1 -0
- package/dist/components/LogViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/LogViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/LrcViewer.js +2 -0
- package/dist/components/LrcViewer.js.map +1 -0
- package/dist/components/LrcViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/LrcViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/MarkdownTable.js +2 -0
- package/dist/components/MarkdownTable.js.map +1 -0
- package/dist/components/MarkdownTable.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/MarkdownTable.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/MarkdownText.js +2 -0
- package/dist/components/MarkdownText.js.map +1 -0
- package/dist/components/MhtViewer.js +2 -0
- package/dist/components/MhtViewer.js.map +1 -0
- package/dist/components/MhtViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/MhtViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/MindMapView.js +2 -0
- package/dist/components/MindMapView.js.map +1 -0
- package/dist/components/MindMapView.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/MindMapView.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/MissingDependency.js +2 -0
- package/dist/components/MissingDependency.js.map +1 -0
- package/dist/components/MissingDependency.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/MissingDependency.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/ModelViewer.js +2 -0
- package/dist/components/ModelViewer.js.map +1 -0
- package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/MsiViewer.js +2 -0
- package/dist/components/MsiViewer.js.map +1 -0
- package/dist/components/MsiViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/MsiViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/NotebookViewer.js +2 -0
- package/dist/components/NotebookViewer.js.map +1 -0
- package/dist/components/NotebookViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/NotebookViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/PdfNavigationPane.js +9 -0
- package/dist/components/PdfNavigationPane.js.map +1 -0
- package/dist/components/PdfNavigationPane.vue.d.ts +24 -0
- package/dist/components/PdfNavigationPane.vue_vue_type_script_setup_true_lang.js +149 -0
- package/dist/components/PdfNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/PdfViewer.js +3 -1
- package/dist/components/PdfViewer.js.map +1 -0
- package/dist/components/PdfViewer.vue.d.ts +0 -11
- package/dist/components/PdfViewer.vue_vue_type_script_setup_true_lang.js +133 -69
- package/dist/components/PdfViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/PptxNavigationPane.js +2 -0
- package/dist/components/PptxNavigationPane.js.map +1 -0
- package/dist/components/PptxNavigationPane.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/PptxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/PptxViewer.js +3 -1
- package/dist/components/PptxViewer.js.map +1 -0
- package/dist/components/PptxViewer.vue_vue_type_script_setup_true_lang.js +55 -49
- package/dist/components/PptxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/PsdViewer.js +2 -0
- package/dist/components/PsdViewer.js.map +1 -0
- package/dist/components/PsdViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/PsdViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/QmcAudioViewer.js +2 -0
- package/dist/components/QmcAudioViewer.js.map +1 -0
- package/dist/components/QmcAudioViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/QmcAudioViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/RasterViewer.js +2 -0
- package/dist/components/RasterViewer.js.map +1 -0
- package/dist/components/RasterViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/RasterViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/RtfViewer.js +2 -0
- package/dist/components/RtfViewer.js.map +1 -0
- package/dist/components/RtfViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/RtfViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/SevenZipBrowser.js +2 -0
- package/dist/components/SevenZipBrowser.js.map +1 -0
- package/dist/components/SevenZipBrowser.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/SevenZipBrowser.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/SqliteViewer.js +2 -0
- package/dist/components/SqliteViewer.js.map +1 -0
- package/dist/components/SqliteViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/SqliteViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/SubtitleViewer.js +2 -0
- package/dist/components/SubtitleViewer.js.map +1 -0
- package/dist/components/SubtitleViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/SubtitleViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/XlsxChart.js +2 -0
- package/dist/components/XlsxChart.js.map +1 -0
- package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/XlsxViewer.js +2 -0
- package/dist/components/XlsxViewer.js.map +1 -0
- package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/XmindViewer.js +2 -0
- package/dist/components/XmindViewer.js.map +1 -0
- package/dist/components/XmindViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/XmindViewer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/ZipBrowser.js +2 -0
- package/dist/components/ZipBrowser.js.map +1 -0
- package/dist/components/ZipBrowser.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/ZipBrowser.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/ZoomImage.js +3 -1
- package/dist/components/ZoomImage.js.map +1 -0
- package/dist/components/ZoomImage.vue_vue_type_script_setup_true_lang.js +82 -80
- package/dist/components/ZoomImage.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/epl/EplCodeBlock.js +2 -0
- package/dist/components/epl/EplCodeBlock.js.map +1 -0
- package/dist/components/epl/EplCodeBlock.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/epl/EplCodeBlock.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/epl/EplDoc.js +2 -0
- package/dist/components/epl/EplDoc.js.map +1 -0
- package/dist/components/epl/EplDoc.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/epl/EplDoc.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/epl/EplMethod.js +2 -0
- package/dist/components/epl/EplMethod.js.map +1 -0
- package/dist/components/epl/EplMethod.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/epl/EplMethod.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/epl/EplResource.js +2 -0
- package/dist/components/epl/EplResource.js.map +1 -0
- package/dist/components/epl/EplResource.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/epl/EplResource.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/epl/EplTree.js +2 -0
- package/dist/components/epl/EplTree.js.map +1 -0
- package/dist/components/epl/EplTree.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/epl/EplTree.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/epl/EplVarTable.js +2 -0
- package/dist/components/epl/EplVarTable.js.map +1 -0
- package/dist/components/epl/EplVarTable.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/epl/EplVarTable.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/epl/numbering.js +2 -0
- package/dist/components/epl/numbering.js.map +1 -0
- package/dist/components/icons/Icon.js +2 -0
- package/dist/components/icons/Icon.js.map +1 -0
- package/dist/components/icons/Icon.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/icons/Icon.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/icons/iconPaths.js +2 -0
- package/dist/components/icons/iconPaths.js.map +1 -0
- package/dist/components/optionalViewer.js +2 -0
- package/dist/components/optionalViewer.js.map +1 -0
- package/dist/components/ui/ClampBlock.js +2 -0
- package/dist/components/ui/ClampBlock.js.map +1 -0
- package/dist/composables/useElementNavigation.js +2 -0
- package/dist/composables/useElementNavigation.js.map +1 -0
- package/dist/composables/useFileViewer.js +2 -0
- package/dist/composables/useFileViewer.js.map +1 -0
- package/dist/composables/useLightbox.js +2 -0
- package/dist/composables/useLightbox.js.map +1 -0
- package/dist/composables/useLogRules.js +2 -0
- package/dist/composables/useLogRules.js.map +1 -0
- package/dist/lib/aes.js +2 -0
- package/dist/lib/aes.js.map +1 -0
- package/dist/lib/appx.js +2 -0
- package/dist/lib/appx.js.map +1 -0
- package/dist/lib/archiveHtml.js +2 -0
- package/dist/lib/archiveHtml.js.map +1 -0
- package/dist/lib/asn1.js +2 -0
- package/dist/lib/asn1.js.map +1 -0
- package/dist/lib/binobj.js +2 -0
- package/dist/lib/binobj.js.map +1 -0
- package/dist/lib/brotli.js +2 -0
- package/dist/lib/brotli.js.map +1 -0
- package/dist/lib/bzip2.js +2 -0
- package/dist/lib/bzip2.js.map +1 -0
- package/dist/lib/classfile.js +2 -0
- package/dist/lib/classfile.js.map +1 -0
- package/dist/lib/codePreview.js +2 -0
- package/dist/lib/codePreview.js.map +1 -0
- package/dist/lib/codefold.js +2 -0
- package/dist/lib/codefold.js.map +1 -0
- package/dist/lib/codeformat.js +2 -0
- package/dist/lib/codeformat.js.map +1 -0
- package/dist/lib/csv.js +2 -0
- package/dist/lib/csv.js.map +1 -0
- package/dist/lib/des.js +2 -0
- package/dist/lib/des.js.map +1 -0
- package/dist/lib/diagram.js +2 -0
- package/dist/lib/diagram.js.map +1 -0
- package/dist/lib/dicom.js +2 -0
- package/dist/lib/dicom.js.map +1 -0
- package/dist/lib/dicomCodecs.js +2 -0
- package/dist/lib/dicomCodecs.js.map +1 -0
- package/dist/lib/dicomRender.js +2 -0
- package/dist/lib/dicomRender.js.map +1 -0
- package/dist/lib/docxNavigation.js +2 -0
- package/dist/lib/docxNavigation.js.map +1 -0
- package/dist/lib/dxf.js +2 -0
- package/dist/lib/dxf.js.map +1 -0
- package/dist/lib/eml.js +2 -0
- package/dist/lib/eml.js.map +1 -0
- package/dist/lib/encoding.js +2 -0
- package/dist/lib/encoding.js.map +1 -0
- package/dist/lib/epl/code/codeDataParser.js +2 -0
- package/dist/lib/epl/code/codeDataParser.js.map +1 -0
- package/dist/lib/epl/code/codeLines.js +2 -0
- package/dist/lib/epl/code/codeLines.js.map +1 -0
- package/dist/lib/epl/code/textCode.js +2 -0
- package/dist/lib/epl/code/textCode.js.map +1 -0
- package/dist/lib/epl/eplDocument.js +2 -0
- package/dist/lib/epl/eplDocument.js.map +1 -0
- package/dist/lib/epl/imageList.js +2 -0
- package/dist/lib/epl/imageList.js.map +1 -0
- package/dist/lib/epl/index.js +2 -0
- package/dist/lib/epl/index.js.map +1 -0
- package/dist/lib/epl/io/binaryReader.js +2 -0
- package/dist/lib/epl/io/binaryReader.js.map +1 -0
- package/dist/lib/epl/io/encoding.js +2 -0
- package/dist/lib/epl/io/encoding.js.map +1 -0
- package/dist/lib/epl/model/eplSystemId.js +2 -0
- package/dist/lib/epl/model/eplSystemId.js.map +1 -0
- package/dist/lib/epl/names/eplLibData.js +2 -0
- package/dist/lib/epl/names/eplLibData.js.map +1 -0
- package/dist/lib/epl/names/idToNameMap.js +2 -0
- package/dist/lib/epl/names/idToNameMap.js.map +1 -0
- package/dist/lib/epl/names/libNameProvider.js +2 -0
- package/dist/lib/epl/names/libNameProvider.js.map +1 -0
- package/dist/lib/epl/program.js +2 -0
- package/dist/lib/epl/program.js.map +1 -0
- package/dist/lib/epl/projectFileReader.js +2 -0
- package/dist/lib/epl/projectFileReader.js.map +1 -0
- package/dist/lib/fb2.js +2 -0
- package/dist/lib/fb2.js.map +1 -0
- package/dist/lib/fileType.js +2 -0
- package/dist/lib/fileType.js.map +1 -0
- package/dist/lib/fonts.js +2 -0
- package/dist/lib/fonts.js.map +1 -0
- package/dist/lib/format.js +2 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/frontmatter.js +2 -0
- package/dist/lib/frontmatter.js.map +1 -0
- package/dist/lib/geo.js +2 -0
- package/dist/lib/geo.js.map +1 -0
- package/dist/lib/har.js +2 -0
- package/dist/lib/har.js.map +1 -0
- package/dist/lib/highlight.js +2 -0
- package/dist/lib/highlight.js.map +1 -0
- package/dist/lib/highlightLangs.js +2 -0
- package/dist/lib/highlightLangs.js.map +1 -0
- package/dist/lib/hprof.js +2 -0
- package/dist/lib/hprof.js.map +1 -0
- package/dist/lib/ipynb.js +2 -0
- package/dist/lib/ipynb.js.map +1 -0
- package/dist/lib/jks.js +2 -0
- package/dist/lib/jks.js.map +1 -0
- package/dist/lib/jsonl.js +2 -0
- package/dist/lib/jsonl.js.map +1 -0
- package/dist/lib/lrc.js +2 -0
- package/dist/lib/lrc.js.map +1 -0
- package/dist/lib/lzma.js +2 -0
- package/dist/lib/lzma.js.map +1 -0
- package/dist/lib/markdown/blocks.js +2 -0
- package/dist/lib/markdown/blocks.js.map +1 -0
- package/dist/lib/markdown/inline.js +2 -0
- package/dist/lib/markdown/inline.js.map +1 -0
- package/dist/lib/markdown/math.js +2 -0
- package/dist/lib/markdown/math.js.map +1 -0
- package/dist/lib/markdown/segmentize.js +2 -0
- package/dist/lib/markdown/segmentize.js.map +1 -0
- package/dist/lib/markdown/styles.js +2 -0
- package/dist/lib/markdown/styles.js.map +1 -0
- package/dist/lib/mht.js +2 -0
- package/dist/lib/mht.js.map +1 -0
- package/dist/lib/midi.js +2 -0
- package/dist/lib/midi.js.map +1 -0
- package/dist/lib/mime.js +2 -0
- package/dist/lib/mime.js.map +1 -0
- package/dist/lib/mindmap.js +2 -0
- package/dist/lib/mindmap.js.map +1 -0
- package/dist/lib/mobi.js +2 -0
- package/dist/lib/mobi.js.map +1 -0
- package/dist/lib/msi.js +2 -0
- package/dist/lib/msi.js.map +1 -0
- package/dist/lib/optionalDep.js +2 -0
- package/dist/lib/optionalDep.js.map +1 -0
- package/dist/lib/pdfNavigation.d.ts +11 -0
- package/dist/lib/pdfNavigation.js +32 -0
- package/dist/lib/pdfNavigation.js.map +1 -0
- package/dist/lib/pkcs12.js +2 -0
- package/dist/lib/pkcs12.js.map +1 -0
- package/dist/lib/pptxNavigation.js +2 -0
- package/dist/lib/pptxNavigation.js.map +1 -0
- package/dist/lib/qmc.js +2 -0
- package/dist/lib/qmc.js.map +1 -0
- package/dist/lib/rar.js +2 -0
- package/dist/lib/rar.js.map +1 -0
- package/dist/lib/rawPreview.js +2 -0
- package/dist/lib/rawPreview.js.map +1 -0
- package/dist/lib/rc2.js +2 -0
- package/dist/lib/rc2.js.map +1 -0
- package/dist/lib/resolveInput.js +2 -0
- package/dist/lib/resolveInput.js.map +1 -0
- package/dist/lib/rtf.js +2 -0
- package/dist/lib/rtf.js.map +1 -0
- package/dist/lib/sevenZip.js +2 -0
- package/dist/lib/sevenZip.js.map +1 -0
- package/dist/lib/sha1.js +2 -0
- package/dist/lib/sha1.js.map +1 -0
- package/dist/lib/sha256.js +2 -0
- package/dist/lib/sha256.js.map +1 -0
- package/dist/lib/speechAudio.js +2 -0
- package/dist/lib/speechAudio.js.map +1 -0
- package/dist/lib/sqlite.js +2 -0
- package/dist/lib/sqlite.js.map +1 -0
- package/dist/lib/subtitle.js +2 -0
- package/dist/lib/subtitle.js.map +1 -0
- package/dist/lib/tarball.js +2 -0
- package/dist/lib/tarball.js.map +1 -0
- package/dist/lib/viewerFormats.js +2 -0
- package/dist/lib/viewerFormats.js.map +1 -0
- package/dist/lib/viewerKind.js +2 -0
- package/dist/lib/viewerKind.js.map +1 -0
- package/dist/lib/x509.js +2 -0
- package/dist/lib/x509.js.map +1 -0
- package/dist/lib/xlsx.js +2 -0
- package/dist/lib/xlsx.js.map +1 -0
- package/dist/lib/xlsxCharts.js +2 -0
- package/dist/lib/xlsxCharts.js.map +1 -0
- package/dist/lib/xmind.js +2 -0
- package/dist/lib/xmind.js.map +1 -0
- package/dist/lib/zip.js +2 -0
- package/dist/lib/zip.js.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,6 +129,7 @@ npm publish # publishConfig.access=public,已配好公开发布
|
|
|
129
129
|
```
|
|
130
130
|
|
|
131
131
|
`prepack` 脚本会在打包/发布前自动重新构建。发布前记得在 `package.json` 补上 `repository` / `homepage` 字段。
|
|
132
|
+
构建产物会同时生成并发布 `dist/**/*.js.map`,其中包含原始 Vue / TypeScript 源码,使用者的构建工具和浏览器开发者工具可据此还原异常堆栈。
|
|
132
133
|
|
|
133
134
|
## License
|
|
134
135
|
|
package/dist/FileViewer.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FileViewer.js","names":[],"sources":["../src/FileViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport type { ViewerFile, ViewerInput } from './types'\nimport { resolveInput, type ResolvedSource } from './lib/resolveInput'\nimport { classifyViewerFile } from './lib/viewerKind'\nimport { fileExt } from './lib/fileType'\nimport FileViewerBody from './components/FileViewerBody.vue'\n\n/**\n * The batteries-included file viewer (see docs/file-viewer-package-design.md §3). Give it a file — as a full\n * {@link ViewerInput}, or the shorthand `src`/`blob`/`text` + `name` props — and it normalizes the source\n * ({@link resolveInput}, honoring `fetchConfig`/`load` for authenticated sources), classifies it\n * ({@link classifyViewerFile}), and dispatches to the right in-browser viewer via {@link FileViewerBody}.\n *\n * The root carries `class=\"fv-root\"` + the resolved `data-theme`, so all tokens (styles/theme.css) are scoped\n * here — the library never touches the host's global styles. `theme=\"inherit\"` sets no attribute, letting the\n * host drive light/dark by defining the CSS variables itself.\n */\nconst props = withDefaults(\n defineProps<{\n /** Full input (takes precedence over the shorthand props below). */\n input?: ViewerInput\n /** Shorthand source + descriptors (used when `input` is omitted). */\n src?: string\n name?: string\n mime?: string | null\n size?: number\n blob?: Blob\n text?: string\n /** Theme: light | dark | system, or `inherit` (no attribute — host-driven). */\n theme?: 'light' | 'dark' | 'system' | 'inherit'\n }>(),\n { theme: 'system' },\n)\nconst emit = defineEmits<{ loaded: [text: string]; error: [message: string]; close: [] }>()\n\n// The effective input: an explicit `input`, else assembled from the shorthand props (needs at least a name).\nconst effectiveInput = computed<ViewerInput | null>(() => {\n if (props.input) return props.input\n if (!props.name) return null\n return {\n name: props.name,\n src: props.src,\n blob: props.blob,\n text: props.text,\n mime: props.mime,\n size: props.size,\n }\n})\n\nconst vf = ref<ViewerFile | null>(null)\nconst resolving = ref(false)\nconst resolveErr = ref('')\nlet currentSource: ResolvedSource | null = null\n\nfunction disposeCurrent(): void {\n if (currentSource) {\n currentSource.dispose()\n currentSource = null\n }\n}\n\nasync function run(input: ViewerInput | null): Promise<void> {\n disposeCurrent()\n vf.value = null\n resolveErr.value = ''\n if (!input) return\n resolving.value = true\n try {\n const src = await resolveInput(input)\n currentSource = src\n const classified = classifyViewerFile({\n name: src.name,\n url: src.url,\n mime: src.mime,\n size: src.size,\n })\n // Pre-decoded text (from a `text` input) → inline content so text-backed viewers skip re-fetching.\n vf.value = src.text != null ? { ...classified, content: src.text } : classified\n } catch (e) {\n resolveErr.value = String(e)\n emit('error', String(e))\n } finally {\n resolving.value = false\n }\n}\n\nwatch(effectiveInput, (input) => void run(input), { immediate: true })\nonBeforeUnmount(disposeCurrent)\n\nconst dataTheme = computed(() => (props.theme === 'inherit' ? undefined : props.theme))\n\nconst displayName = computed(() => effectiveInput.value?.name ?? '')\nconst ext = computed(() => fileExt(displayName.value).toUpperCase())\nconst isInline = computed(() => vf.value?.content != null)\n</script>\n\n<template>\n <div class=\"fv-root fv\" :data-theme=\"dataTheme\">\n <header class=\"fv__head\">\n <span class=\"fv__title\" :title=\"displayName\">\n <span class=\"fv__name\">{{ displayName }}</span>\n <span v-if=\"ext\" class=\"fv__ext\">· {{ ext }}</span>\n </span>\n <a v-if=\"vf?.url && !isInline\" class=\"fv__act\" :href=\"vf.url\" :download=\"vf.name\" title=\"下载\"\n >下载</a\n >\n <button type=\"button\" class=\"fv__act\" title=\"关闭\" @click=\"emit('close')\">关闭</button>\n </header>\n\n <div class=\"fv__body\">\n <p v-if=\"resolving\" class=\"fv__msg\">准备中…</p>\n <p v-else-if=\"resolveErr\" class=\"fv__msg fv__msg--err\">{{ resolveErr }}</p>\n <FileViewerBody\n v-else-if=\"vf\"\n :file=\"vf\"\n @loaded=\"emit('loaded', $event)\"\n @error=\"emit('error', $event)\"\n />\n <p v-else class=\"fv__msg\">未提供文件。</p>\n </div>\n </div>\n</template>\n\n<style scoped>\n.fv {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n background: var(--surface);\n border: 1px solid var(--border);\n}\n.fv__head {\n display: flex;\n align-items: center;\n gap: 10px;\n flex-shrink: 0;\n padding: 10px 12px 10px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.fv__title {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n align-items: baseline;\n gap: 5px;\n white-space: nowrap;\n overflow: hidden;\n}\n.fv__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n font-size: 13px;\n font-weight: 500;\n color: var(--text-strong);\n}\n.fv__ext {\n flex-shrink: 0;\n font-size: 12px;\n font-weight: 600;\n color: var(--muted);\n}\n.fv__act {\n flex-shrink: 0;\n height: 28px;\n display: inline-flex;\n align-items: center;\n padding: 0 10px;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: transparent;\n border: 1px solid var(--border-2);\n border-radius: 7px;\n text-decoration: none;\n cursor: pointer;\n}\n.fv__act:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.fv__body {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n}\n.fv__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.fv__msg--err {\n color: var(--danger);\n}\n</style>\n"],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FileViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../src/FileViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport type { ViewerFile, ViewerInput } from './types'\nimport { resolveInput, type ResolvedSource } from './lib/resolveInput'\nimport { classifyViewerFile } from './lib/viewerKind'\nimport { fileExt } from './lib/fileType'\nimport FileViewerBody from './components/FileViewerBody.vue'\n\n/**\n * The batteries-included file viewer (see docs/file-viewer-package-design.md §3). Give it a file — as a full\n * {@link ViewerInput}, or the shorthand `src`/`blob`/`text` + `name` props — and it normalizes the source\n * ({@link resolveInput}, honoring `fetchConfig`/`load` for authenticated sources), classifies it\n * ({@link classifyViewerFile}), and dispatches to the right in-browser viewer via {@link FileViewerBody}.\n *\n * The root carries `class=\"fv-root\"` + the resolved `data-theme`, so all tokens (styles/theme.css) are scoped\n * here — the library never touches the host's global styles. `theme=\"inherit\"` sets no attribute, letting the\n * host drive light/dark by defining the CSS variables itself.\n */\nconst props = withDefaults(\n defineProps<{\n /** Full input (takes precedence over the shorthand props below). */\n input?: ViewerInput\n /** Shorthand source + descriptors (used when `input` is omitted). */\n src?: string\n name?: string\n mime?: string | null\n size?: number\n blob?: Blob\n text?: string\n /** Theme: light | dark | system, or `inherit` (no attribute — host-driven). */\n theme?: 'light' | 'dark' | 'system' | 'inherit'\n }>(),\n { theme: 'system' },\n)\nconst emit = defineEmits<{ loaded: [text: string]; error: [message: string]; close: [] }>()\n\n// The effective input: an explicit `input`, else assembled from the shorthand props (needs at least a name).\nconst effectiveInput = computed<ViewerInput | null>(() => {\n if (props.input) return props.input\n if (!props.name) return null\n return {\n name: props.name,\n src: props.src,\n blob: props.blob,\n text: props.text,\n mime: props.mime,\n size: props.size,\n }\n})\n\nconst vf = ref<ViewerFile | null>(null)\nconst resolving = ref(false)\nconst resolveErr = ref('')\nlet currentSource: ResolvedSource | null = null\n\nfunction disposeCurrent(): void {\n if (currentSource) {\n currentSource.dispose()\n currentSource = null\n }\n}\n\nasync function run(input: ViewerInput | null): Promise<void> {\n disposeCurrent()\n vf.value = null\n resolveErr.value = ''\n if (!input) return\n resolving.value = true\n try {\n const src = await resolveInput(input)\n currentSource = src\n const classified = classifyViewerFile({\n name: src.name,\n url: src.url,\n mime: src.mime,\n size: src.size,\n })\n // Pre-decoded text (from a `text` input) → inline content so text-backed viewers skip re-fetching.\n vf.value = src.text != null ? { ...classified, content: src.text } : classified\n } catch (e) {\n resolveErr.value = String(e)\n emit('error', String(e))\n } finally {\n resolving.value = false\n }\n}\n\nwatch(effectiveInput, (input) => void run(input), { immediate: true })\nonBeforeUnmount(disposeCurrent)\n\nconst dataTheme = computed(() => (props.theme === 'inherit' ? undefined : props.theme))\n\nconst displayName = computed(() => effectiveInput.value?.name ?? '')\nconst ext = computed(() => fileExt(displayName.value).toUpperCase())\nconst isInline = computed(() => vf.value?.content != null)\n</script>\n\n<template>\n <div class=\"fv-root fv\" :data-theme=\"dataTheme\">\n <header class=\"fv__head\">\n <span class=\"fv__title\" :title=\"displayName\">\n <span class=\"fv__name\">{{ displayName }}</span>\n <span v-if=\"ext\" class=\"fv__ext\">· {{ ext }}</span>\n </span>\n <a v-if=\"vf?.url && !isInline\" class=\"fv__act\" :href=\"vf.url\" :download=\"vf.name\" title=\"下载\"\n >下载</a\n >\n <button type=\"button\" class=\"fv__act\" title=\"关闭\" @click=\"emit('close')\">关闭</button>\n </header>\n\n <div class=\"fv__body\">\n <p v-if=\"resolving\" class=\"fv__msg\">准备中…</p>\n <p v-else-if=\"resolveErr\" class=\"fv__msg fv__msg--err\">{{ resolveErr }}</p>\n <FileViewerBody\n v-else-if=\"vf\"\n :file=\"vf\"\n @loaded=\"emit('loaded', $event)\"\n @error=\"emit('error', $event)\"\n />\n <p v-else class=\"fv__msg\">未提供文件。</p>\n </div>\n </div>\n</template>\n\n<style scoped>\n.fv {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n background: var(--surface);\n border: 1px solid var(--border);\n}\n.fv__head {\n display: flex;\n align-items: center;\n gap: 10px;\n flex-shrink: 0;\n padding: 10px 12px 10px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.fv__title {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n align-items: baseline;\n gap: 5px;\n white-space: nowrap;\n overflow: hidden;\n}\n.fv__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n font-size: 13px;\n font-weight: 500;\n color: var(--text-strong);\n}\n.fv__ext {\n flex-shrink: 0;\n font-size: 12px;\n font-weight: 600;\n color: var(--muted);\n}\n.fv__act {\n flex-shrink: 0;\n height: 28px;\n display: inline-flex;\n align-items: center;\n padding: 0 10px;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: transparent;\n border: 1px solid var(--border-2);\n border-radius: 7px;\n text-decoration: none;\n cursor: pointer;\n}\n.fv__act:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.fv__body {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n}\n.fv__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.fv__msg--err {\n color: var(--danger);\n}\n</style>\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkBA,IAAM,IAAQ,GAgBR,IAAO,GAGP,IAAiB,QACjB,EAAM,QAAc,EAAM,QACzB,EAAM,OACJ;GACL,MAAM,EAAM;GACZ,KAAK,EAAM;GACX,MAAM,EAAM;GACZ,MAAM,EAAM;GACZ,MAAM,EAAM;GACZ,MAAM,EAAM;EACd,IARwB,IASzB,GAEK,IAAK,EAAuB,IAAI,GAChC,IAAY,EAAI,EAAK,GACrB,IAAa,EAAI,EAAE,GACrB,IAAuC;EAE3C,SAAS,IAAuB;GAC9B,AAEE,OADA,EAAc,QAAQ,GACN;EAEpB;EAEA,eAAe,EAAI,GAA0C;GAC3D,MAAe,GACf,EAAG,QAAQ,MACX,EAAW,QAAQ,IACd,GACL;MAAU,QAAQ;IAClB,IAAI;KACF,IAAM,IAAM,MAAM,EAAa,CAAK;KACpC,IAAgB;KAChB,IAAM,IAAa,EAAmB;MACpC,MAAM,EAAI;MACV,KAAK,EAAI;MACT,MAAM,EAAI;MACV,MAAM,EAAI;KACZ,CAAC;KAED,EAAG,QAAQ,EAAI,QAAQ,OAA8C,IAAvC;MAAE,GAAG;MAAY,SAAS,EAAI;KAAK;IACnE,SAAS,GAAG;KAEV,AADA,EAAW,QAAQ,OAAO,CAAC,GAC3B,EAAK,SAAS,OAAO,CAAC,CAAC;IACzB,UAAU;KACR,EAAU,QAAQ;IACpB;GAjBkB;EAkBpB;EAGA,AADA,EAAM,IAAiB,MAAU,KAAK,EAAI,CAAK,GAAG,EAAE,WAAW,GAAK,CAAC,GACrE,EAAgB,CAAc;EAE9B,IAAM,IAAY,QAAgB,EAAM,UAAU,YAAY,KAAA,IAAY,EAAM,KAAM,GAEhF,IAAc,QAAe,EAAe,OAAO,QAAQ,EAAE,GAC7D,IAAM,QAAe,EAAQ,EAAY,KAAK,CAAC,CAAC,YAAY,CAAC,GAC7D,IAAW,QAAe,EAAG,OAAO,WAAW,IAAI;yBAIvD,EAuBM,OAAA;GAvBD,OAAM;GAAc,cAAY,EAAA;MACnC,EASS,UATT,GASS;GARP,EAGO,QAAA;IAHD,OAAM;IAAa,OAAO,EAAA;OAC9B,EAA+C,QAA/C,GAA+C,EAArB,EAAA,KAAW,GAAA,CAAA,GACzB,EAAA,SAAA,EAAA,GAAZ,EAAmD,QAAnD,GAAiC,OAAE,EAAG,EAAA,KAAG,GAAA,CAAA,KAAA,EAAA,IAAA,EAAA,CAAA,GAAA,GAAA,CAAA;GAElC,EAAA,OAAI,OAAG,CAAK,EAAA,SAAA,EAAA,GAArB,EAEC,KAAA;;IAF8B,OAAM;IAAW,MAAM,EAAA,MAAG;IAAM,UAAU,EAAA,MAAG;IAAM,OAAM;MACrF,MAAE,GAAA,CAAA,KAAA,EAAA,IAAA,EAAA;GAEL,EAAmF,UAAA;IAA3E,MAAK;IAAS,OAAM;IAAU,OAAM;IAAM,SAAK,AAAA,EAAA,QAAA,MAAE,EAAI,OAAA;MAAW,IAAE;MAG5E,EAUM,OAVN,GAUM,CATK,EAAA,SAAA,EAAA,GAAT,EAA4C,KAA5C,GAAoC,MAAI,KAC1B,EAAA,SAAA,EAAA,GAAd,EAA2E,KAA3E,GAA2E,EAAjB,EAAA,KAAU,GAAA,CAAA,KAEvD,EAAA,SAAA,EAAA,GADb,EAKE,GAAA;;GAHC,MAAM,EAAA;GACN,UAAM,AAAA,EAAA,QAAA,MAAE,EAAI,UAAW,CAAM;GAC7B,SAAK,AAAA,EAAA,QAAA,MAAE,EAAI,SAAU,CAAM;iCAE9B,EAAoC,KAApC,GAA0B,QAAM,EAAA,CAAA,CAAA,GAAA,GAAA,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AppxViewer.js","names":[],"sources":["../../src/components/AppxViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'\nimport { parseAppx, NotAppxError, type AppxInfo } from '@/lib/appx'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * The side viewer's APPX / MSIX mode: fetch a Windows app package, unzip its manifest in-browser (see\n * lib/appx.ts — an APPX is an OPC zip, decompressed via the project's own ZIP reader), and surface the\n * identity (name / version / publisher), dependencies, declared capabilities, applications and languages.\n * Mirrors BinObjBrowser / MsiViewer: the bytes come from the same download endpoint as any other file and\n * never leave the browser. An APPX is neither a PE nor an MSI — different container, hence a separate viewer.\n *\n * Large-file gate: a package can be hundreds of MB; unless the manifest size is known to be small\n * (≤ LARGE_THRESHOLD) we show a confirm card and only fetch on click.\n */\nconst props = defineProps<{ url: string; name: string; size?: number }>()\n\nconst LARGE_THRESHOLD = 8 * 1024 * 1024 // 8 MB\n\ntype Status = 'confirm' | 'loading' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst info = ref<AppxInfo | null>(null)\nconst showLangs = ref(false)\n\nfunction decideInitial(): void {\n info.value = null\n showLangs.value = false\n errorMsg.value = ''\n if (props.size != null && props.size <= LARGE_THRESHOLD) void load()\n else status.value = 'confirm'\n}\n\nasync function load(): Promise<void> {\n status.value = 'loading'\n errorMsg.value = ''\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n info.value = await parseAppx(await res.arrayBuffer())\n status.value = 'ready'\n } catch (e) {\n errorMsg.value =\n e instanceof NotAppxError ? e.message : e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nconst kindLabel = computed(() => (info.value?.kind === 'bundle' ? '捆绑包' : '应用包'))\n\n// `ms-resource:…` display names are localized resource references we can't resolve without the PRI — show\n// them with a hint instead of pretending they're literal text.\nfunction resourceHint(v: string | undefined): boolean {\n return !!v && v.toLowerCase().startsWith('ms-resource:')\n}\n\nconst identity = computed<{ label: string; value: string; hint?: boolean }[]>(() => {\n const c = info.value\n if (!c) return []\n const rows: { label: string; value: string; hint?: boolean }[] = []\n const add = (label: string, v?: string): void => {\n if (v) rows.push({ label, value: v, hint: resourceHint(v) })\n }\n add('显示名', c.displayName)\n add('名称', c.name)\n add('版本', c.version)\n add('架构', c.architecture)\n add('发布者', c.publisherDisplayName)\n add('发布者证书', c.publisher)\n return rows\n})\n\nconst copied = ref('')\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nasync function copyValue(v: string): Promise<void> {\n try {\n await navigator.clipboard?.writeText(v)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copied.value = v\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copied.value = ''), 1200)\n}\n\nonMounted(decideInitial)\nwatch(() => props.url, decideInitial)\nonBeforeUnmount(() => clearTimeout(copyTimer))\n</script>\n\n<template>\n <div class=\"apx\">\n <div v-if=\"status === 'confirm'\" class=\"apx__confirm\">\n <Icon name=\"archive\" :size=\"32\" />\n <p class=\"apx__confirm-title\" :title=\"name\">{{ name }}</p>\n <p class=\"apx__confirm-sub\">\n {{\n size != null ? `文件较大(${formatBytes(size)})` : '文件大小未知'\n }},解析需将整个文件下载到浏览器。\n </p>\n <button type=\"button\" class=\"apx__btn\" @click=\"load\">加载并解析清单</button>\n </div>\n\n <p v-else-if=\"status === 'loading'\" class=\"apx__msg\">正在解析应用包…</p>\n\n <div v-else-if=\"status === 'error'\" class=\"apx__msg apx__msg--error\">\n <p>{{ errorMsg }}</p>\n <button type=\"button\" class=\"apx__retry\" @click=\"load\">重试</button>\n </div>\n\n <template v-else-if=\"status === 'ready' && info\">\n <div class=\"apx__summary\">\n <span class=\"apx__fmt\">{{ kindLabel }}</span>\n <span v-if=\"info.version\">v{{ info.version }}</span>\n <span v-if=\"info.architecture\">{{ info.architecture }}</span>\n <span>{{ info.fileCount }} 个文件</span>\n </div>\n\n <!-- identity -->\n <div v-if=\"identity.length\" class=\"apx__card\">\n <h3 class=\"apx__h\">包标识</h3>\n <dl class=\"apx__dl\">\n <template v-for=\"(r, i) in identity\" :key=\"i\">\n <dt class=\"apx__dt\">{{ r.label }}</dt>\n <dd class=\"apx__dd\">\n <button\n type=\"button\"\n class=\"apx__copyable\"\n :title=\"`点击复制:${r.value}`\"\n @click=\"copyValue(r.value)\"\n >\n <span>{{ r.value }}</span>\n <span v-if=\"r.hint\" class=\"apx__tag\">资源引用</span>\n <span v-if=\"copied === r.value\" class=\"apx__copied\">已复制</span>\n </button>\n </dd>\n </template>\n </dl>\n </div>\n\n <!-- bundle: contained packages -->\n <div v-if=\"info.bundlePackages.length\" class=\"apx__card\">\n <h3 class=\"apx__h\">包含的包({{ info.bundlePackages.length }})</h3>\n <div class=\"apx__deps\">\n <div v-for=\"(p, i) in info.bundlePackages\" :key=\"i\" class=\"apx__dep\">\n <span class=\"apx__depname\">{{ p.fileName || p.architecture || p.type }}</span>\n <span class=\"apx__depver\">\n <span v-if=\"p.architecture\" class=\"apx__chip\">{{ p.architecture }}</span>\n <span v-if=\"p.type\">{{ p.type }}</span>\n <span v-if=\"p.version\">v{{ p.version }}</span>\n </span>\n </div>\n </div>\n </div>\n\n <!-- applications -->\n <div v-if=\"info.applications.length\" class=\"apx__card\">\n <h3 class=\"apx__h\">应用({{ info.applications.length }})</h3>\n <div class=\"apx__deps\">\n <div v-for=\"(a, i) in info.applications\" :key=\"i\" class=\"apx__dep\">\n <span class=\"apx__depname\">{{ a.id || '(无 Id)' }}</span>\n <span v-if=\"a.displayName\" class=\"apx__depver\">{{ a.displayName }}</span>\n </div>\n </div>\n </div>\n\n <!-- dependencies -->\n <div\n v-if=\"info.targetDeviceFamilies.length || info.packageDependencies.length\"\n class=\"apx__card\"\n >\n <h3 class=\"apx__h\">依赖</h3>\n <div class=\"apx__deps\">\n <div v-for=\"(d, i) in info.targetDeviceFamilies\" :key=\"'t' + i\" class=\"apx__dep\">\n <span class=\"apx__depname\">{{ d.name }}</span>\n <span class=\"apx__depver\">\n <span class=\"apx__chip\">系统</span>\n <span v-if=\"d.minVersion\">≥ {{ d.minVersion }}</span>\n <span v-if=\"d.maxVersionTested\" class=\"apx__depmuted\"\n >测试至 {{ d.maxVersionTested }}</span\n >\n </span>\n </div>\n <div v-for=\"(d, i) in info.packageDependencies\" :key=\"'p' + i\" class=\"apx__dep\">\n <span class=\"apx__depname\">{{ d.name }}</span>\n <span class=\"apx__depver\">\n <span class=\"apx__chip\">包</span>\n <span v-if=\"d.minVersion\">≥ {{ d.minVersion }}</span>\n </span>\n </div>\n </div>\n </div>\n\n <!-- capabilities -->\n <div v-if=\"info.capabilities.length\" class=\"apx__card\">\n <h3 class=\"apx__h\">能力({{ info.capabilities.length }})</h3>\n <div class=\"apx__tables\">\n <span v-for=\"c in info.capabilities\" :key=\"c\" class=\"apx__chip\">{{ c }}</span>\n </div>\n </div>\n\n <!-- languages (collapsible — often dozens) -->\n <div v-if=\"info.languages.length\" class=\"apx__section\">\n <button type=\"button\" class=\"apx__toggle\" @click=\"showLangs = !showLangs\">\n <span class=\"apx__caret\" :class=\"{ 'apx__caret--open': showLangs }\">▶</span>\n 语言({{ info.languages.length }})\n </button>\n <div v-if=\"showLangs\" class=\"apx__tables apx__tables--pad\">\n <span v-for=\"l in info.languages\" :key=\"l\" class=\"apx__chip\">{{ l }}</span>\n </div>\n </div>\n\n <p v-for=\"(n, i) in info.notes\" :key=\"i\" class=\"apx__note\">{{ n }}</p>\n </template>\n </div>\n</template>\n\n<style scoped>\n.apx {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n overflow: auto;\n overscroll-behavior: contain;\n background: var(--surface);\n}\n\n.apx__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.apx__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.apx__retry {\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.apx__retry:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.apx__confirm {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n padding: 32px 28px;\n text-align: center;\n color: var(--muted);\n}\n.apx__confirm-title {\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13.5px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.apx__confirm-sub {\n font-size: 12.5px;\n line-height: 1.6;\n color: var(--muted);\n}\n.apx__btn {\n margin-top: 4px;\n font-family: inherit;\n font-size: 13px;\n color: #fff;\n background: var(--accent);\n border: none;\n border-radius: 8px;\n padding: 8px 18px;\n cursor: pointer;\n}\n.apx__btn:hover {\n filter: brightness(1.06);\n}\n\n.apx__summary {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 6px 12px;\n padding: 9px 16px;\n border-bottom: 1px solid var(--border);\n font-size: 11.5px;\n letter-spacing: 0.02em;\n color: var(--faint);\n}\n.apx__fmt {\n font-weight: 700;\n color: var(--accent);\n}\n\n.apx__card {\n flex-shrink: 0;\n padding: 12px 16px;\n border-bottom: 1px solid var(--border);\n}\n.apx__h {\n margin: 0 0 8px;\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.08em;\n color: var(--faint);\n text-transform: uppercase;\n}\n.apx__dl {\n margin: 0;\n display: grid;\n grid-template-columns: auto minmax(0, 1fr);\n gap: 4px 12px;\n align-content: start;\n}\n.apx__dt {\n color: var(--faint);\n font-size: 12px;\n white-space: nowrap;\n}\n.apx__dd {\n margin: 0;\n min-width: 0;\n font-size: 12.5px;\n color: var(--text);\n}\n.apx__copyable {\n display: inline-flex;\n align-items: baseline;\n flex-wrap: wrap;\n gap: 6px;\n max-width: 100%;\n padding: 0;\n background: transparent;\n border: none;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n text-align: left;\n cursor: pointer;\n overflow-wrap: anywhere;\n}\n.apx__copyable:hover {\n color: var(--accent);\n}\n.apx__tag {\n flex-shrink: 0;\n padding: 0 6px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--faint);\n font-size: 10px;\n}\n.apx__copied {\n flex-shrink: 0;\n font-size: 10.5px;\n color: var(--accent);\n}\n\n.apx__deps {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n.apx__dep {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 12px;\n flex-wrap: wrap;\n}\n.apx__depname {\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--text);\n overflow-wrap: anywhere;\n}\n.apx__depver {\n display: inline-flex;\n align-items: baseline;\n gap: 8px;\n font-size: 11.5px;\n color: var(--muted);\n font-variant-numeric: tabular-nums;\n}\n.apx__depmuted {\n color: var(--faint);\n}\n\n.apx__tables {\n display: flex;\n flex-wrap: wrap;\n gap: 5px;\n}\n.apx__tables--pad {\n padding: 2px 16px 12px;\n}\n.apx__chip {\n padding: 2px 9px;\n border-radius: 999px;\n background: var(--surface-2);\n border: 1px solid var(--border);\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--muted);\n}\n\n.apx__section {\n flex-shrink: 0;\n border-bottom: 1px solid var(--border);\n}\n.apx__toggle {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 10px 16px;\n background: transparent;\n border: none;\n font-family: inherit;\n font-size: 12px;\n font-weight: 600;\n color: var(--muted);\n text-align: left;\n cursor: pointer;\n}\n.apx__toggle:hover {\n background: var(--surface-2);\n}\n.apx__caret {\n flex-shrink: 0;\n width: 9px;\n font-size: 9px;\n color: var(--faint);\n transition: transform 0.12s;\n}\n.apx__caret--open {\n transform: rotate(90deg);\n}\n\n.apx__note {\n flex-shrink: 0;\n margin: 0;\n padding: 10px 16px;\n font-size: 12px;\n color: var(--muted);\n background: var(--surface-2);\n}\n</style>\n"],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AppxViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/AppxViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'\nimport { parseAppx, NotAppxError, type AppxInfo } from '@/lib/appx'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * The side viewer's APPX / MSIX mode: fetch a Windows app package, unzip its manifest in-browser (see\n * lib/appx.ts — an APPX is an OPC zip, decompressed via the project's own ZIP reader), and surface the\n * identity (name / version / publisher), dependencies, declared capabilities, applications and languages.\n * Mirrors BinObjBrowser / MsiViewer: the bytes come from the same download endpoint as any other file and\n * never leave the browser. An APPX is neither a PE nor an MSI — different container, hence a separate viewer.\n *\n * Large-file gate: a package can be hundreds of MB; unless the manifest size is known to be small\n * (≤ LARGE_THRESHOLD) we show a confirm card and only fetch on click.\n */\nconst props = defineProps<{ url: string; name: string; size?: number }>()\n\nconst LARGE_THRESHOLD = 8 * 1024 * 1024 // 8 MB\n\ntype Status = 'confirm' | 'loading' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst info = ref<AppxInfo | null>(null)\nconst showLangs = ref(false)\n\nfunction decideInitial(): void {\n info.value = null\n showLangs.value = false\n errorMsg.value = ''\n if (props.size != null && props.size <= LARGE_THRESHOLD) void load()\n else status.value = 'confirm'\n}\n\nasync function load(): Promise<void> {\n status.value = 'loading'\n errorMsg.value = ''\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n info.value = await parseAppx(await res.arrayBuffer())\n status.value = 'ready'\n } catch (e) {\n errorMsg.value =\n e instanceof NotAppxError ? e.message : e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nconst kindLabel = computed(() => (info.value?.kind === 'bundle' ? '捆绑包' : '应用包'))\n\n// `ms-resource:…` display names are localized resource references we can't resolve without the PRI — show\n// them with a hint instead of pretending they're literal text.\nfunction resourceHint(v: string | undefined): boolean {\n return !!v && v.toLowerCase().startsWith('ms-resource:')\n}\n\nconst identity = computed<{ label: string; value: string; hint?: boolean }[]>(() => {\n const c = info.value\n if (!c) return []\n const rows: { label: string; value: string; hint?: boolean }[] = []\n const add = (label: string, v?: string): void => {\n if (v) rows.push({ label, value: v, hint: resourceHint(v) })\n }\n add('显示名', c.displayName)\n add('名称', c.name)\n add('版本', c.version)\n add('架构', c.architecture)\n add('发布者', c.publisherDisplayName)\n add('发布者证书', c.publisher)\n return rows\n})\n\nconst copied = ref('')\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nasync function copyValue(v: string): Promise<void> {\n try {\n await navigator.clipboard?.writeText(v)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copied.value = v\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copied.value = ''), 1200)\n}\n\nonMounted(decideInitial)\nwatch(() => props.url, decideInitial)\nonBeforeUnmount(() => clearTimeout(copyTimer))\n</script>\n\n<template>\n <div class=\"apx\">\n <div v-if=\"status === 'confirm'\" class=\"apx__confirm\">\n <Icon name=\"archive\" :size=\"32\" />\n <p class=\"apx__confirm-title\" :title=\"name\">{{ name }}</p>\n <p class=\"apx__confirm-sub\">\n {{\n size != null ? `文件较大(${formatBytes(size)})` : '文件大小未知'\n }},解析需将整个文件下载到浏览器。\n </p>\n <button type=\"button\" class=\"apx__btn\" @click=\"load\">加载并解析清单</button>\n </div>\n\n <p v-else-if=\"status === 'loading'\" class=\"apx__msg\">正在解析应用包…</p>\n\n <div v-else-if=\"status === 'error'\" class=\"apx__msg apx__msg--error\">\n <p>{{ errorMsg }}</p>\n <button type=\"button\" class=\"apx__retry\" @click=\"load\">重试</button>\n </div>\n\n <template v-else-if=\"status === 'ready' && info\">\n <div class=\"apx__summary\">\n <span class=\"apx__fmt\">{{ kindLabel }}</span>\n <span v-if=\"info.version\">v{{ info.version }}</span>\n <span v-if=\"info.architecture\">{{ info.architecture }}</span>\n <span>{{ info.fileCount }} 个文件</span>\n </div>\n\n <!-- identity -->\n <div v-if=\"identity.length\" class=\"apx__card\">\n <h3 class=\"apx__h\">包标识</h3>\n <dl class=\"apx__dl\">\n <template v-for=\"(r, i) in identity\" :key=\"i\">\n <dt class=\"apx__dt\">{{ r.label }}</dt>\n <dd class=\"apx__dd\">\n <button\n type=\"button\"\n class=\"apx__copyable\"\n :title=\"`点击复制:${r.value}`\"\n @click=\"copyValue(r.value)\"\n >\n <span>{{ r.value }}</span>\n <span v-if=\"r.hint\" class=\"apx__tag\">资源引用</span>\n <span v-if=\"copied === r.value\" class=\"apx__copied\">已复制</span>\n </button>\n </dd>\n </template>\n </dl>\n </div>\n\n <!-- bundle: contained packages -->\n <div v-if=\"info.bundlePackages.length\" class=\"apx__card\">\n <h3 class=\"apx__h\">包含的包({{ info.bundlePackages.length }})</h3>\n <div class=\"apx__deps\">\n <div v-for=\"(p, i) in info.bundlePackages\" :key=\"i\" class=\"apx__dep\">\n <span class=\"apx__depname\">{{ p.fileName || p.architecture || p.type }}</span>\n <span class=\"apx__depver\">\n <span v-if=\"p.architecture\" class=\"apx__chip\">{{ p.architecture }}</span>\n <span v-if=\"p.type\">{{ p.type }}</span>\n <span v-if=\"p.version\">v{{ p.version }}</span>\n </span>\n </div>\n </div>\n </div>\n\n <!-- applications -->\n <div v-if=\"info.applications.length\" class=\"apx__card\">\n <h3 class=\"apx__h\">应用({{ info.applications.length }})</h3>\n <div class=\"apx__deps\">\n <div v-for=\"(a, i) in info.applications\" :key=\"i\" class=\"apx__dep\">\n <span class=\"apx__depname\">{{ a.id || '(无 Id)' }}</span>\n <span v-if=\"a.displayName\" class=\"apx__depver\">{{ a.displayName }}</span>\n </div>\n </div>\n </div>\n\n <!-- dependencies -->\n <div\n v-if=\"info.targetDeviceFamilies.length || info.packageDependencies.length\"\n class=\"apx__card\"\n >\n <h3 class=\"apx__h\">依赖</h3>\n <div class=\"apx__deps\">\n <div v-for=\"(d, i) in info.targetDeviceFamilies\" :key=\"'t' + i\" class=\"apx__dep\">\n <span class=\"apx__depname\">{{ d.name }}</span>\n <span class=\"apx__depver\">\n <span class=\"apx__chip\">系统</span>\n <span v-if=\"d.minVersion\">≥ {{ d.minVersion }}</span>\n <span v-if=\"d.maxVersionTested\" class=\"apx__depmuted\"\n >测试至 {{ d.maxVersionTested }}</span\n >\n </span>\n </div>\n <div v-for=\"(d, i) in info.packageDependencies\" :key=\"'p' + i\" class=\"apx__dep\">\n <span class=\"apx__depname\">{{ d.name }}</span>\n <span class=\"apx__depver\">\n <span class=\"apx__chip\">包</span>\n <span v-if=\"d.minVersion\">≥ {{ d.minVersion }}</span>\n </span>\n </div>\n </div>\n </div>\n\n <!-- capabilities -->\n <div v-if=\"info.capabilities.length\" class=\"apx__card\">\n <h3 class=\"apx__h\">能力({{ info.capabilities.length }})</h3>\n <div class=\"apx__tables\">\n <span v-for=\"c in info.capabilities\" :key=\"c\" class=\"apx__chip\">{{ c }}</span>\n </div>\n </div>\n\n <!-- languages (collapsible — often dozens) -->\n <div v-if=\"info.languages.length\" class=\"apx__section\">\n <button type=\"button\" class=\"apx__toggle\" @click=\"showLangs = !showLangs\">\n <span class=\"apx__caret\" :class=\"{ 'apx__caret--open': showLangs }\">▶</span>\n 语言({{ info.languages.length }})\n </button>\n <div v-if=\"showLangs\" class=\"apx__tables apx__tables--pad\">\n <span v-for=\"l in info.languages\" :key=\"l\" class=\"apx__chip\">{{ l }}</span>\n </div>\n </div>\n\n <p v-for=\"(n, i) in info.notes\" :key=\"i\" class=\"apx__note\">{{ n }}</p>\n </template>\n </div>\n</template>\n\n<style scoped>\n.apx {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n overflow: auto;\n overscroll-behavior: contain;\n background: var(--surface);\n}\n\n.apx__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.apx__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.apx__retry {\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.apx__retry:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.apx__confirm {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n padding: 32px 28px;\n text-align: center;\n color: var(--muted);\n}\n.apx__confirm-title {\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13.5px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.apx__confirm-sub {\n font-size: 12.5px;\n line-height: 1.6;\n color: var(--muted);\n}\n.apx__btn {\n margin-top: 4px;\n font-family: inherit;\n font-size: 13px;\n color: #fff;\n background: var(--accent);\n border: none;\n border-radius: 8px;\n padding: 8px 18px;\n cursor: pointer;\n}\n.apx__btn:hover {\n filter: brightness(1.06);\n}\n\n.apx__summary {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 6px 12px;\n padding: 9px 16px;\n border-bottom: 1px solid var(--border);\n font-size: 11.5px;\n letter-spacing: 0.02em;\n color: var(--faint);\n}\n.apx__fmt {\n font-weight: 700;\n color: var(--accent);\n}\n\n.apx__card {\n flex-shrink: 0;\n padding: 12px 16px;\n border-bottom: 1px solid var(--border);\n}\n.apx__h {\n margin: 0 0 8px;\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.08em;\n color: var(--faint);\n text-transform: uppercase;\n}\n.apx__dl {\n margin: 0;\n display: grid;\n grid-template-columns: auto minmax(0, 1fr);\n gap: 4px 12px;\n align-content: start;\n}\n.apx__dt {\n color: var(--faint);\n font-size: 12px;\n white-space: nowrap;\n}\n.apx__dd {\n margin: 0;\n min-width: 0;\n font-size: 12.5px;\n color: var(--text);\n}\n.apx__copyable {\n display: inline-flex;\n align-items: baseline;\n flex-wrap: wrap;\n gap: 6px;\n max-width: 100%;\n padding: 0;\n background: transparent;\n border: none;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n text-align: left;\n cursor: pointer;\n overflow-wrap: anywhere;\n}\n.apx__copyable:hover {\n color: var(--accent);\n}\n.apx__tag {\n flex-shrink: 0;\n padding: 0 6px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--faint);\n font-size: 10px;\n}\n.apx__copied {\n flex-shrink: 0;\n font-size: 10.5px;\n color: var(--accent);\n}\n\n.apx__deps {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n.apx__dep {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 12px;\n flex-wrap: wrap;\n}\n.apx__depname {\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--text);\n overflow-wrap: anywhere;\n}\n.apx__depver {\n display: inline-flex;\n align-items: baseline;\n gap: 8px;\n font-size: 11.5px;\n color: var(--muted);\n font-variant-numeric: tabular-nums;\n}\n.apx__depmuted {\n color: var(--faint);\n}\n\n.apx__tables {\n display: flex;\n flex-wrap: wrap;\n gap: 5px;\n}\n.apx__tables--pad {\n padding: 2px 16px 12px;\n}\n.apx__chip {\n padding: 2px 9px;\n border-radius: 999px;\n background: var(--surface-2);\n border: 1px solid var(--border);\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--muted);\n}\n\n.apx__section {\n flex-shrink: 0;\n border-bottom: 1px solid var(--border);\n}\n.apx__toggle {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 10px 16px;\n background: transparent;\n border: none;\n font-family: inherit;\n font-size: 12px;\n font-weight: 600;\n color: var(--muted);\n text-align: left;\n cursor: pointer;\n}\n.apx__toggle:hover {\n background: var(--surface-2);\n}\n.apx__caret {\n flex-shrink: 0;\n width: 9px;\n font-size: 9px;\n color: var(--faint);\n transition: transform 0.12s;\n}\n.apx__caret--open {\n transform: rotate(90deg);\n}\n\n.apx__note {\n flex-shrink: 0;\n margin: 0;\n padding: 10px 16px;\n font-size: 12px;\n color: var(--muted);\n background: var(--surface-2);\n}\n</style>\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ArchiveBrowser.js","names":[],"sources":["../../src/components/ArchiveBrowser.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport {\n computed,\n defineAsyncComponent,\n onBeforeUnmount,\n onMounted,\n reactive,\n ref,\n watch,\n} from 'vue'\nimport type { ArchiveEntry, OpenedArchive } from '@/lib/archive'\nimport type { DicomSlice } from '@/lib/dicom'\nimport type { ViewerFile } from '@/composables/useFileViewer'\nimport { fileExt, isDicomFile, isTextFile } from '@/lib/fileType'\nimport { canToggleView, classifyViewerFile, mediaMimeForName } from '@/lib/viewerKind'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n// A folder of sibling .dcm files browses as one scrollable series — handled directly (the body has no slice model).\nconst DicomViewer = defineAsyncComponent(() => import('@/components/DicomViewer.vue'))\n// The shared side-viewer body renders a selected entry with the SAME viewers as a top-level file. Lazy to break\n// the module cycle (the body lazy-imports this browser back, for nested archives).\nconst FileViewerBody = defineAsyncComponent(() => import('@/components/FileViewerBody.vue'))\n\n/**\n * The side viewer's generic archive mode — the multi-file browser shared by the tar family (tar/gz/xz/bz2) and\n * rar. Same master→detail UX as {@link ZipBrowser}: a collapsible file tree; selecting an entry extracts just\n * that one, wraps it in a blob URL, and hands it to {@link FileViewerBody} (so a file inside the archive\n * previews with every viewer a top-level file gets).\n *\n * The format-specific work lives behind {@link OpenedArchive}: this component fetches the bytes, lazy-imports\n * the right opener by `kind` (so neither the tar codecs nor the rar WASM ship in the main bundle), and drives\n * everything through `entries` + `extract()`. Extraction may be sync (tar) or async (rar) — every call is awaited.\n */\nconst props = defineProps<{ url: string; name: string; kind: 'tar' | 'rar' }>()\n\ntype Status = 'loading' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nlet archive: OpenedArchive | null = null\nconst entries = ref<ArchiveEntry[]>([])\n\ninterface TreeNode {\n name: string\n path: string\n isDir: boolean\n size: number\n entry?: ArchiveEntry\n children: TreeNode[]\n}\nconst root = ref<TreeNode>({ name: '', path: '', isDir: true, size: 0, children: [] })\nconst expanded = reactive(new Set<string>())\n\nconst fileCount = computed(() => entries.value.reduce((n, e) => n + (e.isDir ? 0 : 1), 0))\nconst totalSize = computed(() => entries.value.reduce((s, e) => s + (e.isDir ? 0 : e.size), 0))\n\n// Build a folder tree from the flat entry paths (directories may be implicit — derive them either way).\nfunction buildTree(list: ArchiveEntry[]): TreeNode {\n const rootNode: TreeNode = { name: '', path: '', isDir: true, size: 0, children: [] }\n const dirs = new Map<string, TreeNode>([['', rootNode]])\n const ensureDir = (segs: string[]): TreeNode => {\n let cur = rootNode\n let acc = ''\n for (const seg of segs) {\n acc = acc ? `${acc}/${seg}` : seg\n let node = dirs.get(acc)\n if (!node) {\n node = { name: seg, path: acc, isDir: true, size: 0, children: [] }\n dirs.set(acc, node)\n cur.children.push(node)\n }\n cur = node\n }\n return cur\n }\n for (const e of list) {\n const segs = e.path.split('/').filter(Boolean)\n if (e.isDir) {\n ensureDir(segs)\n continue\n }\n const fname = segs.pop()\n if (!fname) continue\n ensureDir(segs).children.push({\n name: fname,\n path: e.path,\n isDir: false,\n size: e.size,\n entry: e,\n children: [],\n })\n }\n sortNode(rootNode)\n return rootNode\n}\n// Folders first, then files, each alphabetical (case-insensitive).\nfunction sortNode(n: TreeNode): void {\n n.children.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1))\n for (const c of n.children) if (c.isDir) sortNode(c)\n}\n\n// Flatten the tree to the rows currently visible (respecting which folders are expanded).\ninterface Row {\n node: TreeNode\n depth: number\n}\nconst rows = computed<Row[]>(() => {\n const out: Row[] = []\n const walk = (nodes: TreeNode[], depth: number): void => {\n for (const n of nodes) {\n out.push({ node: n, depth })\n if (n.isDir && expanded.has(n.path)) walk(n.children, depth + 1)\n }\n }\n walk(root.value.children, 0)\n return out\n})\n\nasync function load(): Promise<void> {\n status.value = 'loading'\n errorMsg.value = ''\n selected.value = null\n archive = null\n revokeBlob()\n stackSlices.value = []\n const url = props.url\n try {\n const res = await fetch(url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buffer = await res.arrayBuffer()\n // Lazy-import the opener so the tar codecs / rar WASM stay out of the main bundle until needed.\n const opened =\n props.kind === 'rar'\n ? await (await import('@/lib/rar')).openRar(buffer)\n : await (await import('@/lib/tarball')).openTarball(buffer, props.name)\n if (props.url !== url) return // a newer file superseded us while decoding\n archive = opened\n entries.value = opened.entries\n root.value = buildTree(opened.entries)\n expanded.clear()\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\n// ── selected entry (detail view) ──────────────────────────────────────────────────────────────\nconst selected = ref<ArchiveEntry | null>(null)\nconst entryStatus = ref<Status>('ready')\nconst entryErr = ref('')\nconst entryText = ref('')\nconst entryFile = ref<ViewerFile | null>(null)\nconst entryStack = ref(false)\nconst entryMode = ref<'render' | 'source'>('render')\nconst blobUrl = ref('')\n\n// DICOM series stack: a folder of .dcm slices browsed as one scrollable series (see ZipBrowser).\nconst stackSlices = ref<DicomSlice[]>([])\nconst stackStart = ref(0)\nconst stackName = ref('')\nconst stackTotalSize = computed(() => stackSlices.value.reduce((s, sl) => s + (sl.size ?? 0), 0))\n\nconst canToggle = computed(() => canToggleView(entryFile.value, entryText.value))\n\nfunction revokeBlob(): void {\n if (blobUrl.value) {\n URL.revokeObjectURL(blobUrl.value)\n blobUrl.value = ''\n }\n}\n\nfunction dirOf(path: string): string {\n const i = path.lastIndexOf('/')\n return i < 0 ? '' : path.slice(0, i)\n}\n// Sibling .dcm files in the same folder, natural-sorted (numeric-aware) so e.g. img2 precedes img10.\nfunction dicomSiblings(e: ArchiveEntry): ArchiveEntry[] {\n const dir = dirOf(e.path)\n return entries.value\n .filter((x) => !x.isDir && isDicomFile(x.name) && dirOf(x.path) === dir)\n .sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' }))\n}\n\n// Treat as text if the name says so; for extensionless entries, sniff the first bytes for NULs.\nfunction looksText(bytes: Uint8Array, name: string): boolean {\n if (isTextFile(name)) return true\n if (fileExt(name)) return false\n const n = Math.min(bytes.length, 4096)\n for (let i = 0; i < n; i++) if (bytes[i] === 0) return false\n return true\n}\n\n// Copy out a freshly-allocated ArrayBuffer (a nested viewer/blob must own its bytes, not alias the archive).\nfunction toArrayBuffer(u: Uint8Array): ArrayBuffer {\n const out = new ArrayBuffer(u.byteLength)\n new Uint8Array(out).set(u)\n return out\n}\n\nasync function selectEntry(e: ArchiveEntry): Promise<void> {\n selected.value = e\n entryStatus.value = 'loading'\n entryErr.value = ''\n entryText.value = ''\n entryFile.value = null\n entryStack.value = false\n entryMode.value = 'render'\n revokeBlob()\n stackSlices.value = []\n if (!archive) return\n const arc = archive\n try {\n if (isDicomFile(e.name)) {\n const siblings = dicomSiblings(e)\n if (siblings.length > 1) {\n stackSlices.value = siblings.map((se) => ({\n name: se.name,\n size: se.size,\n load: async () => toArrayBuffer(await arc.extract(se)),\n }))\n stackStart.value = Math.max(\n 0,\n siblings.findIndex((se) => se.path === e.path),\n )\n stackName.value = dirOf(e.path).split('/').pop() || e.name\n entryStack.value = true\n entryStatus.value = 'ready'\n return\n }\n }\n const bytes = await arc.extract(e)\n if (selected.value !== e) return // a newer selection superseded us mid-extract\n blobUrl.value = URL.createObjectURL(new Blob([bytes], { type: mediaMimeForName(e.name) }))\n entryFile.value = classifyViewerFile({\n name: e.name,\n url: blobUrl.value,\n size: e.size,\n textHint: looksText(bytes, e.name),\n })\n entryStatus.value = 'ready'\n } catch (err) {\n entryErr.value = err instanceof Error ? err.message : String(err)\n entryStatus.value = 'error'\n }\n}\n\nfunction onRow(node: TreeNode): void {\n if (node.isDir) {\n if (expanded.has(node.path)) expanded.delete(node.path)\n else expanded.add(node.path)\n } else if (node.entry) {\n void selectEntry(node.entry)\n }\n}\n\nfunction back(): void {\n selected.value = null\n entryText.value = ''\n entryFile.value = null\n entryStack.value = false\n revokeBlob()\n stackSlices.value = []\n}\n\nasync function downloadEntry(e: ArchiveEntry): Promise<void> {\n if (!archive) return\n try {\n const url = URL.createObjectURL(new Blob([await archive.extract(e)]))\n const a = document.createElement('a')\n a.href = url\n a.download = e.name\n document.body.appendChild(a)\n a.click()\n a.remove()\n setTimeout(() => URL.revokeObjectURL(url), 1000)\n } catch {\n /* extraction failed — nothing to download */\n }\n}\n\nconst copied = ref(false)\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nasync function copyEntry(): Promise<void> {\n try {\n await navigator.clipboard?.writeText(entryText.value)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copied.value = true\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copied.value = false), 1400)\n}\n\nonMounted(load)\nwatch(() => props.url, load)\nonBeforeUnmount(() => {\n revokeBlob()\n clearTimeout(copyTimer)\n})\n</script>\n\n<template>\n <div class=\"ar\">\n <p v-if=\"status === 'loading'\" class=\"ar__msg\">正在解压…</p>\n\n <div v-else-if=\"status === 'error'\" class=\"ar__msg ar__msg--error\">\n <p>{{ errorMsg }}</p>\n <button type=\"button\" class=\"ar__retry\" @click=\"load\">重试</button>\n </div>\n\n <!-- detail: one selected entry -->\n <template v-else-if=\"selected\">\n <div class=\"ar__bar\">\n <button type=\"button\" class=\"ar__back\" title=\"返回列表\" @click=\"back\">\n <Icon name=\"arrowLeft\" :size=\"16\" />\n </button>\n <span class=\"ar__path\" :title=\"selected.path\">{{ selected.path }}</span>\n <span class=\"ar__bsize\">{{ formatBytes(selected.size) }}</span>\n <div v-if=\"canToggle\" class=\"ar__view\" role=\"group\" aria-label=\"渲染或源码\">\n <button\n type=\"button\"\n class=\"ar__vbtn\"\n :class=\"{ 'ar__vbtn--on': entryMode === 'render' }\"\n title=\"渲染\"\n @click=\"entryMode = 'render'\"\n >\n <Icon name=\"eye\" :size=\"14\" />\n </button>\n <button\n type=\"button\"\n class=\"ar__vbtn\"\n :class=\"{ 'ar__vbtn--on': entryMode === 'source' }\"\n title=\"源码\"\n @click=\"entryMode = 'source'\"\n >\n <Icon name=\"code\" :size=\"14\" />\n </button>\n </div>\n <button\n v-if=\"entryText && entryStatus === 'ready'\"\n type=\"button\"\n class=\"ar__act\"\n :title=\"copied ? '已复制' : '复制'\"\n @click=\"copyEntry\"\n >\n <Icon name=\"copy\" :size=\"14\" />\n </button>\n <button type=\"button\" class=\"ar__act\" title=\"下载\" @click=\"downloadEntry(selected)\">\n <Icon name=\"download\" :size=\"15\" />\n </button>\n </div>\n\n <p v-if=\"entryStatus === 'loading'\" class=\"ar__msg\">读取中…</p>\n <div v-else-if=\"entryStatus === 'error'\" class=\"ar__msg ar__msg--error\">\n <p>{{ entryErr }}</p>\n </div>\n\n <!-- dicom series: a whole folder of .dcm slices browsed as one scrollable series -->\n <DicomViewer\n v-else-if=\"entryStack\"\n :slices=\"stackSlices\"\n :start=\"stackStart\"\n :name=\"stackName\"\n :size=\"stackTotalSize\"\n />\n\n <!-- everything else: the shared body picks the right viewer from the extracted blob -->\n <FileViewerBody\n v-else-if=\"entryFile\"\n :file=\"entryFile\"\n :mode=\"entryMode\"\n @loaded=\"entryText = $event\"\n />\n </template>\n\n <!-- list: the file tree -->\n <template v-else>\n <div class=\"ar__summary\">{{ fileCount }} 个文件 · {{ formatBytes(totalSize) }}</div>\n <div class=\"ar__scroll ar__tree\">\n <p v-if=\"rows.length === 0\" class=\"ar__msg\">空压缩包</p>\n <button\n v-for=\"row in rows\"\n :key=\"row.node.path\"\n type=\"button\"\n class=\"ar__row\"\n :class=\"{ 'ar__row--dir': row.node.isDir }\"\n :style=\"{ paddingLeft: `${12 + row.depth * 14}px` }\"\n @click=\"onRow(row.node)\"\n >\n <span class=\"ar__twisty\">\n <Icon\n v-if=\"row.node.isDir\"\n name=\"chevronRight\"\n :size=\"13\"\n :class=\"{ 'ar__twisty--open': expanded.has(row.node.path) }\"\n />\n </span>\n <span class=\"ar__rowicon\">\n <Icon :name=\"row.node.isDir ? 'folder' : 'fileOutline'\" :size=\"15\" />\n </span>\n <span class=\"ar__rowname\">{{ row.node.name }}</span>\n <span v-if=\"!row.node.isDir\" class=\"ar__rowsize\">{{ formatBytes(row.node.size) }}</span>\n </button>\n </div>\n </template>\n </div>\n</template>\n\n<style scoped>\n.ar {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.ar__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.ar__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.ar__retry {\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.ar__retry:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* List view ------------------------------------------------------------------------------------ */\n.ar__summary {\n flex-shrink: 0;\n padding: 9px 16px;\n border-bottom: 1px solid var(--border);\n font-size: 11.5px;\n letter-spacing: 0.02em;\n color: var(--faint);\n}\n.ar__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n}\n.ar__tree {\n padding: 6px 0;\n}\n.ar__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 5px 12px 5px 12px;\n background: transparent;\n border: none;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n text-align: left;\n cursor: pointer;\n}\n.ar__row:hover {\n background: var(--surface-2);\n}\n.ar__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 13px;\n color: var(--faint);\n}\n.ar__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.ar__rowicon {\n flex-shrink: 0;\n display: inline-flex;\n color: var(--faint);\n}\n.ar__row--dir .ar__rowicon {\n color: var(--accent);\n}\n.ar__rowname {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.ar__row--dir .ar__rowname {\n font-weight: 500;\n}\n.ar__rowsize {\n flex-shrink: 0;\n font-size: 11px;\n color: var(--faint);\n font-variant-numeric: tabular-nums;\n}\n\n/* Detail bar ----------------------------------------------------------------------------------- */\n.ar__bar {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n padding: 7px 10px 7px 8px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.ar__back,\n.ar__act {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 30px;\n height: 28px;\n background: transparent;\n border: none;\n border-radius: 7px;\n color: var(--muted);\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.ar__back:hover,\n.ar__act:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.ar__path {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n direction: rtl;\n text-align: left;\n font-size: 12.5px;\n font-weight: 500;\n color: var(--text-strong);\n}\n.ar__bsize {\n flex-shrink: 0;\n font-size: 11px;\n color: var(--faint);\n font-variant-numeric: tabular-nums;\n}\n.ar__view {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n gap: 2px;\n padding: 2px;\n border-radius: 7px;\n background: var(--surface-2);\n}\n.ar__vbtn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 24px;\n padding: 0;\n background: transparent;\n border: none;\n border-radius: 5px;\n color: var(--faint);\n cursor: pointer;\n}\n.ar__vbtn--on {\n background: var(--surface);\n color: var(--accent);\n box-shadow: var(--shadow);\n}\n</style>\n"],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ArchiveBrowser.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/ArchiveBrowser.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport {\n computed,\n defineAsyncComponent,\n onBeforeUnmount,\n onMounted,\n reactive,\n ref,\n watch,\n} from 'vue'\nimport type { ArchiveEntry, OpenedArchive } from '@/lib/archive'\nimport type { DicomSlice } from '@/lib/dicom'\nimport type { ViewerFile } from '@/composables/useFileViewer'\nimport { fileExt, isDicomFile, isTextFile } from '@/lib/fileType'\nimport { canToggleView, classifyViewerFile, mediaMimeForName } from '@/lib/viewerKind'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n// A folder of sibling .dcm files browses as one scrollable series — handled directly (the body has no slice model).\nconst DicomViewer = defineAsyncComponent(() => import('@/components/DicomViewer.vue'))\n// The shared side-viewer body renders a selected entry with the SAME viewers as a top-level file. Lazy to break\n// the module cycle (the body lazy-imports this browser back, for nested archives).\nconst FileViewerBody = defineAsyncComponent(() => import('@/components/FileViewerBody.vue'))\n\n/**\n * The side viewer's generic archive mode — the multi-file browser shared by the tar family (tar/gz/xz/bz2) and\n * rar. Same master→detail UX as {@link ZipBrowser}: a collapsible file tree; selecting an entry extracts just\n * that one, wraps it in a blob URL, and hands it to {@link FileViewerBody} (so a file inside the archive\n * previews with every viewer a top-level file gets).\n *\n * The format-specific work lives behind {@link OpenedArchive}: this component fetches the bytes, lazy-imports\n * the right opener by `kind` (so neither the tar codecs nor the rar WASM ship in the main bundle), and drives\n * everything through `entries` + `extract()`. Extraction may be sync (tar) or async (rar) — every call is awaited.\n */\nconst props = defineProps<{ url: string; name: string; kind: 'tar' | 'rar' }>()\n\ntype Status = 'loading' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nlet archive: OpenedArchive | null = null\nconst entries = ref<ArchiveEntry[]>([])\n\ninterface TreeNode {\n name: string\n path: string\n isDir: boolean\n size: number\n entry?: ArchiveEntry\n children: TreeNode[]\n}\nconst root = ref<TreeNode>({ name: '', path: '', isDir: true, size: 0, children: [] })\nconst expanded = reactive(new Set<string>())\n\nconst fileCount = computed(() => entries.value.reduce((n, e) => n + (e.isDir ? 0 : 1), 0))\nconst totalSize = computed(() => entries.value.reduce((s, e) => s + (e.isDir ? 0 : e.size), 0))\n\n// Build a folder tree from the flat entry paths (directories may be implicit — derive them either way).\nfunction buildTree(list: ArchiveEntry[]): TreeNode {\n const rootNode: TreeNode = { name: '', path: '', isDir: true, size: 0, children: [] }\n const dirs = new Map<string, TreeNode>([['', rootNode]])\n const ensureDir = (segs: string[]): TreeNode => {\n let cur = rootNode\n let acc = ''\n for (const seg of segs) {\n acc = acc ? `${acc}/${seg}` : seg\n let node = dirs.get(acc)\n if (!node) {\n node = { name: seg, path: acc, isDir: true, size: 0, children: [] }\n dirs.set(acc, node)\n cur.children.push(node)\n }\n cur = node\n }\n return cur\n }\n for (const e of list) {\n const segs = e.path.split('/').filter(Boolean)\n if (e.isDir) {\n ensureDir(segs)\n continue\n }\n const fname = segs.pop()\n if (!fname) continue\n ensureDir(segs).children.push({\n name: fname,\n path: e.path,\n isDir: false,\n size: e.size,\n entry: e,\n children: [],\n })\n }\n sortNode(rootNode)\n return rootNode\n}\n// Folders first, then files, each alphabetical (case-insensitive).\nfunction sortNode(n: TreeNode): void {\n n.children.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1))\n for (const c of n.children) if (c.isDir) sortNode(c)\n}\n\n// Flatten the tree to the rows currently visible (respecting which folders are expanded).\ninterface Row {\n node: TreeNode\n depth: number\n}\nconst rows = computed<Row[]>(() => {\n const out: Row[] = []\n const walk = (nodes: TreeNode[], depth: number): void => {\n for (const n of nodes) {\n out.push({ node: n, depth })\n if (n.isDir && expanded.has(n.path)) walk(n.children, depth + 1)\n }\n }\n walk(root.value.children, 0)\n return out\n})\n\nasync function load(): Promise<void> {\n status.value = 'loading'\n errorMsg.value = ''\n selected.value = null\n archive = null\n revokeBlob()\n stackSlices.value = []\n const url = props.url\n try {\n const res = await fetch(url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buffer = await res.arrayBuffer()\n // Lazy-import the opener so the tar codecs / rar WASM stay out of the main bundle until needed.\n const opened =\n props.kind === 'rar'\n ? await (await import('@/lib/rar')).openRar(buffer)\n : await (await import('@/lib/tarball')).openTarball(buffer, props.name)\n if (props.url !== url) return // a newer file superseded us while decoding\n archive = opened\n entries.value = opened.entries\n root.value = buildTree(opened.entries)\n expanded.clear()\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\n// ── selected entry (detail view) ──────────────────────────────────────────────────────────────\nconst selected = ref<ArchiveEntry | null>(null)\nconst entryStatus = ref<Status>('ready')\nconst entryErr = ref('')\nconst entryText = ref('')\nconst entryFile = ref<ViewerFile | null>(null)\nconst entryStack = ref(false)\nconst entryMode = ref<'render' | 'source'>('render')\nconst blobUrl = ref('')\n\n// DICOM series stack: a folder of .dcm slices browsed as one scrollable series (see ZipBrowser).\nconst stackSlices = ref<DicomSlice[]>([])\nconst stackStart = ref(0)\nconst stackName = ref('')\nconst stackTotalSize = computed(() => stackSlices.value.reduce((s, sl) => s + (sl.size ?? 0), 0))\n\nconst canToggle = computed(() => canToggleView(entryFile.value, entryText.value))\n\nfunction revokeBlob(): void {\n if (blobUrl.value) {\n URL.revokeObjectURL(blobUrl.value)\n blobUrl.value = ''\n }\n}\n\nfunction dirOf(path: string): string {\n const i = path.lastIndexOf('/')\n return i < 0 ? '' : path.slice(0, i)\n}\n// Sibling .dcm files in the same folder, natural-sorted (numeric-aware) so e.g. img2 precedes img10.\nfunction dicomSiblings(e: ArchiveEntry): ArchiveEntry[] {\n const dir = dirOf(e.path)\n return entries.value\n .filter((x) => !x.isDir && isDicomFile(x.name) && dirOf(x.path) === dir)\n .sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' }))\n}\n\n// Treat as text if the name says so; for extensionless entries, sniff the first bytes for NULs.\nfunction looksText(bytes: Uint8Array, name: string): boolean {\n if (isTextFile(name)) return true\n if (fileExt(name)) return false\n const n = Math.min(bytes.length, 4096)\n for (let i = 0; i < n; i++) if (bytes[i] === 0) return false\n return true\n}\n\n// Copy out a freshly-allocated ArrayBuffer (a nested viewer/blob must own its bytes, not alias the archive).\nfunction toArrayBuffer(u: Uint8Array): ArrayBuffer {\n const out = new ArrayBuffer(u.byteLength)\n new Uint8Array(out).set(u)\n return out\n}\n\nasync function selectEntry(e: ArchiveEntry): Promise<void> {\n selected.value = e\n entryStatus.value = 'loading'\n entryErr.value = ''\n entryText.value = ''\n entryFile.value = null\n entryStack.value = false\n entryMode.value = 'render'\n revokeBlob()\n stackSlices.value = []\n if (!archive) return\n const arc = archive\n try {\n if (isDicomFile(e.name)) {\n const siblings = dicomSiblings(e)\n if (siblings.length > 1) {\n stackSlices.value = siblings.map((se) => ({\n name: se.name,\n size: se.size,\n load: async () => toArrayBuffer(await arc.extract(se)),\n }))\n stackStart.value = Math.max(\n 0,\n siblings.findIndex((se) => se.path === e.path),\n )\n stackName.value = dirOf(e.path).split('/').pop() || e.name\n entryStack.value = true\n entryStatus.value = 'ready'\n return\n }\n }\n const bytes = await arc.extract(e)\n if (selected.value !== e) return // a newer selection superseded us mid-extract\n blobUrl.value = URL.createObjectURL(new Blob([bytes], { type: mediaMimeForName(e.name) }))\n entryFile.value = classifyViewerFile({\n name: e.name,\n url: blobUrl.value,\n size: e.size,\n textHint: looksText(bytes, e.name),\n })\n entryStatus.value = 'ready'\n } catch (err) {\n entryErr.value = err instanceof Error ? err.message : String(err)\n entryStatus.value = 'error'\n }\n}\n\nfunction onRow(node: TreeNode): void {\n if (node.isDir) {\n if (expanded.has(node.path)) expanded.delete(node.path)\n else expanded.add(node.path)\n } else if (node.entry) {\n void selectEntry(node.entry)\n }\n}\n\nfunction back(): void {\n selected.value = null\n entryText.value = ''\n entryFile.value = null\n entryStack.value = false\n revokeBlob()\n stackSlices.value = []\n}\n\nasync function downloadEntry(e: ArchiveEntry): Promise<void> {\n if (!archive) return\n try {\n const url = URL.createObjectURL(new Blob([await archive.extract(e)]))\n const a = document.createElement('a')\n a.href = url\n a.download = e.name\n document.body.appendChild(a)\n a.click()\n a.remove()\n setTimeout(() => URL.revokeObjectURL(url), 1000)\n } catch {\n /* extraction failed — nothing to download */\n }\n}\n\nconst copied = ref(false)\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nasync function copyEntry(): Promise<void> {\n try {\n await navigator.clipboard?.writeText(entryText.value)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copied.value = true\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copied.value = false), 1400)\n}\n\nonMounted(load)\nwatch(() => props.url, load)\nonBeforeUnmount(() => {\n revokeBlob()\n clearTimeout(copyTimer)\n})\n</script>\n\n<template>\n <div class=\"ar\">\n <p v-if=\"status === 'loading'\" class=\"ar__msg\">正在解压…</p>\n\n <div v-else-if=\"status === 'error'\" class=\"ar__msg ar__msg--error\">\n <p>{{ errorMsg }}</p>\n <button type=\"button\" class=\"ar__retry\" @click=\"load\">重试</button>\n </div>\n\n <!-- detail: one selected entry -->\n <template v-else-if=\"selected\">\n <div class=\"ar__bar\">\n <button type=\"button\" class=\"ar__back\" title=\"返回列表\" @click=\"back\">\n <Icon name=\"arrowLeft\" :size=\"16\" />\n </button>\n <span class=\"ar__path\" :title=\"selected.path\">{{ selected.path }}</span>\n <span class=\"ar__bsize\">{{ formatBytes(selected.size) }}</span>\n <div v-if=\"canToggle\" class=\"ar__view\" role=\"group\" aria-label=\"渲染或源码\">\n <button\n type=\"button\"\n class=\"ar__vbtn\"\n :class=\"{ 'ar__vbtn--on': entryMode === 'render' }\"\n title=\"渲染\"\n @click=\"entryMode = 'render'\"\n >\n <Icon name=\"eye\" :size=\"14\" />\n </button>\n <button\n type=\"button\"\n class=\"ar__vbtn\"\n :class=\"{ 'ar__vbtn--on': entryMode === 'source' }\"\n title=\"源码\"\n @click=\"entryMode = 'source'\"\n >\n <Icon name=\"code\" :size=\"14\" />\n </button>\n </div>\n <button\n v-if=\"entryText && entryStatus === 'ready'\"\n type=\"button\"\n class=\"ar__act\"\n :title=\"copied ? '已复制' : '复制'\"\n @click=\"copyEntry\"\n >\n <Icon name=\"copy\" :size=\"14\" />\n </button>\n <button type=\"button\" class=\"ar__act\" title=\"下载\" @click=\"downloadEntry(selected)\">\n <Icon name=\"download\" :size=\"15\" />\n </button>\n </div>\n\n <p v-if=\"entryStatus === 'loading'\" class=\"ar__msg\">读取中…</p>\n <div v-else-if=\"entryStatus === 'error'\" class=\"ar__msg ar__msg--error\">\n <p>{{ entryErr }}</p>\n </div>\n\n <!-- dicom series: a whole folder of .dcm slices browsed as one scrollable series -->\n <DicomViewer\n v-else-if=\"entryStack\"\n :slices=\"stackSlices\"\n :start=\"stackStart\"\n :name=\"stackName\"\n :size=\"stackTotalSize\"\n />\n\n <!-- everything else: the shared body picks the right viewer from the extracted blob -->\n <FileViewerBody\n v-else-if=\"entryFile\"\n :file=\"entryFile\"\n :mode=\"entryMode\"\n @loaded=\"entryText = $event\"\n />\n </template>\n\n <!-- list: the file tree -->\n <template v-else>\n <div class=\"ar__summary\">{{ fileCount }} 个文件 · {{ formatBytes(totalSize) }}</div>\n <div class=\"ar__scroll ar__tree\">\n <p v-if=\"rows.length === 0\" class=\"ar__msg\">空压缩包</p>\n <button\n v-for=\"row in rows\"\n :key=\"row.node.path\"\n type=\"button\"\n class=\"ar__row\"\n :class=\"{ 'ar__row--dir': row.node.isDir }\"\n :style=\"{ paddingLeft: `${12 + row.depth * 14}px` }\"\n @click=\"onRow(row.node)\"\n >\n <span class=\"ar__twisty\">\n <Icon\n v-if=\"row.node.isDir\"\n name=\"chevronRight\"\n :size=\"13\"\n :class=\"{ 'ar__twisty--open': expanded.has(row.node.path) }\"\n />\n </span>\n <span class=\"ar__rowicon\">\n <Icon :name=\"row.node.isDir ? 'folder' : 'fileOutline'\" :size=\"15\" />\n </span>\n <span class=\"ar__rowname\">{{ row.node.name }}</span>\n <span v-if=\"!row.node.isDir\" class=\"ar__rowsize\">{{ formatBytes(row.node.size) }}</span>\n </button>\n </div>\n </template>\n </div>\n</template>\n\n<style scoped>\n.ar {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.ar__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.ar__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.ar__retry {\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.ar__retry:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* List view ------------------------------------------------------------------------------------ */\n.ar__summary {\n flex-shrink: 0;\n padding: 9px 16px;\n border-bottom: 1px solid var(--border);\n font-size: 11.5px;\n letter-spacing: 0.02em;\n color: var(--faint);\n}\n.ar__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n}\n.ar__tree {\n padding: 6px 0;\n}\n.ar__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 5px 12px 5px 12px;\n background: transparent;\n border: none;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n text-align: left;\n cursor: pointer;\n}\n.ar__row:hover {\n background: var(--surface-2);\n}\n.ar__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 13px;\n color: var(--faint);\n}\n.ar__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.ar__rowicon {\n flex-shrink: 0;\n display: inline-flex;\n color: var(--faint);\n}\n.ar__row--dir .ar__rowicon {\n color: var(--accent);\n}\n.ar__rowname {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.ar__row--dir .ar__rowname {\n font-weight: 500;\n}\n.ar__rowsize {\n flex-shrink: 0;\n font-size: 11px;\n color: var(--faint);\n font-variant-numeric: tabular-nums;\n}\n\n/* Detail bar ----------------------------------------------------------------------------------- */\n.ar__bar {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n padding: 7px 10px 7px 8px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.ar__back,\n.ar__act {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 30px;\n height: 28px;\n background: transparent;\n border: none;\n border-radius: 7px;\n color: var(--muted);\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.ar__back:hover,\n.ar__act:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.ar__path {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n direction: rtl;\n text-align: left;\n font-size: 12.5px;\n font-weight: 500;\n color: var(--text-strong);\n}\n.ar__bsize {\n flex-shrink: 0;\n font-size: 11px;\n color: var(--faint);\n font-variant-numeric: tabular-nums;\n}\n.ar__view {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n gap: 2px;\n padding: 2px;\n border-radius: 7px;\n background: var(--surface-2);\n}\n.ar__vbtn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 24px;\n padding: 0;\n background: transparent;\n border: none;\n border-radius: 5px;\n color: var(--faint);\n cursor: pointer;\n}\n.ar__vbtn--on {\n background: var(--surface);\n color: var(--accent);\n box-shadow: var(--shadow);\n}\n</style>\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AudioViewer.js","names":[],"sources":["../../src/components/AudioViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { parseMidi, type ParsedMidi } from '@/lib/midi'\nimport { decodeSpeechToWav, sniffSpeechCodec, type SpeechCodec } from '@/lib/speechAudio'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer audio player. Fetches the bytes from the download URL and plays them through a native\n * <audio> element from a typed Blob URL — the download endpoint serves octet-stream + attachment, so a\n * direct `<audio src=downloadUrl>` won't decode (same reason the video lightbox fetches a blob). Covers\n * mp3 / wav / flac / ogg / m4a / aac / opus — whatever the browser's media stack can decode.\n *\n * Two best-effort exceptions for formats browsers can't play natively, both detected by file-header magic\n * (NOT the extension) after the bytes are fetched:\n * - AMR / SILK voice clips (.amr/.silk/.slk; a WeChat `.amr` is often actually SILK) are DECODED to WAV\n * in-browser via WASM (see lib/speechAudio.ts) and played from that, with a \"download WAV\" action.\n * - MIDI (.mid/.midi) CAN'T be played (browsers have no synthesizer, and self-hosting a soundfont is out of\n * scope), so the bytes are parsed (lib/midi.ts) and shown as a piano-roll visualization — download to listen.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'gate' | 'decoding' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst audioUrl = ref('')\nconst midi = ref<ParsedMidi | null>(null)\n// Set when the fetched bytes were an AMR/SILK voice clip decoded to WAV in-browser (drives the badge + WAV download).\nconst speech = ref<{ codec: SpeechCodec; sampleRate: number; durationSec: number } | null>(null)\n\nconst isMidi = computed(() => {\n const e = fileExt(props.name)\n return e === 'mid' || e === 'midi'\n})\n\nconst SPEECH_LABEL: Record<SpeechCodec, string> = {\n silk: 'SILK',\n 'amr-nb': 'AMR-NB',\n 'amr-wb': 'AMR-WB',\n}\n\n// Decoded AMR/SILK download as a .wav (the panel's own download button only serves the undecodable original).\nconst baseName = computed(() => {\n const n = props.name || 'audio'\n const e = fileExt(n)\n return e ? n.slice(0, n.length - e.length - 1) : n\n})\nconst wavDownloadName = computed(() => `${baseName.value}.wav`)\n\n// Loading the whole file into a blob is in-memory; gate very large audio behind a confirm.\nconst SIZE_LIMIT = 80 * 1024 * 1024\n\nfunction audioMime(ext: string): string {\n switch (ext) {\n case 'mp3':\n return 'audio/mpeg'\n case 'wav':\n return 'audio/wav'\n case 'flac':\n return 'audio/flac'\n case 'm4a':\n return 'audio/mp4'\n case 'aac':\n return 'audio/aac'\n case 'oga':\n case 'ogg':\n case 'opus':\n return 'audio/ogg'\n case 'weba':\n return 'audio/webm'\n default:\n return 'audio/mpeg'\n }\n}\n\nfunction revoke(): void {\n if (audioUrl.value) {\n URL.revokeObjectURL(audioUrl.value)\n audioUrl.value = ''\n }\n}\n\nfunction fmtTime(sec: number): string {\n if (!Number.isFinite(sec) || sec < 0) sec = 0\n const m = Math.floor(sec / 60)\n const s = Math.round(sec % 60)\n return `${m}:${String(s).padStart(2, '0')}`\n}\n\n// ---- piano roll (MIDI only) ---------------------------------------------------------------------\n\nconst PALETTE = [\n '#3b82f6',\n '#e8590c',\n '#2f9e44',\n '#ae3ec9',\n '#d6336c',\n '#1098ad',\n '#f08c00',\n '#5f3dc4',\n]\nconst NOTE_CAP = 6000 // cap rendered rects so a huge file doesn't lock the SVG\n\nconst roll = computed(() => {\n const m = midi.value\n if (!m || !m.notes.length) return null\n let minP = 127\n let maxP = 0\n for (const n of m.notes) {\n if (n.pitch < minP) minP = n.pitch\n if (n.pitch > maxP) maxP = n.pitch\n }\n minP = Math.max(0, minP - 1)\n maxP = Math.min(127, maxP + 1)\n const rows = maxP - minP + 1\n const rowH = 7\n const pxPerTick = 48 / m.division // ~48px per quarter note\n const width = Math.max(200, Math.ceil(m.totalTicks * pxPerTick) + 8)\n const height = rows * rowH\n const rects = m.notes.slice(0, NOTE_CAP).map((n) => ({\n x: n.startTick * pxPerTick,\n y: (maxP - n.pitch) * rowH,\n w: Math.max(1.5, (n.endTick - n.startTick) * pxPerTick),\n h: rowH - 1,\n color: PALETTE[n.track % PALETTE.length],\n }))\n return { width, height, rects, truncated: m.notes.length > NOTE_CAP }\n})\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n revoke()\n midi.value = null\n speech.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 if (isMidi.value) {\n midi.value = parseMidi(buf)\n } else {\n const bytes = new Uint8Array(buf)\n const codec = sniffSpeechCodec(bytes)\n if (codec) {\n // Browsers can't decode AMR/SILK — transcode to WAV in-browser. Yield a frame so \"解码中…\" paints first.\n status.value = 'decoding'\n await new Promise((r) => setTimeout(r, 0))\n const out = await decodeSpeechToWav(bytes)\n speech.value = {\n codec: out.codec,\n sampleRate: out.sampleRate,\n durationSec: out.durationSec,\n }\n audioUrl.value = URL.createObjectURL(new Blob([out.wav], { type: 'audio/wav' }))\n } else {\n audioUrl.value = URL.createObjectURL(\n new Blob([buf], { type: audioMime(fileExt(props.name)) }),\n )\n }\n }\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\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(revoke)\n</script>\n\n<template>\n <div class=\"au\">\n <p v-if=\"status === 'loading'\" class=\"au__msg\">正在加载…</p>\n <p v-else-if=\"status === 'decoding'\" class=\"au__msg\">正在解码…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"au__msg au__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内加载。是否继续?</p>\n <button type=\"button\" class=\"au__btn\" @click=\"doLoad\">加载并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"au__msg au__msg--error\">\n <p>加载失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"au__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <!-- audio formats: native player (AMR/SILK shown decoded-to-WAV with a download action) -->\n <div v-else-if=\"!isMidi\" class=\"au__player\">\n <div class=\"au__icon\">♪</div>\n <div class=\"au__name\">{{ props.name }}</div>\n <div v-if=\"speech\" class=\"au__meta\">\n <span class=\"au__badge\">{{ SPEECH_LABEL[speech.codec] }} · 已解码为 WAV</span>\n <span v-if=\"speech.durationSec > 0\">{{ fmtTime(speech.durationSec) }}</span>\n </div>\n <audio class=\"au__audio\" :src=\"audioUrl\" controls preload=\"metadata\" />\n <a v-if=\"speech\" class=\"au__dl\" :href=\"audioUrl\" :download=\"wavDownloadName\">\n <Icon name=\"download\" :size=\"14\" /><span>下载 WAV</span>\n </a>\n </div>\n\n <!-- MIDI: piano-roll visualization (no playback in-browser) -->\n <template v-else-if=\"midi\">\n <div class=\"au__head\">\n <span class=\"au__badge\">MIDI</span>\n <span>格式 {{ midi.format }}</span>\n <span>{{ midi.trackCount }} 轨</span>\n <span>{{ midi.tempoBpm }} BPM</span>\n <span>{{ midi.notes.length }} 音符</span>\n <span v-if=\"midi.durationSec > 0\">{{ fmtTime(midi.durationSec) }}</span>\n </div>\n <p class=\"au__note\">\n 浏览器不支持直接播放 MIDI,此处仅作可视化预览;如需试听请下载后用本地播放器打开。\n </p>\n <div class=\"au__roll\">\n <svg\n v-if=\"roll\"\n :width=\"roll.width\"\n :height=\"roll.height\"\n :viewBox=\"`0 0 ${roll.width} ${roll.height}`\"\n >\n <rect\n v-for=\"(r, i) in roll.rects\"\n :key=\"i\"\n :x=\"r.x\"\n :y=\"r.y\"\n :width=\"r.w\"\n :height=\"r.h\"\n :fill=\"r.color\"\n rx=\"1.5\"\n />\n </svg>\n <p v-else class=\"au__msg\">未解析到音符。</p>\n </div>\n <p v-if=\"roll?.truncated\" class=\"au__trunc\">音符过多,仅渲染前 {{ NOTE_CAP }} 个。</p>\n </template>\n </div>\n</template>\n\n<style scoped>\n.au {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.au__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.au__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.au__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.au__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.au__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* audio player: a centered card with a big glyph, the filename, and the native controls. */\n.au__player {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 16px;\n padding: 24px;\n}\n.au__icon {\n width: 84px;\n height: 84px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 44px;\n color: var(--accent);\n background: var(--surface-2);\n border-radius: 50%;\n}\n.au__name {\n max-width: 100%;\n font-size: 13.5px;\n color: var(--text-strong);\n word-break: break-word;\n text-align: center;\n}\n.au__audio {\n width: min(420px, 100%);\n}\n/* Decoded AMR/SILK: a format/duration line above the controls + a \"download WAV\" button below. */\n.au__meta {\n display: flex;\n align-items: center;\n gap: 10px;\n font-size: 12px;\n color: var(--faint);\n}\n.au__dl {\n display: inline-flex;\n align-items: center;\n gap: 6px;\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: 6px 14px;\n text-decoration: none;\n cursor: pointer;\n}\n.au__dl:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* MIDI piano roll */\n.au__head {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 12px;\n flex-shrink: 0;\n padding: 10px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n font-size: 12px;\n color: var(--muted);\n}\n.au__badge {\n padding: 2px 9px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--accent);\n font-weight: 600;\n}\n.au__note {\n flex-shrink: 0;\n margin: 0;\n padding: 8px 16px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--faint);\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.au__roll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 12px;\n background: var(--code-bg);\n}\n.au__roll svg {\n display: block;\n}\n.au__trunc {\n flex-shrink: 0;\n margin: 0;\n padding: 6px 16px;\n font-size: 11.5px;\n color: var(--faint);\n}\n</style>\n"],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AudioViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/AudioViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { parseMidi, type ParsedMidi } from '@/lib/midi'\nimport { decodeSpeechToWav, sniffSpeechCodec, type SpeechCodec } from '@/lib/speechAudio'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer audio player. Fetches the bytes from the download URL and plays them through a native\n * <audio> element from a typed Blob URL — the download endpoint serves octet-stream + attachment, so a\n * direct `<audio src=downloadUrl>` won't decode (same reason the video lightbox fetches a blob). Covers\n * mp3 / wav / flac / ogg / m4a / aac / opus — whatever the browser's media stack can decode.\n *\n * Two best-effort exceptions for formats browsers can't play natively, both detected by file-header magic\n * (NOT the extension) after the bytes are fetched:\n * - AMR / SILK voice clips (.amr/.silk/.slk; a WeChat `.amr` is often actually SILK) are DECODED to WAV\n * in-browser via WASM (see lib/speechAudio.ts) and played from that, with a \"download WAV\" action.\n * - MIDI (.mid/.midi) CAN'T be played (browsers have no synthesizer, and self-hosting a soundfont is out of\n * scope), so the bytes are parsed (lib/midi.ts) and shown as a piano-roll visualization — download to listen.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'gate' | 'decoding' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst audioUrl = ref('')\nconst midi = ref<ParsedMidi | null>(null)\n// Set when the fetched bytes were an AMR/SILK voice clip decoded to WAV in-browser (drives the badge + WAV download).\nconst speech = ref<{ codec: SpeechCodec; sampleRate: number; durationSec: number } | null>(null)\n\nconst isMidi = computed(() => {\n const e = fileExt(props.name)\n return e === 'mid' || e === 'midi'\n})\n\nconst SPEECH_LABEL: Record<SpeechCodec, string> = {\n silk: 'SILK',\n 'amr-nb': 'AMR-NB',\n 'amr-wb': 'AMR-WB',\n}\n\n// Decoded AMR/SILK download as a .wav (the panel's own download button only serves the undecodable original).\nconst baseName = computed(() => {\n const n = props.name || 'audio'\n const e = fileExt(n)\n return e ? n.slice(0, n.length - e.length - 1) : n\n})\nconst wavDownloadName = computed(() => `${baseName.value}.wav`)\n\n// Loading the whole file into a blob is in-memory; gate very large audio behind a confirm.\nconst SIZE_LIMIT = 80 * 1024 * 1024\n\nfunction audioMime(ext: string): string {\n switch (ext) {\n case 'mp3':\n return 'audio/mpeg'\n case 'wav':\n return 'audio/wav'\n case 'flac':\n return 'audio/flac'\n case 'm4a':\n return 'audio/mp4'\n case 'aac':\n return 'audio/aac'\n case 'oga':\n case 'ogg':\n case 'opus':\n return 'audio/ogg'\n case 'weba':\n return 'audio/webm'\n default:\n return 'audio/mpeg'\n }\n}\n\nfunction revoke(): void {\n if (audioUrl.value) {\n URL.revokeObjectURL(audioUrl.value)\n audioUrl.value = ''\n }\n}\n\nfunction fmtTime(sec: number): string {\n if (!Number.isFinite(sec) || sec < 0) sec = 0\n const m = Math.floor(sec / 60)\n const s = Math.round(sec % 60)\n return `${m}:${String(s).padStart(2, '0')}`\n}\n\n// ---- piano roll (MIDI only) ---------------------------------------------------------------------\n\nconst PALETTE = [\n '#3b82f6',\n '#e8590c',\n '#2f9e44',\n '#ae3ec9',\n '#d6336c',\n '#1098ad',\n '#f08c00',\n '#5f3dc4',\n]\nconst NOTE_CAP = 6000 // cap rendered rects so a huge file doesn't lock the SVG\n\nconst roll = computed(() => {\n const m = midi.value\n if (!m || !m.notes.length) return null\n let minP = 127\n let maxP = 0\n for (const n of m.notes) {\n if (n.pitch < minP) minP = n.pitch\n if (n.pitch > maxP) maxP = n.pitch\n }\n minP = Math.max(0, minP - 1)\n maxP = Math.min(127, maxP + 1)\n const rows = maxP - minP + 1\n const rowH = 7\n const pxPerTick = 48 / m.division // ~48px per quarter note\n const width = Math.max(200, Math.ceil(m.totalTicks * pxPerTick) + 8)\n const height = rows * rowH\n const rects = m.notes.slice(0, NOTE_CAP).map((n) => ({\n x: n.startTick * pxPerTick,\n y: (maxP - n.pitch) * rowH,\n w: Math.max(1.5, (n.endTick - n.startTick) * pxPerTick),\n h: rowH - 1,\n color: PALETTE[n.track % PALETTE.length],\n }))\n return { width, height, rects, truncated: m.notes.length > NOTE_CAP }\n})\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n revoke()\n midi.value = null\n speech.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 if (isMidi.value) {\n midi.value = parseMidi(buf)\n } else {\n const bytes = new Uint8Array(buf)\n const codec = sniffSpeechCodec(bytes)\n if (codec) {\n // Browsers can't decode AMR/SILK — transcode to WAV in-browser. Yield a frame so \"解码中…\" paints first.\n status.value = 'decoding'\n await new Promise((r) => setTimeout(r, 0))\n const out = await decodeSpeechToWav(bytes)\n speech.value = {\n codec: out.codec,\n sampleRate: out.sampleRate,\n durationSec: out.durationSec,\n }\n audioUrl.value = URL.createObjectURL(new Blob([out.wav], { type: 'audio/wav' }))\n } else {\n audioUrl.value = URL.createObjectURL(\n new Blob([buf], { type: audioMime(fileExt(props.name)) }),\n )\n }\n }\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\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(revoke)\n</script>\n\n<template>\n <div class=\"au\">\n <p v-if=\"status === 'loading'\" class=\"au__msg\">正在加载…</p>\n <p v-else-if=\"status === 'decoding'\" class=\"au__msg\">正在解码…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"au__msg au__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内加载。是否继续?</p>\n <button type=\"button\" class=\"au__btn\" @click=\"doLoad\">加载并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"au__msg au__msg--error\">\n <p>加载失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"au__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <!-- audio formats: native player (AMR/SILK shown decoded-to-WAV with a download action) -->\n <div v-else-if=\"!isMidi\" class=\"au__player\">\n <div class=\"au__icon\">♪</div>\n <div class=\"au__name\">{{ props.name }}</div>\n <div v-if=\"speech\" class=\"au__meta\">\n <span class=\"au__badge\">{{ SPEECH_LABEL[speech.codec] }} · 已解码为 WAV</span>\n <span v-if=\"speech.durationSec > 0\">{{ fmtTime(speech.durationSec) }}</span>\n </div>\n <audio class=\"au__audio\" :src=\"audioUrl\" controls preload=\"metadata\" />\n <a v-if=\"speech\" class=\"au__dl\" :href=\"audioUrl\" :download=\"wavDownloadName\">\n <Icon name=\"download\" :size=\"14\" /><span>下载 WAV</span>\n </a>\n </div>\n\n <!-- MIDI: piano-roll visualization (no playback in-browser) -->\n <template v-else-if=\"midi\">\n <div class=\"au__head\">\n <span class=\"au__badge\">MIDI</span>\n <span>格式 {{ midi.format }}</span>\n <span>{{ midi.trackCount }} 轨</span>\n <span>{{ midi.tempoBpm }} BPM</span>\n <span>{{ midi.notes.length }} 音符</span>\n <span v-if=\"midi.durationSec > 0\">{{ fmtTime(midi.durationSec) }}</span>\n </div>\n <p class=\"au__note\">\n 浏览器不支持直接播放 MIDI,此处仅作可视化预览;如需试听请下载后用本地播放器打开。\n </p>\n <div class=\"au__roll\">\n <svg\n v-if=\"roll\"\n :width=\"roll.width\"\n :height=\"roll.height\"\n :viewBox=\"`0 0 ${roll.width} ${roll.height}`\"\n >\n <rect\n v-for=\"(r, i) in roll.rects\"\n :key=\"i\"\n :x=\"r.x\"\n :y=\"r.y\"\n :width=\"r.w\"\n :height=\"r.h\"\n :fill=\"r.color\"\n rx=\"1.5\"\n />\n </svg>\n <p v-else class=\"au__msg\">未解析到音符。</p>\n </div>\n <p v-if=\"roll?.truncated\" class=\"au__trunc\">音符过多,仅渲染前 {{ NOTE_CAP }} 个。</p>\n </template>\n </div>\n</template>\n\n<style scoped>\n.au {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.au__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.au__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.au__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.au__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.au__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* audio player: a centered card with a big glyph, the filename, and the native controls. */\n.au__player {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 16px;\n padding: 24px;\n}\n.au__icon {\n width: 84px;\n height: 84px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 44px;\n color: var(--accent);\n background: var(--surface-2);\n border-radius: 50%;\n}\n.au__name {\n max-width: 100%;\n font-size: 13.5px;\n color: var(--text-strong);\n word-break: break-word;\n text-align: center;\n}\n.au__audio {\n width: min(420px, 100%);\n}\n/* Decoded AMR/SILK: a format/duration line above the controls + a \"download WAV\" button below. */\n.au__meta {\n display: flex;\n align-items: center;\n gap: 10px;\n font-size: 12px;\n color: var(--faint);\n}\n.au__dl {\n display: inline-flex;\n align-items: center;\n gap: 6px;\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: 6px 14px;\n text-decoration: none;\n cursor: pointer;\n}\n.au__dl:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* MIDI piano roll */\n.au__head {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 12px;\n flex-shrink: 0;\n padding: 10px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n font-size: 12px;\n color: var(--muted);\n}\n.au__badge {\n padding: 2px 9px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--accent);\n font-weight: 600;\n}\n.au__note {\n flex-shrink: 0;\n margin: 0;\n padding: 8px 16px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--faint);\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.au__roll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 12px;\n background: var(--code-bg);\n}\n.au__roll svg {\n display: block;\n}\n.au__trunc {\n flex-shrink: 0;\n margin: 0;\n padding: 6px 16px;\n font-size: 11.5px;\n color: var(--faint);\n}\n</style>\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BinObjBrowser.js","names":[],"sources":["../../src/components/BinObjBrowser.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'\nimport { parseBinObj, UnsupportedBinaryError, type BinObjInfo, type BinSymbol } from '@/lib/binobj'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * The side viewer's binary-object mode: fetch a `.so`/`.dll`'s bytes, parse its symbol table entirely\n * in-browser (see lib/binobj.ts), and list the function symbols. Mirrors ZipBrowser — the bytes come from\n * the same download endpoint as any other file and never leave the browser. No disassembly: this answers\n * \"what functions does this library define?\", and clicking a symbol copies its name.\n *\n * Large-file gate: a `.so`/`.dll` can be tens of MB, and parsing needs the whole file in memory. So unless\n * the manifest size is known to be small (≤ LARGE_THRESHOLD), we don't auto-fetch — we show a confirm card\n * and only download when the user clicks. Unknown size is treated as \"large\" (better to ask than to pull\n * tens of MB unprompted).\n */\nconst props = defineProps<{ url: string; name: string; size?: number }>()\n\nconst LARGE_THRESHOLD = 5 * 1024 * 1024 // 5 MB\nconst MAX_ROWS = 2000 // cap rendered rows so a library with tens of thousands of symbols stays responsive\n\ntype Status = 'confirm' | 'loading' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst info = ref<BinObjInfo | null>(null)\nconst query = ref('')\n\n// Decide whether to auto-load or gate behind a confirm, based on the (manifest) size. Runs on mount and\n// whenever a different binary is opened into the same panel. Never fetches during SSR (onMounted is client-only).\nfunction decideInitial(): void {\n info.value = null\n query.value = ''\n errorMsg.value = ''\n if (props.size != null && props.size <= LARGE_THRESHOLD) void load()\n else status.value = 'confirm'\n}\n\nasync function load(): Promise<void> {\n status.value = 'loading'\n errorMsg.value = ''\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 info.value = parseBinObj(buf)\n status.value = 'ready'\n } catch (e) {\n errorMsg.value =\n e instanceof UnsupportedBinaryError ? e.message : e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\n// Name filter (case-insensitive substring), then cap to MAX_ROWS so huge tables don't blow up the DOM.\nconst filtered = computed<BinSymbol[]>(() => {\n const all = info.value?.symbols ?? []\n const q = query.value.trim().toLowerCase()\n return q ? all.filter((s) => s.name.toLowerCase().includes(q)) : all\n})\nconst visible = computed(() => filtered.value.slice(0, MAX_ROWS))\nconst truncated = computed(() => filtered.value.length > MAX_ROWS)\n\nfunction hex(n: number): string {\n return `0x${n.toString(16)}`\n}\nfunction kindLabel(s: BinSymbol): string {\n if (s.kind === 'export') return 'EXP'\n if (s.kind === 'ifunc') return 'IFN'\n return 'FN'\n}\n\n// Click a row → copy the (possibly mangled) symbol name; the row briefly shows \"已复制\".\nconst copiedName = ref('')\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nasync function copyName(name: string): Promise<void> {\n try {\n await navigator.clipboard?.writeText(name)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copiedName.value = name\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copiedName.value = ''), 1200)\n}\n\nonMounted(decideInitial)\nwatch(() => props.url, decideInitial)\nonBeforeUnmount(() => clearTimeout(copyTimer))\n</script>\n\n<template>\n <div class=\"bo\">\n <!-- large-file gate: don't auto-download; ask first -->\n <div v-if=\"status === 'confirm'\" class=\"bo__confirm\">\n <Icon name=\"code\" :size=\"32\" />\n <p class=\"bo__confirm-title\" :title=\"name\">{{ name }}</p>\n <p class=\"bo__confirm-sub\">\n {{\n size != null ? `文件较大(${formatBytes(size)})` : '文件大小未知'\n }},解析需将整个文件下载到浏览器。\n </p>\n <button type=\"button\" class=\"bo__btn\" @click=\"load\">加载并解析符号</button>\n </div>\n\n <p v-else-if=\"status === 'loading'\" class=\"bo__msg\">正在解析符号…</p>\n\n <div v-else-if=\"status === 'error'\" class=\"bo__msg bo__msg--error\">\n <p>{{ errorMsg }}</p>\n <button type=\"button\" class=\"bo__retry\" @click=\"load\">重试</button>\n </div>\n\n <template v-else-if=\"status === 'ready' && info\">\n <div class=\"bo__summary\">\n <span class=\"bo__fmt\">{{ info.format.toUpperCase() }}</span>\n <span>{{ info.arch }}</span>\n <span>{{ info.bits }}-bit</span>\n <span>{{ info.symbols.length }} 个函数</span>\n <span v-if=\"info.stripped\" class=\"bo__tag\" title=\"无完整符号表,仅列动态/导出符号\"\n >已剥离</span\n >\n </div>\n\n <!-- metadata card: PE version-info + icon, or ELF header / interpreter / dependencies -->\n <div v-if=\"info.iconDataUrl || info.meta.length\" class=\"bo__meta\">\n <img v-if=\"info.iconDataUrl\" class=\"bo__icon\" :src=\"info.iconDataUrl\" :alt=\"name\" />\n <dl v-if=\"info.meta.length\" class=\"bo__metalist\">\n <template v-for=\"(m, i) in info.meta\" :key=\"i\">\n <dt class=\"bo__metakey\">{{ m.label }}</dt>\n <dd class=\"bo__metaval\">{{ m.value }}</dd>\n </template>\n </dl>\n </div>\n\n <div v-if=\"info.symbols.length\" class=\"bo__search\">\n <input v-model=\"query\" type=\"search\" class=\"bo__input\" placeholder=\"搜索函数名…\" />\n </div>\n\n <p v-if=\"info.note\" class=\"bo__note\">{{ info.note }}</p>\n\n <div class=\"bo__list\">\n <p v-if=\"info.symbols.length && filtered.length === 0\" class=\"bo__msg\">无匹配函数</p>\n <button\n v-for=\"(s, i) in visible\"\n :key=\"i\"\n type=\"button\"\n class=\"bo__row\"\n :title=\"`点击复制:${s.name}`\"\n @click=\"copyName(s.name)\"\n >\n <span class=\"bo__kind\" :class=\"{ 'bo__kind--weak': s.bind === 'weak' }\">{{\n kindLabel(s)\n }}</span>\n <span class=\"bo__name\">{{ s.name }}</span>\n <span class=\"bo__meta\">\n <span v-if=\"copiedName === s.name\" class=\"bo__copied\">已复制</span>\n <template v-else>\n <span class=\"bo__addr\">{{ hex(s.address) }}</span>\n <span v-if=\"s.size\" class=\"bo__bsize\">{{ formatBytes(s.size) }}</span>\n </template>\n </span>\n </button>\n <p v-if=\"truncated\" class=\"bo__more\">\n 仅显示前 {{ MAX_ROWS }} 个,输入关键词缩小范围(共 {{ filtered.length }} 个)\n </p>\n </div>\n </template>\n </div>\n</template>\n\n<style scoped>\n.bo {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.bo__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.bo__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.bo__retry {\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.bo__retry:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* Large-file confirm card -------------------------------------------------------------------------- */\n.bo__confirm {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n padding: 32px 28px;\n text-align: center;\n color: var(--muted);\n}\n.bo__confirm-title {\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13.5px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.bo__confirm-sub {\n font-size: 12.5px;\n line-height: 1.6;\n color: var(--muted);\n}\n.bo__btn {\n margin-top: 4px;\n font-family: inherit;\n font-size: 13px;\n color: #fff;\n background: var(--accent);\n border: none;\n border-radius: 8px;\n padding: 8px 18px;\n cursor: pointer;\n}\n.bo__btn:hover {\n filter: brightness(1.06);\n}\n\n/* Summary bar -------------------------------------------------------------------------------------- */\n.bo__summary {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 6px 12px;\n padding: 9px 16px;\n border-bottom: 1px solid var(--border);\n font-size: 11.5px;\n letter-spacing: 0.02em;\n color: var(--faint);\n}\n.bo__fmt {\n font-weight: 700;\n color: var(--accent);\n}\n.bo__tag {\n padding: 1px 7px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--muted);\n font-size: 10.5px;\n}\n\n/* Metadata card (PE version-info + icon / ELF header + dynamic) -------------------------------------- */\n.bo__meta {\n flex-shrink: 0;\n display: flex;\n gap: 14px;\n padding: 12px 16px;\n border-bottom: 1px solid var(--border);\n}\n.bo__icon {\n flex-shrink: 0;\n width: 52px;\n height: 52px;\n object-fit: contain;\n border-radius: 8px;\n background: var(--surface-2);\n image-rendering: -webkit-optimize-contrast;\n}\n.bo__metalist {\n flex: 1 1 auto;\n min-width: 0;\n margin: 0;\n display: grid;\n grid-template-columns: auto minmax(0, 1fr);\n gap: 3px 12px;\n align-content: start;\n}\n.bo__metakey {\n color: var(--faint);\n font-size: 11.5px;\n white-space: nowrap;\n}\n.bo__metaval {\n margin: 0;\n min-width: 0;\n color: var(--text);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n\n/* Search ------------------------------------------------------------------------------------------- */\n.bo__search {\n flex-shrink: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--border);\n}\n.bo__input {\n width: 100%;\n box-sizing: border-box;\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.bo__input:focus {\n outline: none;\n border-color: var(--accent);\n}\n\n.bo__note {\n flex-shrink: 0;\n padding: 8px 16px;\n font-size: 12px;\n color: var(--muted);\n background: var(--surface-2);\n}\n\n/* Symbol list -------------------------------------------------------------------------------------- */\n.bo__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 4px 0;\n}\n.bo__row {\n display: flex;\n align-items: center;\n gap: 9px;\n width: 100%;\n padding: 5px 14px;\n background: transparent;\n border: none;\n font-family: inherit;\n text-align: left;\n cursor: pointer;\n}\n.bo__row:hover {\n background: var(--surface-2);\n}\n.bo__kind {\n flex-shrink: 0;\n width: 30px;\n text-align: center;\n padding: 1px 0;\n border-radius: 5px;\n background: var(--surface-2);\n color: var(--accent);\n font-size: 9.5px;\n font-weight: 700;\n letter-spacing: 0.04em;\n}\n.bo__kind--weak {\n color: var(--faint);\n}\n.bo__name {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-family: var(--font-mono);\n font-size: 12.5px;\n color: var(--text);\n}\n.bo__meta {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n font-variant-numeric: tabular-nums;\n}\n.bo__addr {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--faint);\n}\n.bo__bsize {\n font-size: 10.5px;\n color: var(--faint);\n}\n.bo__copied {\n font-size: 11px;\n color: var(--accent);\n}\n.bo__more {\n padding: 10px 16px;\n font-size: 11.5px;\n color: var(--faint);\n}\n</style>\n"],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BinObjBrowser.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/BinObjBrowser.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'\nimport { parseBinObj, UnsupportedBinaryError, type BinObjInfo, type BinSymbol } from '@/lib/binobj'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * The side viewer's binary-object mode: fetch a `.so`/`.dll`'s bytes, parse its symbol table entirely\n * in-browser (see lib/binobj.ts), and list the function symbols. Mirrors ZipBrowser — the bytes come from\n * the same download endpoint as any other file and never leave the browser. No disassembly: this answers\n * \"what functions does this library define?\", and clicking a symbol copies its name.\n *\n * Large-file gate: a `.so`/`.dll` can be tens of MB, and parsing needs the whole file in memory. So unless\n * the manifest size is known to be small (≤ LARGE_THRESHOLD), we don't auto-fetch — we show a confirm card\n * and only download when the user clicks. Unknown size is treated as \"large\" (better to ask than to pull\n * tens of MB unprompted).\n */\nconst props = defineProps<{ url: string; name: string; size?: number }>()\n\nconst LARGE_THRESHOLD = 5 * 1024 * 1024 // 5 MB\nconst MAX_ROWS = 2000 // cap rendered rows so a library with tens of thousands of symbols stays responsive\n\ntype Status = 'confirm' | 'loading' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst info = ref<BinObjInfo | null>(null)\nconst query = ref('')\n\n// Decide whether to auto-load or gate behind a confirm, based on the (manifest) size. Runs on mount and\n// whenever a different binary is opened into the same panel. Never fetches during SSR (onMounted is client-only).\nfunction decideInitial(): void {\n info.value = null\n query.value = ''\n errorMsg.value = ''\n if (props.size != null && props.size <= LARGE_THRESHOLD) void load()\n else status.value = 'confirm'\n}\n\nasync function load(): Promise<void> {\n status.value = 'loading'\n errorMsg.value = ''\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 info.value = parseBinObj(buf)\n status.value = 'ready'\n } catch (e) {\n errorMsg.value =\n e instanceof UnsupportedBinaryError ? e.message : e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\n// Name filter (case-insensitive substring), then cap to MAX_ROWS so huge tables don't blow up the DOM.\nconst filtered = computed<BinSymbol[]>(() => {\n const all = info.value?.symbols ?? []\n const q = query.value.trim().toLowerCase()\n return q ? all.filter((s) => s.name.toLowerCase().includes(q)) : all\n})\nconst visible = computed(() => filtered.value.slice(0, MAX_ROWS))\nconst truncated = computed(() => filtered.value.length > MAX_ROWS)\n\nfunction hex(n: number): string {\n return `0x${n.toString(16)}`\n}\nfunction kindLabel(s: BinSymbol): string {\n if (s.kind === 'export') return 'EXP'\n if (s.kind === 'ifunc') return 'IFN'\n return 'FN'\n}\n\n// Click a row → copy the (possibly mangled) symbol name; the row briefly shows \"已复制\".\nconst copiedName = ref('')\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nasync function copyName(name: string): Promise<void> {\n try {\n await navigator.clipboard?.writeText(name)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copiedName.value = name\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copiedName.value = ''), 1200)\n}\n\nonMounted(decideInitial)\nwatch(() => props.url, decideInitial)\nonBeforeUnmount(() => clearTimeout(copyTimer))\n</script>\n\n<template>\n <div class=\"bo\">\n <!-- large-file gate: don't auto-download; ask first -->\n <div v-if=\"status === 'confirm'\" class=\"bo__confirm\">\n <Icon name=\"code\" :size=\"32\" />\n <p class=\"bo__confirm-title\" :title=\"name\">{{ name }}</p>\n <p class=\"bo__confirm-sub\">\n {{\n size != null ? `文件较大(${formatBytes(size)})` : '文件大小未知'\n }},解析需将整个文件下载到浏览器。\n </p>\n <button type=\"button\" class=\"bo__btn\" @click=\"load\">加载并解析符号</button>\n </div>\n\n <p v-else-if=\"status === 'loading'\" class=\"bo__msg\">正在解析符号…</p>\n\n <div v-else-if=\"status === 'error'\" class=\"bo__msg bo__msg--error\">\n <p>{{ errorMsg }}</p>\n <button type=\"button\" class=\"bo__retry\" @click=\"load\">重试</button>\n </div>\n\n <template v-else-if=\"status === 'ready' && info\">\n <div class=\"bo__summary\">\n <span class=\"bo__fmt\">{{ info.format.toUpperCase() }}</span>\n <span>{{ info.arch }}</span>\n <span>{{ info.bits }}-bit</span>\n <span>{{ info.symbols.length }} 个函数</span>\n <span v-if=\"info.stripped\" class=\"bo__tag\" title=\"无完整符号表,仅列动态/导出符号\"\n >已剥离</span\n >\n </div>\n\n <!-- metadata card: PE version-info + icon, or ELF header / interpreter / dependencies -->\n <div v-if=\"info.iconDataUrl || info.meta.length\" class=\"bo__meta\">\n <img v-if=\"info.iconDataUrl\" class=\"bo__icon\" :src=\"info.iconDataUrl\" :alt=\"name\" />\n <dl v-if=\"info.meta.length\" class=\"bo__metalist\">\n <template v-for=\"(m, i) in info.meta\" :key=\"i\">\n <dt class=\"bo__metakey\">{{ m.label }}</dt>\n <dd class=\"bo__metaval\">{{ m.value }}</dd>\n </template>\n </dl>\n </div>\n\n <div v-if=\"info.symbols.length\" class=\"bo__search\">\n <input v-model=\"query\" type=\"search\" class=\"bo__input\" placeholder=\"搜索函数名…\" />\n </div>\n\n <p v-if=\"info.note\" class=\"bo__note\">{{ info.note }}</p>\n\n <div class=\"bo__list\">\n <p v-if=\"info.symbols.length && filtered.length === 0\" class=\"bo__msg\">无匹配函数</p>\n <button\n v-for=\"(s, i) in visible\"\n :key=\"i\"\n type=\"button\"\n class=\"bo__row\"\n :title=\"`点击复制:${s.name}`\"\n @click=\"copyName(s.name)\"\n >\n <span class=\"bo__kind\" :class=\"{ 'bo__kind--weak': s.bind === 'weak' }\">{{\n kindLabel(s)\n }}</span>\n <span class=\"bo__name\">{{ s.name }}</span>\n <span class=\"bo__meta\">\n <span v-if=\"copiedName === s.name\" class=\"bo__copied\">已复制</span>\n <template v-else>\n <span class=\"bo__addr\">{{ hex(s.address) }}</span>\n <span v-if=\"s.size\" class=\"bo__bsize\">{{ formatBytes(s.size) }}</span>\n </template>\n </span>\n </button>\n <p v-if=\"truncated\" class=\"bo__more\">\n 仅显示前 {{ MAX_ROWS }} 个,输入关键词缩小范围(共 {{ filtered.length }} 个)\n </p>\n </div>\n </template>\n </div>\n</template>\n\n<style scoped>\n.bo {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.bo__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.bo__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.bo__retry {\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.bo__retry:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* Large-file confirm card -------------------------------------------------------------------------- */\n.bo__confirm {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n padding: 32px 28px;\n text-align: center;\n color: var(--muted);\n}\n.bo__confirm-title {\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13.5px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.bo__confirm-sub {\n font-size: 12.5px;\n line-height: 1.6;\n color: var(--muted);\n}\n.bo__btn {\n margin-top: 4px;\n font-family: inherit;\n font-size: 13px;\n color: #fff;\n background: var(--accent);\n border: none;\n border-radius: 8px;\n padding: 8px 18px;\n cursor: pointer;\n}\n.bo__btn:hover {\n filter: brightness(1.06);\n}\n\n/* Summary bar -------------------------------------------------------------------------------------- */\n.bo__summary {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 6px 12px;\n padding: 9px 16px;\n border-bottom: 1px solid var(--border);\n font-size: 11.5px;\n letter-spacing: 0.02em;\n color: var(--faint);\n}\n.bo__fmt {\n font-weight: 700;\n color: var(--accent);\n}\n.bo__tag {\n padding: 1px 7px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--muted);\n font-size: 10.5px;\n}\n\n/* Metadata card (PE version-info + icon / ELF header + dynamic) -------------------------------------- */\n.bo__meta {\n flex-shrink: 0;\n display: flex;\n gap: 14px;\n padding: 12px 16px;\n border-bottom: 1px solid var(--border);\n}\n.bo__icon {\n flex-shrink: 0;\n width: 52px;\n height: 52px;\n object-fit: contain;\n border-radius: 8px;\n background: var(--surface-2);\n image-rendering: -webkit-optimize-contrast;\n}\n.bo__metalist {\n flex: 1 1 auto;\n min-width: 0;\n margin: 0;\n display: grid;\n grid-template-columns: auto minmax(0, 1fr);\n gap: 3px 12px;\n align-content: start;\n}\n.bo__metakey {\n color: var(--faint);\n font-size: 11.5px;\n white-space: nowrap;\n}\n.bo__metaval {\n margin: 0;\n min-width: 0;\n color: var(--text);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n\n/* Search ------------------------------------------------------------------------------------------- */\n.bo__search {\n flex-shrink: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--border);\n}\n.bo__input {\n width: 100%;\n box-sizing: border-box;\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.bo__input:focus {\n outline: none;\n border-color: var(--accent);\n}\n\n.bo__note {\n flex-shrink: 0;\n padding: 8px 16px;\n font-size: 12px;\n color: var(--muted);\n background: var(--surface-2);\n}\n\n/* Symbol list -------------------------------------------------------------------------------------- */\n.bo__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 4px 0;\n}\n.bo__row {\n display: flex;\n align-items: center;\n gap: 9px;\n width: 100%;\n padding: 5px 14px;\n background: transparent;\n border: none;\n font-family: inherit;\n text-align: left;\n cursor: pointer;\n}\n.bo__row:hover {\n background: var(--surface-2);\n}\n.bo__kind {\n flex-shrink: 0;\n width: 30px;\n text-align: center;\n padding: 1px 0;\n border-radius: 5px;\n background: var(--surface-2);\n color: var(--accent);\n font-size: 9.5px;\n font-weight: 700;\n letter-spacing: 0.04em;\n}\n.bo__kind--weak {\n color: var(--faint);\n}\n.bo__name {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-family: var(--font-mono);\n font-size: 12.5px;\n color: var(--text);\n}\n.bo__meta {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n font-variant-numeric: tabular-nums;\n}\n.bo__addr {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--faint);\n}\n.bo__bsize {\n font-size: 10.5px;\n color: var(--faint);\n}\n.bo__copied {\n font-size: 11px;\n color: var(--accent);\n}\n.bo__more {\n padding: 10px 16px;\n font-size: 11.5px;\n color: var(--faint);\n}\n</style>\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CertViewer.js","names":[],"sources":["../../src/components/CertViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport {\n asn1String,\n asn1Time,\n bytesToHex,\n decodeAsn1,\n oidToString,\n tagName,\n TAG,\n type Asn1Node,\n} from '@/lib/asn1'\nimport {\n looksLikePem,\n oidName,\n parseCertificate,\n parseCsr,\n parsePemBlocks,\n sha256Hex,\n summarizeBlock,\n type CertSummary,\n} from '@/lib/x509'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for certificates / keys (pem / crt / cer / der / csr / key / p12 / pfx). Fetches the BYTES\n * itself (binary-safe — a .der/.p12 would be mangled by the panel's text load), sniffs PEM vs raw DER, and\n * for each block shows the human-meaningful X.509 fields (subject / issuer, validity with an expiry badge,\n * serial, signature + public-key algorithm, SANs, key usages, SHA-256 fingerprint) plus a collapsible raw\n * ASN.1 structure tree. Parsing is hand-rolled and READ-ONLY (lib/asn1.ts + lib/x509.ts) — it never verifies\n * signatures or trust. PKCS#12 (.p12/.pfx) containers are usually encrypted, so they're shown as structure\n * only with a \"use openssl to extract\" note (best effort).\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ninterface CertItem {\n summary: CertSummary\n forest: Asn1Node[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst items = ref<CertItem[]>([])\nconst fingerprints = reactive<Record<number, string>>({})\nconst expanded = reactive(new Set<string>())\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nfunction buildItem(summary: CertSummary): CertItem {\n return { summary, forest: decodeAsn1(summary.der) }\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const ext = fileExt(props.name)\n const head = new TextDecoder('latin1').decode(bytes.subarray(0, Math.min(64, bytes.length)))\n const list: CertItem[] = []\n\n if (head.includes('-----BEGIN') || looksLikePem(new TextDecoder('latin1').decode(bytes))) {\n const text = new TextDecoder('utf-8').decode(bytes)\n for (const block of parsePemBlocks(text))\n list.push(buildItem(summarizeBlock(block.label, block.der)))\n }\n if (!list.length) {\n // Raw DER (or a PEM that yielded no blocks). Pick a parser from the extension.\n if (ext === 'p12' || ext === 'pfx') {\n const s = summarizeP12(bytes)\n list.push(buildItem(s))\n } else if (ext === 'csr' || ext === 'req') {\n list.push(buildItem(parseCsr(bytes)))\n } else {\n const cert = parseCertificate(bytes)\n list.push(buildItem(cert.ok ? cert : fallbackStructure(bytes, ext)))\n }\n }\n\n items.value = list\n status.value = 'ready'\n // Fingerprint each certificate asynchronously (SHA-256 of its DER).\n list.forEach((it, i) => {\n if (it.summary.kind === 'certificate' && it.summary.ok) {\n void sha256Hex(it.summary.der)\n .then((hex) => {\n fingerprints[i] = hex\n })\n .catch(() => {})\n }\n })\n // Open the first level of the first item so there's something to see without a click.\n if (list.length && list[0].forest.length) expanded.add('0.0')\n}\n\nfunction summarizeP12(bytes: Uint8Array): CertSummary {\n return {\n ok: true,\n kind: 'pkcs12',\n label: 'PKCS#12',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: 'PKCS#12 容器通常已加密,浏览器内仅展示 ASN.1 结构;如需提取证书/私钥请用 openssl pkcs12 -info。',\n }\n}\n\nfunction fallbackStructure(bytes: Uint8Array, ext: string): CertSummary {\n return {\n ok: true,\n kind: 'unknown',\n label: ext ? `.${ext}` : '二进制',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: '无法识别为 X.509 证书 —— 展开下方结构树查看原始 ASN.1。',\n }\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n items.value = []\n expanded.clear()\n for (const k of Object.keys(fingerprints)) delete fingerprints[Number(k)]\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\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\n// ── friendly-field formatting ────────────────────────────────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst KIND_LABEL: Record<CertSummary['kind'], string> = {\n certificate: '证书',\n request: '证书请求 (CSR)',\n key: '密钥',\n crl: 'CRL',\n pkcs12: 'PKCS#12',\n pkcs7: 'PKCS#7',\n unknown: '未知',\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'cert__badge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'cert__badge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'cert__badge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'cert__badge--ok' }\n}\n\n// ── ASN.1 structure tree (flattened with an expand set) ──────────────────────────────────────────\ninterface TreeRow {\n node: Asn1Node\n depth: number\n path: string\n hasChildren: boolean\n}\n\nfunction flatten(forest: Asn1Node[], prefix: string): TreeRow[] {\n const rows: TreeRow[] = []\n const walk = (nodes: Asn1Node[], depth: number, pre: string): void => {\n nodes.forEach((node, i) => {\n const path = `${pre}.${i}`\n const hasChildren = node.children.length > 0\n rows.push({ node, depth, path, hasChildren })\n if (hasChildren && expanded.has(path)) walk(node.children, depth + 1, path)\n })\n }\n walk(forest, 0, prefix)\n return rows\n}\n\nconst treeRows = computed(() => items.value.map((it, i) => flatten(it.forest, String(i))))\n\nfunction toggleNode(path: string): void {\n if (expanded.has(path)) expanded.delete(path)\n else expanded.add(path)\n}\n\n// A short, human value preview for a primitive node (constructed nodes show only their tag).\nfunction nodePreview(node: Asn1Node): string {\n if (node.constructed) return ''\n switch (node.tagNumber) {\n case TAG.OID: {\n const oid = oidToString(node.content)\n const nm = oidName(oid)\n return nm === oid ? oid : `${nm} (${oid})`\n }\n case TAG.BOOLEAN:\n return node.content[0] ? 'true' : 'false'\n case TAG.INTEGER: {\n if (node.content.length <= 4) {\n let v = 0\n for (const b of node.content) v = v * 256 + b\n return String(v)\n }\n return `0x${bytesToHex(node.content).slice(0, 32)}${node.content.length > 16 ? '…' : ''}`\n }\n case TAG.UTF8String:\n case TAG.PrintableString:\n case TAG.IA5String:\n return asn1String(node)\n case TAG.UTCTime:\n case TAG.GeneralizedTime:\n return asn1Time(node)?.text ?? asn1String(node)\n case TAG.NULL:\n return ''\n default: {\n if (node.tagClass === 2) {\n // context-specific primitive (e.g. a SAN name) — show as text if printable, else hex\n const txt = new TextDecoder('latin1').decode(node.content)\n if (/^[\\x20-\\x7e]*$/.test(txt)) return txt\n }\n const hex = bytesToHex(node.content)\n return hex.length > 32 ? `${hex.slice(0, 32)}… (${node.length}B)` : hex\n }\n }\n}\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"cert\">\n <p v-if=\"status === 'loading'\" class=\"cert__msg\">正在解析证书…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"cert__msg cert__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"cert__msg cert__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else class=\"cert__scroll\">\n <section v-for=\"(it, i) in items\" :key=\"i\" class=\"cert__card\">\n <header class=\"cert__cardhead\">\n <span class=\"cert__kind\">{{ KIND_LABEL[it.summary.kind] }}</span>\n <span class=\"cert__label\">{{ it.summary.subjectCN || it.summary.label }}</span>\n <span v-if=\"expiry(it.summary)\" class=\"cert__badge\" :class=\"expiry(it.summary)?.cls\">\n {{ expiry(it.summary)?.text }}\n </span>\n </header>\n\n <p v-if=\"it.summary.note\" class=\"cert__note\">{{ it.summary.note }}</p>\n\n <dl v-if=\"it.summary.subject.length || it.summary.kind === 'certificate'\" class=\"cert__dl\">\n <template v-if=\"it.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(it.summary.subject) }}</dd>\n </template>\n <template v-if=\"it.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(it.summary.issuer) }}</dd>\n </template>\n <template v-if=\"it.summary.notBefore || it.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ it.summary.notBefore ?? '?' }} → {{ it.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(it.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(it.summary) }}</dd>\n </template>\n <template v-if=\"it.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ it.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"it.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"cert__mono\">{{ it.summary.serialHex }}</dd>\n </template>\n <template v-if=\"it.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ it.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.keyUsages.length\">\n <dt>密钥用法</dt>\n <dd>{{ it.summary.keyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.extKeyUsages.length\">\n <dt>扩展用法</dt>\n <dd>{{ it.summary.extKeyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.isCa != null\">\n <dt>CA</dt>\n <dd>\n {{ it.summary.isCa ? '是' : '否'\n }}{{ it.summary.pathLen != null ? `(pathLen ${it.summary.pathLen})` : '' }}\n </dd>\n </template>\n <template v-if=\"fingerprints[i]\">\n <dt>SHA-256</dt>\n <dd class=\"cert__mono cert__fp\">{{ fingerprints[i] }}</dd>\n </template>\n </dl>\n\n <details class=\"cert__tree\">\n <summary class=\"cert__treehead\">ASN.1 结构</summary>\n <div class=\"cert__rows\">\n <button\n v-for=\"row in treeRows[i]\"\n :key=\"row.path\"\n type=\"button\"\n class=\"cert__row\"\n :style=\"{ paddingLeft: `${8 + row.depth * 16}px` }\"\n @click=\"row.hasChildren && toggleNode(row.path)\"\n >\n <span class=\"cert__twisty\">\n <Icon\n v-if=\"row.hasChildren\"\n name=\"chevronRight\"\n :size=\"12\"\n :class=\"{ 'cert__twisty--open': expanded.has(row.path) }\"\n />\n </span>\n <span class=\"cert__tag\">{{ tagName(row.node) }}</span>\n <span v-if=\"nodePreview(row.node)\" class=\"cert__val\">{{\n nodePreview(row.node)\n }}</span>\n </button>\n </div>\n </details>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cert {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.cert__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.cert__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.cert__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.cert__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.cert__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.cert__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n.cert__card {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.cert__cardhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.cert__kind {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 5px;\n padding: 2px 7px;\n}\n.cert__label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.cert__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.cert__badge--ok {\n color: #15803d;\n background: color-mix(in srgb, #22c55e 16%, transparent);\n}\n.cert__badge--warn {\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 18%, transparent);\n}\n.cert__badge--bad {\n color: #b91c1c;\n background: color-mix(in srgb, #ef4444 16%, transparent);\n}\n\n.cert__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n border-bottom: 1px solid var(--border);\n}\n\n.cert__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 12px;\n}\n.cert__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.cert__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.cert__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.cert__fp {\n word-break: break-all;\n}\n\n.cert__tree {\n border-top: 1px solid var(--border);\n}\n.cert__treehead {\n padding: 8px 12px;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n}\n.cert__treehead:hover {\n color: var(--accent);\n}\n.cert__rows {\n padding: 4px 0 10px;\n background: var(--code-bg);\n border-top: 1px solid var(--code-border);\n overflow-x: auto;\n}\n.cert__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 3px 12px 3px 8px;\n background: transparent;\n border: none;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text);\n text-align: left;\n white-space: nowrap;\n cursor: default;\n}\n.cert__row:hover {\n background: var(--surface-2);\n}\n.cert__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 12px;\n color: var(--faint);\n}\n.cert__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.cert__tag {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cert__val {\n min-width: 0;\n color: var(--muted);\n}\n</style>\n"],"mappings":""}
|