@l1yp/file-viewer 0.1.1 → 0.1.2
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 +2 -0
- 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 +2 -0
- 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/PdfViewer.js +2 -0
- package/dist/components/PdfViewer.js.map +1 -0
- package/dist/components/PdfViewer.vue_vue_type_script_setup_true_lang.js +2 -0
- 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 +2 -0
- package/dist/components/ZoomImage.js.map +1 -0
- package/dist/components/ZoomImage.vue_vue_type_script_setup_true_lang.js +2 -0
- 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/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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LogViewer.js","names":[],"sources":["../../src/components/LogViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\nimport { useLogRules, type ColorRule } from '@/composables/useLogRules'\n\n/**\n * Side-viewer for large text / log files. Fetches the bytes, splits into lines, and renders them with a\n * fixed-height VIRTUAL list — only the visible window (plus a little overscan) is ever in the DOM, so a\n * multi-hundred-MB log scrolls smoothly instead of locking the page (the plain source view would mount one\n * huge highlighted block). No syntax highlight (logs don't need it, and tokenizing 500k lines is infeasible).\n *\n * Routed here for any `.log`, and for any large text file (see FileChip's size gate) so a 50MB `.txt`/`.csv`\n * doesn't freeze the normal viewer. Tools: a sticky line-number gutter, substring search (highlight, match\n * count, prev/next, optional filter-to-matches), go-to-line, plus three log-specific aids:\n *\n * 1. Call-stack folding — error logs from common languages (Java/Kotlin/Scala, JS/Node, Python, C#/.NET,\n * Ruby) are one header line (\"Exception in thread…\", \"Traceback…\", \"Caused by:\") followed by a run of\n * indented frame lines (\" at …\", ' File \"…\"', …). We detect those runs, hang a chevron off the header,\n * and collapse the frames behind a \"N 帧\" badge (collapsed by DEFAULT). A live search force-expands any\n * stack with a hit; filter mode pulls a matched frame's WHOLE stack in (see {@link filtered}).\n * 2. Context expansion (filter mode) — between filtered chunks sits a \"⋯ N 行\" expander bar; click it to\n * progressively reveal hidden lines around a match (a chunk from the top, from the bottom, or all), the\n * way JetBrains' diff reveals collapsed unchanged regions. Revealed lines are tracked in {@link revealed}.\n * 3. Color rules — user-defined substring/regex rules paint matching lines' background/text (see\n * {@link useLogRules}); applied to visible rows only via {@link styleFor}.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst lines = ref<string[]>([])\n\n// Reading the whole file into memory is unavoidable here (the endpoint has no range support); gate the\n// truly huge ones behind a confirm.\nconst SIZE_LIMIT = 64 * 1024 * 1024\nconst LINE_H = 19 // px — MUST match the .log__row CSS height\nconst OVERSCAN = 12\nconst STEP = 10 // lines revealed per directional context-expand click\n\nconst scrollEl = ref<HTMLElement | null>(null)\nconst scrollTop = ref(0)\nconst viewportH = ref(400)\nlet ro: ResizeObserver | null = null\n\n// ---- stack-trace detection ----------------------------------------------------------------------\n// A stack group spans original lines [header .. frameEnd]; `header` is always `frameStart - 1` (the error /\n// \"Caused by:\" / \"Traceback:\" line just above the run), and the collapsible frames are [frameStart .. frameEnd].\ntype Group = { header: number; frameStart: number; frameEnd: number }\n\n// Only fold a run with at least this many frame lines (folding a lone frame saves nothing).\nconst MIN_FRAMES = 2\n\n// Indented stack-frame lines across common languages, matched on the raw line:\n// RE_AT Java/Kotlin/Scala/JS/Node/C#/.NET/PHP \" at pkg.Class.method(File:12)\" / \"at fn (file.js:1:2)\"\n// (requires a '.' or '(' after the symbol so a prose line like \" at 3pm the job ran\" won't match)\n// RE_FILE Python ' File \"/x.py\", line 10, in fn'\n// RE_FROM Ruby \" from a.rb:5:in `m'\"\n// RE_MORE Java \" ... 12 more\"\n// RE_NET C#/.NET async boundary \" --- End of stack trace from previous location ---\"\nconst RE_AT = /^[ \\t]+at\\s+\\S+[.(]/\nconst RE_FILE = /^[ \\t]+File\\s[\"']/\nconst RE_FROM = /^[ \\t]+from\\s+\\S+:\\d+/\nconst RE_MORE = /^[ \\t]*\\.\\.\\.\\s+\\d+\\s+more\\b/\nconst RE_NET = /^[ \\t]+--- End of /\nfunction isFrame(line: string): boolean {\n return (\n RE_AT.test(line) ||\n RE_FILE.test(line) ||\n RE_FROM.test(line) ||\n RE_MORE.test(line) ||\n RE_NET.test(line)\n )\n}\n\n// Group consecutive frame runs. Python frames come in two lines (the `File \"…\"` line plus the indented source\n// line under it) — absorb that trailing source line into the run so it folds with its frame.\nconst groups = computed<Group[]>(() => {\n const src = lines.value\n const N = src.length\n const out: Group[] = []\n let i = 0\n while (i < N) {\n if (!isFrame(src[i])) {\n i++\n continue\n }\n const runStart = i\n let prevFile = RE_FILE.test(src[i])\n i++\n while (i < N) {\n const ln = src[i]\n if (isFrame(ln)) {\n prevFile = RE_FILE.test(ln)\n i++\n } else if (prevFile && /^[ \\t]+\\S/.test(ln)) {\n prevFile = false // Python source line under a `File \"…\"` frame\n i++\n } else break\n }\n const runEnd = i - 1\n // Prefer the (non-blank) plain line just above the run as the header; otherwise treat the run's first line\n // as the header and fold the rest.\n const above = runStart - 1\n const useAbove = above >= 0 && !isFrame(src[above]) && src[above].trim() !== ''\n const frameStart = useAbove ? runStart : runStart + 1\n if (runEnd - frameStart + 1 >= MIN_FRAMES) {\n out.push({ header: frameStart - 1, frameStart, frameEnd: runEnd })\n }\n }\n return out\n})\n\nconst hasGroups = computed(() => groups.value.length > 0)\n\n// origLine → groupId, for header lines only.\nconst headerMap = computed(() => {\n const m = new Map<number, number>()\n groups.value.forEach((g, i) => m.set(g.header, i))\n return m\n})\n\n// The group whose span [header .. frameEnd] contains `orig` (groups are position-sorted & disjoint), or -1.\nfunction groupOf(orig: number): number {\n const gs = groups.value\n let lo = 0\n let hi = gs.length - 1\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n const g = gs[mid]\n if (orig < g.header) hi = mid - 1\n else if (orig > g.frameEnd) lo = mid + 1\n else return mid\n }\n return -1\n}\n\n// User-expanded group ids (default = absent = collapsed). Reset on each load.\nconst expanded = ref<Set<number>>(new Set())\nconst allExpanded = computed(() => hasGroups.value && expanded.value.size === groups.value.length)\n\nfunction toggleGroup(id: number): void {\n const s = new Set(expanded.value)\n if (s.has(id)) s.delete(id)\n else s.add(id)\n expanded.value = s\n}\nfunction toggleAll(): void {\n expanded.value = allExpanded.value ? new Set() : new Set(groups.value.map((_, i) => i))\n}\n\n// Is this group folded right now? Collapsed unless the user expanded it — OR a live query has a hit inside it\n// (force-expand so the match can't hide behind the fold).\nfunction isCollapsed(id: number): boolean {\n if (expanded.value.has(id)) return false\n if (debounced.value && includedGroups.value.has(id)) return false\n return true\n}\n\n// ---- color rules --------------------------------------------------------------------------------\n\nconst { rules, add: addRule, remove: removeRule, move: moveRule } = useLogRules()\nconst rulesOpen = ref(false)\n\n// Enabled, non-empty rules pre-compiled to a matcher (+ colors). Invalid regex / color-less rules are dropped.\nconst compiledRules = computed(() => {\n const out: { test: (s: string) => boolean; bg: string; fg: string }[] = []\n for (const r of rules.value) {\n if (!r.enabled || !r.pattern) continue\n if (!(r.bgOn || r.fgOn)) continue\n let test: ((s: string) => boolean) | null\n if (r.regex) {\n try {\n const re = new RegExp(r.pattern, r.caseSensitive ? '' : 'i')\n test = (s) => re.test(s)\n } catch {\n test = null // invalid regex → skip until the user fixes it\n }\n } else if (r.caseSensitive) {\n const n = r.pattern\n test = (s) => s.includes(n)\n } else {\n const n = r.pattern.toLowerCase()\n test = (s) => s.toLowerCase().includes(n)\n }\n if (test) out.push({ test, bg: r.bgOn ? r.bg : '', fg: r.fgOn ? r.fg : '' })\n }\n return out\n})\n\n// First matching rule's colors for a line, or null. Called per VISIBLE row only.\nfunction styleFor(text: string): { bg: string; fg: string } | null {\n for (const c of compiledRules.value) {\n if (c.test(text)) return { bg: c.bg, fg: c.fg }\n }\n return null\n}\n\n// A regex rule with a syntax error — used to flag the input red in the editor.\nfunction regexBad(r: ColorRule): boolean {\n if (!r.regex || !r.pattern) return false\n try {\n new RegExp(r.pattern)\n return false\n } catch {\n return true\n }\n}\n\n// ---- search -------------------------------------------------------------------------------------\n\nconst query = ref('')\nconst debounced = ref('')\nlet debounceTimer: ReturnType<typeof setTimeout> | undefined\nwatch(query, (q) => {\n clearTimeout(debounceTimer)\n debounceTimer = setTimeout(() => {\n debounced.value = q\n currentMatch.value = 0\n revealed.value = new Set() // context is relative to the current filter → drop it when the query changes\n }, 160)\n})\n\n// Clear the search box (× button / Esc) — apply immediately instead of waiting on the debounce.\nfunction clearSearch(): void {\n query.value = ''\n clearTimeout(debounceTimer)\n debounced.value = ''\n currentMatch.value = 0\n revealed.value = new Set()\n}\n\nconst filterMode = ref(false)\nconst errorOnly = ref(false)\nconst currentMatch = ref(0)\n\n// Original line indices (0-based) that contain the query.\nconst matchIdx = computed(() => {\n const q = debounced.value.toLowerCase()\n if (!q) return [] as number[]\n const out: number[] = []\n const src = lines.value\n for (let i = 0; i < src.length; i++) {\n if (src[i].toLowerCase().includes(q)) out.push(i)\n }\n return out\n})\n\nfunction hitsQuery(text: string, q: string): boolean {\n return q ? text.toLowerCase().includes(q.toLowerCase()) : false\n}\n\n// Groups with at least one match (header or any frame). Drives both force-expand and filter-keeps-stack.\nconst includedGroups = computed(() => {\n const s = new Set<number>()\n if (!debounced.value) return s\n for (const m of matchIdx.value) {\n const g = groupOf(m)\n if (g >= 0) s.add(g)\n }\n return s\n})\n\n// The currently highlighted match's original line index (for the active-row tint), or -1.\nconst activeOrig = computed(() => {\n const m = matchIdx.value\n return m.length ? m[Math.min(currentMatch.value, m.length - 1)] : -1\n})\n\n// ---- \"only errors\" filter -----------------------------------------------------------------------\n// Error / exception lines for the 只看异常 quick filter. Case-sensitive on purpose: uppercase level tokens,\n// PascalCase *Exception / *Error class names, and a few explicit markers. Lowercase \"error\"/\"warn\" are NOT\n// matched (too noisy in ordinary messages) — a stack's header line is picked up via headerMap instead.\nconst ERROR_RE =\n /\\b(?:ERROR|FATAL|SEVERE|CRITICAL|PANIC|FAILED|FAILURE)\\b|\\b\\w*(?:Exception|Error)\\b|\\bTraceback\\b|\\bCaused by\\b|^\\s*panic:/\n\n// Error-line indices (only computed while the toggle is on). Stack headers always count as errors.\nconst errorIdx = computed<number[]>(() => {\n if (!errorOnly.value) return []\n const src = lines.value\n const hdr = headerMap.value\n const out: number[] = []\n for (let i = 0; i < src.length; i++) {\n if (hdr.has(i) || ERROR_RE.test(src[i])) out.push(i)\n }\n return out\n})\nconst errorSet = computed(() => new Set(errorIdx.value))\n\n// The search \"过滤\" toggle only bites when there's a query.\nconst searchFilterActive = computed(() => filterMode.value && debounced.value !== '')\n\n// The anchor lines the filtered view is built around (null ⇒ no filter → show the whole log via `runs`).\n// Search + errors compose as an intersection (\"errors that also match the query\"); either alone is its own set.\nconst anchors = computed<number[] | null>(() => {\n const s = searchFilterActive.value\n const e = errorOnly.value\n if (!s && !e) return null\n if (s && e) {\n const es = errorSet.value\n return matchIdx.value.filter((i) => es.has(i))\n }\n return e ? errorIdx.value : matchIdx.value\n})\n\n// Keep whole stacks around anchors only when the search filter contributes (its contract: 过滤保留整条堆栈).\n// The pure 只看异常 view stays compact — one line per error, the stack peekable via the context expander.\nconst keepStacks = computed(() => searchFilterActive.value)\n\n// ---- context expansion (filter mode) ------------------------------------------------------------\n// Original line indices the user has manually revealed as context around matches. Reset on load / query change.\nconst revealed = ref<Set<number>>(new Set())\n// Switching filters changes what the gaps mean → drop revealed context.\nwatch([errorOnly, filterMode], () => {\n revealed.value = new Set()\n})\n\n// Reveal a chunk of a gap between shown lines (top, bottom are the shown lines bracketing it; -1 / N at file\n// edges): 'down' from the top match, 'up' toward the bottom match, or 'all'.\nfunction reveal(top: number, bottom: number, dir: 'up' | 'down' | 'all'): void {\n const lo = top + 1\n const hi = bottom - 1\n if (hi < lo) return\n let a = lo\n let b = hi\n if (dir === 'down') b = Math.min(hi, lo + STEP - 1)\n else if (dir === 'up') a = Math.max(lo, hi - STEP + 1)\n const s = new Set(revealed.value)\n for (let i = a; i <= b; i++) s.add(i)\n revealed.value = s\n}\n\n// ---- display layout -----------------------------------------------------------------------------\n// Two modes drive the virtual list:\n// - Non-filter: every line shows, minus the frames of collapsed groups. Expressed as `runs` — the visible\n// original-index segments between the hidden frame ranges — so display↔original stays a cheap binary search\n// (no per-line array; big files stay light).\n// - Filter: only matches show, but every included group contributes its WHOLE span so stacks stay intact,\n// plus any manually-revealed context lines; the remaining gaps become expander rows. Materialized as an\n// item list (bounded by what's shown, normally small).\n\n// Non-filter: visible segments + total row count.\nconst runs = computed(() => {\n const gs = groups.value\n const N = lines.value.length\n const out: { o0: number; o1: number; d0: number }[] = []\n let disp = 0\n let cursor = 0\n for (let id = 0; id < gs.length; id++) {\n if (!isCollapsed(id)) continue\n const g = gs[id]\n if (g.frameStart > cursor) {\n // visible run up to and including the header (header === frameStart - 1)\n out.push({ o0: cursor, o1: g.frameStart - 1, d0: disp })\n disp += g.frameStart - cursor\n }\n cursor = g.frameEnd + 1 // skip the folded frames\n }\n if (cursor < N) {\n out.push({ o0: cursor, o1: N - 1, d0: disp })\n disp += N - cursor\n }\n return { runs: out, total: disp }\n})\n\n// Filter-mode items: `line` rows plus `gap` expander rows. `key` is a parallel, strictly-increasing lookup\n// array (lines → their orig, gaps → top + 0.5) so origToDisplay can binary-search a match's row.\ntype FItem = { t: 'line'; orig: number } | { t: 'gap'; top: number; bottom: number; size: number }\nconst filtered = computed<{ items: FItem[]; key: number[] } | null>(() => {\n const anc = anchors.value\n if (anc === null) return null\n const N = lines.value.length\n const gs = groups.value\n // Stacks kept whole around anchors — only when the search filter contributes (see keepStacks).\n const ranges: [number, number][] = []\n if (keepStacks.value) {\n const inc = new Set<number>()\n for (const a of anc) {\n const g = groupOf(a)\n if (g >= 0) inc.add(g)\n }\n for (const id of inc) ranges.push([gs[id].header, gs[id].frameEnd])\n ranges.sort((a, b) => a[0] - b[0])\n }\n const covered = (o: number): boolean => {\n let lo = 0\n let hi = ranges.length - 1\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n if (o < ranges[mid][0]) hi = mid - 1\n else if (o > ranges[mid][1]) lo = mid + 1\n else return true\n }\n return false\n }\n // Standalone shown lines = anchors + revealed context, minus whatever a kept-whole stack already covers.\n const extra: number[] = []\n for (const a of anc) if (!covered(a)) extra.push(a)\n for (const r of revealed.value) if (!covered(r)) extra.push(r)\n extra.sort((a, b) => a - b)\n // Merge (sorted) range indices with (sorted) standalone lines → sorted, de-duped base set.\n const base: number[] = []\n let ri = 0\n let si = 0\n let cur = ranges.length ? ranges[0][0] : Infinity\n let last = -1\n while (ri < ranges.length || si < extra.length) {\n const rn = ri < ranges.length ? cur : Infinity\n const sn = si < extra.length ? extra[si] : Infinity\n let v: number\n if (rn <= sn) {\n v = rn\n if (cur >= ranges[ri][1]) {\n ri++\n cur = ri < ranges.length ? ranges[ri][0] : Infinity\n } else cur++\n } else {\n v = sn\n si++\n }\n if (v !== last) {\n base.push(v)\n last = v\n }\n }\n // Weave in gap expanders between non-adjacent shown lines (and at the file's head/tail).\n const items: FItem[] = []\n const key: number[] = []\n let prev = -1\n for (const o of base) {\n if (o - prev > 1) {\n items.push({ t: 'gap', top: prev, bottom: o, size: o - prev - 1 })\n key.push(prev + 0.5)\n }\n items.push({ t: 'line', orig: o })\n key.push(o)\n prev = o\n }\n if (prev < N - 1) {\n items.push({ t: 'gap', top: prev, bottom: N, size: N - 1 - prev })\n key.push(prev + 0.5)\n }\n return { items, key }\n})\n\nconst inFilter = computed(() => filtered.value !== null)\nconst total = computed(() => (filtered.value ? filtered.value.items.length : runs.value.total))\n\ntype RowKind = 'plain' | 'header' | 'frame'\ntype RowInfo = {\n n: number\n text: string\n kind: RowKind\n collapsed: boolean\n count: number\n gid: number\n}\n\nfunction rowInfo(orig: number): RowInfo {\n const text = lines.value[orig] ?? ''\n const gid = headerMap.value.get(orig)\n if (gid !== undefined) {\n const g = groups.value[gid]\n // Filter mode always shows groups expanded (frames must be visible to keep the stack), so no fold chrome.\n return {\n n: orig + 1,\n text,\n kind: 'header',\n collapsed: filtered.value ? false : isCollapsed(gid),\n count: g.frameEnd - g.frameStart + 1,\n gid,\n }\n }\n const fg = groupOf(orig)\n const kind: RowKind = fg >= 0 && orig >= groups.value[fg].frameStart ? 'frame' : 'plain'\n return { n: orig + 1, text, kind, collapsed: false, count: 0, gid: -1 }\n}\n\n// Non-filter: display row index → original line index (binary search over the visible runs).\nfunction origInRuns(rs: { o0: number; o1: number; d0: number }[], d: number): number {\n let lo = 0\n let hi = rs.length - 1\n let pick = 0\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n if (d < rs[mid].d0) hi = mid - 1\n else {\n pick = mid\n lo = mid + 1\n }\n }\n const r = rs[pick]\n return r ? r.o0 + (d - r.d0) : d\n}\n\nconst startIdx = computed(() => Math.max(0, Math.floor(scrollTop.value / LINE_H) - OVERSCAN))\nconst endIdx = computed(() =>\n Math.min(total.value, Math.ceil((scrollTop.value + viewportH.value) / LINE_H) + OVERSCAN),\n)\n\n// One flat shape for every visible row (content OR gap) so the template needs no union narrowing. Content\n// rows default the gap fields; gap rows default the content fields.\ntype VisRow = {\n i: number\n isGap: boolean\n n: number\n text: string\n kind: RowKind\n collapsed: boolean\n count: number\n gid: number\n style: { bg: string; fg: string } | null\n context: boolean\n top: number\n bottom: number\n size: number\n step: number\n}\nfunction contentRow(i: number, orig: number): VisRow {\n const info = rowInfo(orig)\n return {\n i,\n isGap: false,\n ...info,\n style: styleFor(info.text),\n // \"Context\" dimming is a filter-mode cue for manually-revealed lines; never dim in the full (non-filter)\n // view, where leftover `revealed` entries are irrelevant.\n context:\n filtered.value !== null && revealed.value.has(orig) && !hitsQuery(info.text, debounced.value),\n top: 0,\n bottom: 0,\n size: 0,\n step: 0,\n }\n}\nconst visible = computed<VisRow[]>(() => {\n const rows: VisRow[] = []\n const s = startIdx.value\n const e = endIdx.value\n const f = filtered.value\n if (f) {\n for (let i = s; i < e; i++) {\n const it = f.items[i]\n if (!it) continue\n if (it.t === 'gap') {\n rows.push({\n i,\n isGap: true,\n n: 0,\n text: '',\n kind: 'plain',\n collapsed: false,\n count: 0,\n gid: -1,\n style: null,\n context: false,\n top: it.top,\n bottom: it.bottom,\n size: it.size,\n step: Math.min(STEP, it.size),\n })\n } else {\n rows.push(contentRow(i, it.orig))\n }\n }\n } else {\n const rs = runs.value.runs\n for (let i = s; i < e; i++) rows.push(contentRow(i, origInRuns(rs, i)))\n }\n return rows\n})\n\nfunction escapeHtml(s: string): string {\n return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')\n}\n\n// Escape the line and wrap query occurrences in <mark> (matching on raw text, escaping each piece → safe).\nfunction renderLine(text: string): string {\n const q = debounced.value\n if (!q) return escapeHtml(text)\n const lower = text.toLowerCase()\n const ql = q.toLowerCase()\n let out = ''\n let i = 0\n let idx = lower.indexOf(ql, i)\n while (idx !== -1) {\n out += escapeHtml(text.slice(i, idx))\n out += '<mark class=\"log__hit\">' + escapeHtml(text.slice(idx, idx + q.length)) + '</mark>'\n i = idx + q.length\n idx = lower.indexOf(ql, i)\n }\n out += escapeHtml(text.slice(i))\n return out\n}\n\n// ---- navigation ---------------------------------------------------------------------------------\n\nfunction scrollToDisplay(i: number): void {\n const el = scrollEl.value\n if (el) el.scrollTop = Math.max(0, i * LINE_H - viewportH.value / 2 + LINE_H)\n}\n\n// Original line index → current display row index (or -1 if not shown). Folded frames redirect to their header.\nfunction origToDisplay(orig: number): number {\n const f = filtered.value\n if (f) {\n const k = f.key\n let lo = 0\n let hi = k.length - 1\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n if (k[mid] === orig) return mid\n if (k[mid] < orig) lo = mid + 1\n else hi = mid - 1\n }\n return -1\n }\n const g = groupOf(orig)\n if (g >= 0 && orig >= groups.value[g].frameStart && isCollapsed(g)) orig = groups.value[g].header\n const rs = runs.value.runs\n let lo = 0\n let hi = rs.length - 1\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n if (orig < rs[mid].o0) hi = mid - 1\n else if (orig > rs[mid].o1) lo = mid + 1\n else return rs[mid].d0 + (orig - rs[mid].o0)\n }\n return -1\n}\n\nfunction gotoMatch(delta: number): void {\n const m = matchIdx.value\n if (!m.length) return\n currentMatch.value = (currentMatch.value + delta + m.length) % m.length\n const d = origToDisplay(m[currentMatch.value])\n if (d >= 0) scrollToDisplay(d)\n}\n\nconst gotoLineInput = ref('')\nfunction gotoLine(): void {\n const n = parseInt(gotoLineInput.value, 10)\n if (!Number.isFinite(n)) return\n const orig = Math.max(0, Math.min(lines.value.length - 1, n - 1))\n const d = origToDisplay(orig)\n if (d >= 0) scrollToDisplay(d)\n}\n\nfunction onScroll(): void {\n if (scrollEl.value) scrollTop.value = scrollEl.value.scrollTop\n}\n\n// ---- load ---------------------------------------------------------------------------------------\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\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 text = await res.text()\n lines.value = text.split('\\n')\n // Drop a single trailing empty line (file ended with \\n) so there's no phantom last row.\n if (lines.value.length > 1 && lines.value[lines.value.length - 1] === '') lines.value.pop()\n expanded.value = new Set() // stacks fold by default on each fresh file\n revealed.value = new Set()\n scrollTop.value = 0\n if (scrollEl.value) scrollEl.value.scrollTop = 0\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\n// scrollEl lives inside the `status === 'ready'` branch (a v-else), so it does NOT exist at mount time —\n// it appears only after the file finishes loading. Watch the ref and attach the ResizeObserver when the\n// element shows up (and re-attach if the pane is torn down + rebuilt on reload), so viewportH reflects the\n// REAL pane height. Without this it stayed at the 400px default and only ~half a screen of rows rendered,\n// leaving the rest blank until you scrolled. flush:'post' so clientHeight is read after the DOM is painted.\nwatch(\n scrollEl,\n (el) => {\n ro?.disconnect()\n if (el && typeof ResizeObserver !== 'undefined') {\n ro = new ResizeObserver(() => {\n if (scrollEl.value) viewportH.value = scrollEl.value.clientHeight\n })\n ro.observe(el)\n viewportH.value = el.clientHeight\n }\n },\n { flush: 'post' },\n)\nwatch(() => props.url, load, { immediate: true })\nonBeforeUnmount(() => {\n clearTimeout(debounceTimer)\n ro?.disconnect()\n})\n</script>\n\n<template>\n <div class=\"log\">\n <p v-if=\"status === 'loading'\" class=\"log__msg\">正在加载…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"log__msg log__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内加载。是否继续?</p>\n <button type=\"button\" class=\"log__btn\" @click=\"doLoad\">加载并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"log__msg log__msg--error\">\n <p>加载失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"log__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else>\n <!-- toolbar: search + match nav + filter + fold-all + color rules + go-to-line -->\n <div class=\"log__bar\">\n <div class=\"log__searchwrap\">\n <input\n v-model=\"query\"\n class=\"log__search\"\n type=\"text\"\n placeholder=\"搜索…\"\n aria-label=\"搜索\"\n @keydown.esc=\"clearSearch\"\n />\n <button\n v-if=\"query\"\n type=\"button\"\n class=\"log__clear\"\n title=\"清空搜索\"\n aria-label=\"清空搜索\"\n @click=\"clearSearch\"\n >\n <Icon name=\"close\" :size=\"12\" />\n </button>\n </div>\n <span v-if=\"debounced\" class=\"log__matches\">\n {{ matchIdx.length ? currentMatch + 1 : 0 }}/{{ matchIdx.length }}\n </span>\n <button\n type=\"button\"\n class=\"log__nav\"\n title=\"上一处\"\n :disabled=\"!matchIdx.length\"\n @click=\"gotoMatch(-1)\"\n >\n ↑\n </button>\n <button\n type=\"button\"\n class=\"log__nav\"\n title=\"下一处\"\n :disabled=\"!matchIdx.length\"\n @click=\"gotoMatch(1)\"\n >\n ↓\n </button>\n <button\n type=\"button\"\n class=\"log__nav log__nav--text\"\n :class=\"{ 'log__nav--on': filterMode }\"\n :disabled=\"!debounced\"\n title=\"只看匹配行(命中的堆栈整条保留,行间可展开上下文)\"\n @click=\"filterMode = !filterMode\"\n >\n 过滤\n </button>\n <button\n type=\"button\"\n class=\"log__nav log__nav--text\"\n :class=\"{ 'log__nav--on': errorOnly }\"\n title=\"只显示异常/错误行(异常头、ERROR/FATAL、*Exception/*Error 等;可与搜索过滤叠加)\"\n @click=\"errorOnly = !errorOnly\"\n >\n 只看异常\n </button>\n <button\n v-if=\"hasGroups\"\n type=\"button\"\n class=\"log__nav log__nav--text\"\n :class=\"{ 'log__nav--on': allExpanded }\"\n :title=\"`共 ${groups.length} 处调用堆栈,点击${allExpanded ? '折叠' : '展开'}全部`\"\n @click=\"toggleAll\"\n >\n {{ allExpanded ? '折叠堆栈' : '展开堆栈' }}\n </button>\n <button\n type=\"button\"\n class=\"log__nav log__nav--text\"\n :class=\"{ 'log__nav--on': rulesOpen || compiledRules.length > 0 }\"\n title=\"按规则给行上色\"\n @click=\"rulesOpen = !rulesOpen\"\n >\n 上色\n </button>\n <span class=\"log__spacer\" />\n <input\n v-model=\"gotoLineInput\"\n class=\"log__goto\"\n type=\"text\"\n inputmode=\"numeric\"\n placeholder=\"行号\"\n aria-label=\"跳转到行\"\n @keydown.enter=\"gotoLine\"\n />\n <span class=\"log__total\">\n {{ lines.length }} 行<template v-if=\"hasGroups\"> · {{ groups.length }} 堆栈</template>\n </span>\n\n <!-- color-rules editor (dropdown). Backdrop closes on outside click. -->\n <div v-if=\"rulesOpen\" class=\"log__overlay\" @click=\"rulesOpen = false\" />\n <div v-if=\"rulesOpen\" class=\"log__rules\" @click.stop>\n <div class=\"log__rules-head\">\n <span class=\"log__rules-title\">行上色规则</span>\n <span class=\"log__rules-hint\">从上到下,首个匹配生效</span>\n </div>\n <p v-if=\"!rules.length\" class=\"log__rules-empty\">\n 还没有规则。添加一条,用关键字或正则给匹配行上色(如 <code>ERROR</code> 标红)。\n </p>\n <div v-for=\"(r, idx) in rules\" :key=\"r.id\" class=\"log__rule\">\n <input v-model=\"r.enabled\" type=\"checkbox\" title=\"启用该规则\" />\n <input\n v-model=\"r.pattern\"\n class=\"log__rule-pat\"\n :class=\"{ 'log__rule-pat--bad': regexBad(r) }\"\n type=\"text\"\n placeholder=\"关键字或正则\"\n />\n <button\n type=\"button\"\n class=\"log__rule-tog\"\n :class=\"{ 'log__rule-tog--on': r.regex }\"\n title=\"按正则匹配\"\n @click=\"r.regex = !r.regex\"\n >\n .*\n </button>\n <button\n type=\"button\"\n class=\"log__rule-tog\"\n :class=\"{ 'log__rule-tog--on': r.caseSensitive }\"\n title=\"区分大小写\"\n @click=\"r.caseSensitive = !r.caseSensitive\"\n >\n Aa\n </button>\n <label class=\"log__rule-color\" title=\"背景色\">\n <input v-model=\"r.bgOn\" type=\"checkbox\" />\n <input v-model=\"r.bg\" type=\"color\" :disabled=\"!r.bgOn\" />\n <span>底</span>\n </label>\n <label class=\"log__rule-color\" title=\"字体色\">\n <input v-model=\"r.fgOn\" type=\"checkbox\" />\n <input v-model=\"r.fg\" type=\"color\" :disabled=\"!r.fgOn\" />\n <span>字</span>\n </label>\n <button\n type=\"button\"\n class=\"log__rule-ic\"\n title=\"上移\"\n :disabled=\"idx === 0\"\n @click=\"moveRule(r.id, -1)\"\n >\n <Icon name=\"arrowUp\" :size=\"12\" />\n </button>\n <button\n type=\"button\"\n class=\"log__rule-ic\"\n title=\"下移\"\n :disabled=\"idx === rules.length - 1\"\n @click=\"moveRule(r.id, 1)\"\n >\n <Icon name=\"arrowDown\" :size=\"12\" />\n </button>\n <button\n type=\"button\"\n class=\"log__rule-ic log__rule-del\"\n title=\"删除\"\n @click=\"removeRule(r.id)\"\n >\n <Icon name=\"trash\" :size=\"12\" />\n </button>\n </div>\n <button type=\"button\" class=\"log__rules-add\" @click=\"addRule\">\n <Icon name=\"plus\" :size=\"12\" /> 添加规则\n </button>\n </div>\n </div>\n\n <!-- virtual list: a fixed-height sizer drives the scrollbar; only the visible window is mounted -->\n <div ref=\"scrollEl\" class=\"log__scroll\" @scroll=\"onScroll\">\n <div class=\"log__sizer\" :style=\"{ height: total * LINE_H + 'px' }\">\n <div class=\"log__rows\" :style=\"{ paddingTop: startIdx * LINE_H + 'px' }\">\n <div\n v-for=\"row in visible\"\n :key=\"row.i\"\n class=\"log__row\"\n :class=\"{\n log__gaprow: row.isGap,\n 'log__row--active': !row.isGap && row.n - 1 === activeOrig,\n 'log__row--header': !row.isGap && row.kind === 'header',\n 'log__row--frame': !row.isGap && row.kind === 'frame',\n 'log__row--context': row.context,\n }\"\n :style=\"\n !row.isGap && row.style && row.style.bg ? { background: row.style.bg } : undefined\n \"\n >\n <!-- gap expander (filter mode): reveal hidden context around matches -->\n <template v-if=\"row.isGap\">\n <button\n v-if=\"row.size > STEP\"\n type=\"button\"\n class=\"log__gapbtn\"\n :title=\"`向下展开 ${row.step} 行(上方匹配之后)`\"\n @click=\"reveal(row.top, row.bottom, 'down')\"\n >\n <Icon name=\"chevronDown\" :size=\"12\" />\n </button>\n <button\n type=\"button\"\n class=\"log__gapmain\"\n :title=\"`展开全部 ${row.size} 行`\"\n @click=\"reveal(row.top, row.bottom, 'all')\"\n >\n ⋯ {{ row.size }} 行\n </button>\n <button\n v-if=\"row.size > STEP\"\n type=\"button\"\n class=\"log__gapbtn log__gapbtn--up\"\n :title=\"`向上展开 ${row.step} 行(下方匹配之前)`\"\n @click=\"reveal(row.top, row.bottom, 'up')\"\n >\n <Icon name=\"chevronDown\" :size=\"12\" />\n </button>\n </template>\n\n <!-- content row -->\n <template v-else>\n <span class=\"log__ln\">{{ row.n }}</span>\n <span v-if=\"hasGroups\" class=\"log__fold-cell\">\n <button\n v-if=\"row.kind === 'header' && !inFilter\"\n type=\"button\"\n class=\"log__fold\"\n :title=\"row.collapsed ? '展开堆栈' : '折叠堆栈'\"\n @click=\"toggleGroup(row.gid)\"\n >\n <Icon\n name=\"chevronRight\"\n :size=\"12\"\n class=\"log__chev\"\n :class=\"{ 'log__chev--open': !row.collapsed }\"\n />\n </button>\n </span>\n <span\n class=\"log__tx\"\n :style=\"row.style && row.style.fg ? { color: row.style.fg } : undefined\"\n v-html=\"renderLine(row.text)\"\n />\n <button\n v-if=\"row.kind === 'header' && row.collapsed && !inFilter\"\n type=\"button\"\n class=\"log__badge\"\n title=\"展开堆栈\"\n @click=\"toggleGroup(row.gid)\"\n >\n ⋯ {{ row.count }} 帧\n </button>\n </template>\n </div>\n </div>\n </div>\n </div>\n </template>\n </div>\n</template>\n\n<style scoped>\n.log {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--code-bg);\n}\n\n.log__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.log__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.log__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.log__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.log__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.log__bar {\n position: relative;\n display: flex;\n align-items: center;\n gap: 6px;\n flex-shrink: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.log__searchwrap {\n position: relative;\n flex: 1 1 auto;\n min-width: 60px;\n display: flex;\n}\n.log__search {\n flex: 1 1 auto;\n min-width: 0;\n padding: 5px 28px 5px 10px;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--code-bg);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n}\n.log__search:focus,\n.log__goto:focus {\n outline: none;\n border-color: var(--accent);\n}\n.log__clear {\n position: absolute;\n top: 50%;\n right: 5px;\n transform: translateY(-50%);\n display: flex;\n align-items: center;\n justify-content: center;\n width: 19px;\n height: 19px;\n padding: 0;\n color: var(--faint);\n background: none;\n border: none;\n border-radius: 5px;\n cursor: pointer;\n}\n.log__clear:hover {\n color: var(--text);\n background: var(--surface-2);\n}\n.log__matches {\n flex-shrink: 0;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--faint);\n min-width: 38px;\n text-align: center;\n}\n.log__nav {\n flex-shrink: 0;\n min-width: 26px;\n height: 26px;\n padding: 0 6px;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n cursor: pointer;\n}\n.log__nav:hover:not(:disabled) {\n border-color: var(--accent);\n color: var(--accent);\n}\n.log__nav:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.log__nav--on {\n color: var(--accent);\n border-color: var(--accent);\n background: var(--surface-2);\n}\n.log__spacer {\n flex: 1 1 auto;\n}\n.log__goto {\n flex-shrink: 0;\n width: 64px;\n padding: 5px 8px;\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--text);\n background: var(--code-bg);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n}\n.log__total {\n flex-shrink: 0;\n font-size: 11.5px;\n color: var(--faint);\n white-space: nowrap;\n}\n\n/* color-rules dropdown */\n.log__overlay {\n position: fixed;\n inset: 0;\n z-index: 40;\n background: transparent;\n}\n.log__rules {\n position: absolute;\n z-index: 41;\n top: calc(100% + 4px);\n right: 12px;\n width: min(520px, calc(100vw - 24px));\n max-height: 320px;\n overflow: auto;\n padding: 10px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 10px;\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);\n}\n.log__rules-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n}\n.log__rules-title {\n font-size: 12.5px;\n font-weight: 600;\n color: var(--text);\n}\n.log__rules-hint {\n font-size: 11px;\n color: var(--faint);\n}\n.log__rules-empty {\n font-size: 12px;\n color: var(--muted);\n line-height: 1.5;\n margin: 2px 0;\n}\n.log__rules-empty code {\n font-family: var(--font-mono);\n font-size: 11px;\n padding: 0 4px;\n background: var(--code-bg);\n border-radius: 4px;\n}\n.log__rule {\n display: flex;\n align-items: center;\n gap: 5px;\n}\n.log__rule-pat {\n flex: 1 1 auto;\n min-width: 80px;\n padding: 4px 8px;\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--text);\n background: var(--code-bg);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n}\n.log__rule-pat:focus {\n outline: none;\n border-color: var(--accent);\n}\n.log__rule-pat--bad {\n border-color: var(--danger);\n color: var(--danger);\n}\n.log__rule-tog {\n flex-shrink: 0;\n width: 26px;\n height: 26px;\n padding: 0;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--muted);\n background: var(--code-bg);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n cursor: pointer;\n}\n.log__rule-tog--on {\n color: var(--accent);\n border-color: var(--accent);\n background: var(--surface-2);\n}\n.log__rule-color {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n gap: 3px;\n font-size: 11px;\n color: var(--faint);\n cursor: pointer;\n}\n.log__rule-color input[type='color'] {\n width: 22px;\n height: 22px;\n padding: 0;\n border: 1px solid var(--border-2);\n border-radius: 5px;\n background: none;\n cursor: pointer;\n}\n.log__rule-color input[type='color']:disabled {\n opacity: 0.35;\n cursor: default;\n}\n.log__rule-ic {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 24px;\n padding: 0;\n color: var(--muted);\n background: none;\n border: 1px solid transparent;\n border-radius: 6px;\n cursor: pointer;\n}\n.log__rule-ic:hover:not(:disabled) {\n color: var(--accent);\n border-color: var(--border-2);\n}\n.log__rule-ic:disabled {\n opacity: 0.3;\n cursor: default;\n}\n.log__rule-del:hover:not(:disabled) {\n color: var(--danger);\n}\n.log__rules-add {\n align-self: flex-start;\n display: inline-flex;\n align-items: center;\n gap: 5px;\n margin-top: 2px;\n padding: 4px 10px;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: var(--code-bg);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n cursor: pointer;\n}\n.log__rules-add:hover {\n color: var(--accent);\n border-color: var(--accent);\n}\n\n.log__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n}\n.log__sizer {\n position: relative;\n min-width: 100%;\n}\n.log__row {\n display: flex;\n align-items: flex-start;\n min-width: 100%;\n height: 19px;\n line-height: 19px;\n white-space: nowrap;\n font-family: var(--font-mono);\n font-size: 12.5px;\n}\n.log__row--active {\n background: color-mix(in srgb, var(--accent) 14%, transparent);\n}\n.log__row--context .log__tx {\n opacity: 0.66;\n}\n.log__ln {\n position: sticky;\n left: 0;\n flex-shrink: 0;\n width: 64px;\n padding-right: 12px;\n text-align: right;\n color: var(--faint);\n background: var(--code-bg);\n user-select: none;\n}\n.log__row--active .log__ln {\n background: color-mix(in srgb, var(--accent) 14%, var(--code-bg));\n}\n\n/* fold gutter — a fixed cell right after the line number, present for every row (only when the file has\n stacks) so header/frame/plain text all stay column-aligned. Holds the chevron on headers; draws a faint\n guide line down the left edge of frame rows to read the run as a nested block. */\n.log__fold-cell {\n flex-shrink: 0;\n width: 16px;\n align-self: stretch;\n display: flex;\n align-items: center;\n justify-content: center;\n user-select: none;\n}\n.log__row--frame .log__fold-cell {\n box-shadow: inset 1px 0 0 var(--border-2);\n}\n.log__fold {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n padding: 0;\n border: none;\n background: none;\n color: var(--faint);\n cursor: pointer;\n}\n.log__chev {\n transition: transform 0.12s ease;\n}\n.log__chev--open {\n transform: rotate(90deg);\n}\n.log__fold:hover .log__chev {\n color: var(--accent);\n}\n\n.log__tx {\n flex: 1 1 auto;\n padding-right: 18px;\n color: var(--text);\n white-space: pre;\n}\n.log__row--header .log__tx {\n color: var(--text);\n font-weight: 600;\n}\n.log__row--frame .log__tx {\n color: var(--muted);\n}\n.log__tx :deep(.log__hit) {\n background: #ffd43b;\n color: #1a1a1a;\n border-radius: 2px;\n}\n\n.log__badge {\n flex-shrink: 0;\n align-self: center;\n margin-left: 8px;\n padding: 0 8px;\n height: 15px;\n font-family: var(--font-mono);\n font-size: 10.5px;\n line-height: 13px;\n color: var(--faint);\n background: var(--surface-2);\n border: 1px solid var(--border-2);\n border-radius: 9px;\n cursor: pointer;\n user-select: none;\n}\n.log__badge:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* filter-mode context expander bar (one virtual row tall) */\n.log__gaprow {\n position: sticky;\n left: 0;\n align-items: center;\n justify-content: center;\n gap: 10px;\n background: var(--surface-2);\n color: var(--faint);\n user-select: none;\n}\n.log__gapbtn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 22px;\n height: 15px;\n padding: 0;\n color: var(--muted);\n background: none;\n border: none;\n cursor: pointer;\n}\n.log__gapbtn:hover {\n color: var(--accent);\n}\n.log__gapbtn--up {\n transform: rotate(180deg);\n}\n.log__gapmain {\n padding: 0 10px;\n height: 15px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--faint);\n background: none;\n border: none;\n cursor: pointer;\n}\n.log__gapmain:hover {\n color: var(--accent);\n}\n</style>\n"],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LogViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/LogViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\nimport { useLogRules, type ColorRule } from '@/composables/useLogRules'\n\n/**\n * Side-viewer for large text / log files. Fetches the bytes, splits into lines, and renders them with a\n * fixed-height VIRTUAL list — only the visible window (plus a little overscan) is ever in the DOM, so a\n * multi-hundred-MB log scrolls smoothly instead of locking the page (the plain source view would mount one\n * huge highlighted block). No syntax highlight (logs don't need it, and tokenizing 500k lines is infeasible).\n *\n * Routed here for any `.log`, and for any large text file (see FileChip's size gate) so a 50MB `.txt`/`.csv`\n * doesn't freeze the normal viewer. Tools: a sticky line-number gutter, substring search (highlight, match\n * count, prev/next, optional filter-to-matches), go-to-line, plus three log-specific aids:\n *\n * 1. Call-stack folding — error logs from common languages (Java/Kotlin/Scala, JS/Node, Python, C#/.NET,\n * Ruby) are one header line (\"Exception in thread…\", \"Traceback…\", \"Caused by:\") followed by a run of\n * indented frame lines (\" at …\", ' File \"…\"', …). We detect those runs, hang a chevron off the header,\n * and collapse the frames behind a \"N 帧\" badge (collapsed by DEFAULT). A live search force-expands any\n * stack with a hit; filter mode pulls a matched frame's WHOLE stack in (see {@link filtered}).\n * 2. Context expansion (filter mode) — between filtered chunks sits a \"⋯ N 行\" expander bar; click it to\n * progressively reveal hidden lines around a match (a chunk from the top, from the bottom, or all), the\n * way JetBrains' diff reveals collapsed unchanged regions. Revealed lines are tracked in {@link revealed}.\n * 3. Color rules — user-defined substring/regex rules paint matching lines' background/text (see\n * {@link useLogRules}); applied to visible rows only via {@link styleFor}.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst lines = ref<string[]>([])\n\n// Reading the whole file into memory is unavoidable here (the endpoint has no range support); gate the\n// truly huge ones behind a confirm.\nconst SIZE_LIMIT = 64 * 1024 * 1024\nconst LINE_H = 19 // px — MUST match the .log__row CSS height\nconst OVERSCAN = 12\nconst STEP = 10 // lines revealed per directional context-expand click\n\nconst scrollEl = ref<HTMLElement | null>(null)\nconst scrollTop = ref(0)\nconst viewportH = ref(400)\nlet ro: ResizeObserver | null = null\n\n// ---- stack-trace detection ----------------------------------------------------------------------\n// A stack group spans original lines [header .. frameEnd]; `header` is always `frameStart - 1` (the error /\n// \"Caused by:\" / \"Traceback:\" line just above the run), and the collapsible frames are [frameStart .. frameEnd].\ntype Group = { header: number; frameStart: number; frameEnd: number }\n\n// Only fold a run with at least this many frame lines (folding a lone frame saves nothing).\nconst MIN_FRAMES = 2\n\n// Indented stack-frame lines across common languages, matched on the raw line:\n// RE_AT Java/Kotlin/Scala/JS/Node/C#/.NET/PHP \" at pkg.Class.method(File:12)\" / \"at fn (file.js:1:2)\"\n// (requires a '.' or '(' after the symbol so a prose line like \" at 3pm the job ran\" won't match)\n// RE_FILE Python ' File \"/x.py\", line 10, in fn'\n// RE_FROM Ruby \" from a.rb:5:in `m'\"\n// RE_MORE Java \" ... 12 more\"\n// RE_NET C#/.NET async boundary \" --- End of stack trace from previous location ---\"\nconst RE_AT = /^[ \\t]+at\\s+\\S+[.(]/\nconst RE_FILE = /^[ \\t]+File\\s[\"']/\nconst RE_FROM = /^[ \\t]+from\\s+\\S+:\\d+/\nconst RE_MORE = /^[ \\t]*\\.\\.\\.\\s+\\d+\\s+more\\b/\nconst RE_NET = /^[ \\t]+--- End of /\nfunction isFrame(line: string): boolean {\n return (\n RE_AT.test(line) ||\n RE_FILE.test(line) ||\n RE_FROM.test(line) ||\n RE_MORE.test(line) ||\n RE_NET.test(line)\n )\n}\n\n// Group consecutive frame runs. Python frames come in two lines (the `File \"…\"` line plus the indented source\n// line under it) — absorb that trailing source line into the run so it folds with its frame.\nconst groups = computed<Group[]>(() => {\n const src = lines.value\n const N = src.length\n const out: Group[] = []\n let i = 0\n while (i < N) {\n if (!isFrame(src[i])) {\n i++\n continue\n }\n const runStart = i\n let prevFile = RE_FILE.test(src[i])\n i++\n while (i < N) {\n const ln = src[i]\n if (isFrame(ln)) {\n prevFile = RE_FILE.test(ln)\n i++\n } else if (prevFile && /^[ \\t]+\\S/.test(ln)) {\n prevFile = false // Python source line under a `File \"…\"` frame\n i++\n } else break\n }\n const runEnd = i - 1\n // Prefer the (non-blank) plain line just above the run as the header; otherwise treat the run's first line\n // as the header and fold the rest.\n const above = runStart - 1\n const useAbove = above >= 0 && !isFrame(src[above]) && src[above].trim() !== ''\n const frameStart = useAbove ? runStart : runStart + 1\n if (runEnd - frameStart + 1 >= MIN_FRAMES) {\n out.push({ header: frameStart - 1, frameStart, frameEnd: runEnd })\n }\n }\n return out\n})\n\nconst hasGroups = computed(() => groups.value.length > 0)\n\n// origLine → groupId, for header lines only.\nconst headerMap = computed(() => {\n const m = new Map<number, number>()\n groups.value.forEach((g, i) => m.set(g.header, i))\n return m\n})\n\n// The group whose span [header .. frameEnd] contains `orig` (groups are position-sorted & disjoint), or -1.\nfunction groupOf(orig: number): number {\n const gs = groups.value\n let lo = 0\n let hi = gs.length - 1\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n const g = gs[mid]\n if (orig < g.header) hi = mid - 1\n else if (orig > g.frameEnd) lo = mid + 1\n else return mid\n }\n return -1\n}\n\n// User-expanded group ids (default = absent = collapsed). Reset on each load.\nconst expanded = ref<Set<number>>(new Set())\nconst allExpanded = computed(() => hasGroups.value && expanded.value.size === groups.value.length)\n\nfunction toggleGroup(id: number): void {\n const s = new Set(expanded.value)\n if (s.has(id)) s.delete(id)\n else s.add(id)\n expanded.value = s\n}\nfunction toggleAll(): void {\n expanded.value = allExpanded.value ? new Set() : new Set(groups.value.map((_, i) => i))\n}\n\n// Is this group folded right now? Collapsed unless the user expanded it — OR a live query has a hit inside it\n// (force-expand so the match can't hide behind the fold).\nfunction isCollapsed(id: number): boolean {\n if (expanded.value.has(id)) return false\n if (debounced.value && includedGroups.value.has(id)) return false\n return true\n}\n\n// ---- color rules --------------------------------------------------------------------------------\n\nconst { rules, add: addRule, remove: removeRule, move: moveRule } = useLogRules()\nconst rulesOpen = ref(false)\n\n// Enabled, non-empty rules pre-compiled to a matcher (+ colors). Invalid regex / color-less rules are dropped.\nconst compiledRules = computed(() => {\n const out: { test: (s: string) => boolean; bg: string; fg: string }[] = []\n for (const r of rules.value) {\n if (!r.enabled || !r.pattern) continue\n if (!(r.bgOn || r.fgOn)) continue\n let test: ((s: string) => boolean) | null\n if (r.regex) {\n try {\n const re = new RegExp(r.pattern, r.caseSensitive ? '' : 'i')\n test = (s) => re.test(s)\n } catch {\n test = null // invalid regex → skip until the user fixes it\n }\n } else if (r.caseSensitive) {\n const n = r.pattern\n test = (s) => s.includes(n)\n } else {\n const n = r.pattern.toLowerCase()\n test = (s) => s.toLowerCase().includes(n)\n }\n if (test) out.push({ test, bg: r.bgOn ? r.bg : '', fg: r.fgOn ? r.fg : '' })\n }\n return out\n})\n\n// First matching rule's colors for a line, or null. Called per VISIBLE row only.\nfunction styleFor(text: string): { bg: string; fg: string } | null {\n for (const c of compiledRules.value) {\n if (c.test(text)) return { bg: c.bg, fg: c.fg }\n }\n return null\n}\n\n// A regex rule with a syntax error — used to flag the input red in the editor.\nfunction regexBad(r: ColorRule): boolean {\n if (!r.regex || !r.pattern) return false\n try {\n new RegExp(r.pattern)\n return false\n } catch {\n return true\n }\n}\n\n// ---- search -------------------------------------------------------------------------------------\n\nconst query = ref('')\nconst debounced = ref('')\nlet debounceTimer: ReturnType<typeof setTimeout> | undefined\nwatch(query, (q) => {\n clearTimeout(debounceTimer)\n debounceTimer = setTimeout(() => {\n debounced.value = q\n currentMatch.value = 0\n revealed.value = new Set() // context is relative to the current filter → drop it when the query changes\n }, 160)\n})\n\n// Clear the search box (× button / Esc) — apply immediately instead of waiting on the debounce.\nfunction clearSearch(): void {\n query.value = ''\n clearTimeout(debounceTimer)\n debounced.value = ''\n currentMatch.value = 0\n revealed.value = new Set()\n}\n\nconst filterMode = ref(false)\nconst errorOnly = ref(false)\nconst currentMatch = ref(0)\n\n// Original line indices (0-based) that contain the query.\nconst matchIdx = computed(() => {\n const q = debounced.value.toLowerCase()\n if (!q) return [] as number[]\n const out: number[] = []\n const src = lines.value\n for (let i = 0; i < src.length; i++) {\n if (src[i].toLowerCase().includes(q)) out.push(i)\n }\n return out\n})\n\nfunction hitsQuery(text: string, q: string): boolean {\n return q ? text.toLowerCase().includes(q.toLowerCase()) : false\n}\n\n// Groups with at least one match (header or any frame). Drives both force-expand and filter-keeps-stack.\nconst includedGroups = computed(() => {\n const s = new Set<number>()\n if (!debounced.value) return s\n for (const m of matchIdx.value) {\n const g = groupOf(m)\n if (g >= 0) s.add(g)\n }\n return s\n})\n\n// The currently highlighted match's original line index (for the active-row tint), or -1.\nconst activeOrig = computed(() => {\n const m = matchIdx.value\n return m.length ? m[Math.min(currentMatch.value, m.length - 1)] : -1\n})\n\n// ---- \"only errors\" filter -----------------------------------------------------------------------\n// Error / exception lines for the 只看异常 quick filter. Case-sensitive on purpose: uppercase level tokens,\n// PascalCase *Exception / *Error class names, and a few explicit markers. Lowercase \"error\"/\"warn\" are NOT\n// matched (too noisy in ordinary messages) — a stack's header line is picked up via headerMap instead.\nconst ERROR_RE =\n /\\b(?:ERROR|FATAL|SEVERE|CRITICAL|PANIC|FAILED|FAILURE)\\b|\\b\\w*(?:Exception|Error)\\b|\\bTraceback\\b|\\bCaused by\\b|^\\s*panic:/\n\n// Error-line indices (only computed while the toggle is on). Stack headers always count as errors.\nconst errorIdx = computed<number[]>(() => {\n if (!errorOnly.value) return []\n const src = lines.value\n const hdr = headerMap.value\n const out: number[] = []\n for (let i = 0; i < src.length; i++) {\n if (hdr.has(i) || ERROR_RE.test(src[i])) out.push(i)\n }\n return out\n})\nconst errorSet = computed(() => new Set(errorIdx.value))\n\n// The search \"过滤\" toggle only bites when there's a query.\nconst searchFilterActive = computed(() => filterMode.value && debounced.value !== '')\n\n// The anchor lines the filtered view is built around (null ⇒ no filter → show the whole log via `runs`).\n// Search + errors compose as an intersection (\"errors that also match the query\"); either alone is its own set.\nconst anchors = computed<number[] | null>(() => {\n const s = searchFilterActive.value\n const e = errorOnly.value\n if (!s && !e) return null\n if (s && e) {\n const es = errorSet.value\n return matchIdx.value.filter((i) => es.has(i))\n }\n return e ? errorIdx.value : matchIdx.value\n})\n\n// Keep whole stacks around anchors only when the search filter contributes (its contract: 过滤保留整条堆栈).\n// The pure 只看异常 view stays compact — one line per error, the stack peekable via the context expander.\nconst keepStacks = computed(() => searchFilterActive.value)\n\n// ---- context expansion (filter mode) ------------------------------------------------------------\n// Original line indices the user has manually revealed as context around matches. Reset on load / query change.\nconst revealed = ref<Set<number>>(new Set())\n// Switching filters changes what the gaps mean → drop revealed context.\nwatch([errorOnly, filterMode], () => {\n revealed.value = new Set()\n})\n\n// Reveal a chunk of a gap between shown lines (top, bottom are the shown lines bracketing it; -1 / N at file\n// edges): 'down' from the top match, 'up' toward the bottom match, or 'all'.\nfunction reveal(top: number, bottom: number, dir: 'up' | 'down' | 'all'): void {\n const lo = top + 1\n const hi = bottom - 1\n if (hi < lo) return\n let a = lo\n let b = hi\n if (dir === 'down') b = Math.min(hi, lo + STEP - 1)\n else if (dir === 'up') a = Math.max(lo, hi - STEP + 1)\n const s = new Set(revealed.value)\n for (let i = a; i <= b; i++) s.add(i)\n revealed.value = s\n}\n\n// ---- display layout -----------------------------------------------------------------------------\n// Two modes drive the virtual list:\n// - Non-filter: every line shows, minus the frames of collapsed groups. Expressed as `runs` — the visible\n// original-index segments between the hidden frame ranges — so display↔original stays a cheap binary search\n// (no per-line array; big files stay light).\n// - Filter: only matches show, but every included group contributes its WHOLE span so stacks stay intact,\n// plus any manually-revealed context lines; the remaining gaps become expander rows. Materialized as an\n// item list (bounded by what's shown, normally small).\n\n// Non-filter: visible segments + total row count.\nconst runs = computed(() => {\n const gs = groups.value\n const N = lines.value.length\n const out: { o0: number; o1: number; d0: number }[] = []\n let disp = 0\n let cursor = 0\n for (let id = 0; id < gs.length; id++) {\n if (!isCollapsed(id)) continue\n const g = gs[id]\n if (g.frameStart > cursor) {\n // visible run up to and including the header (header === frameStart - 1)\n out.push({ o0: cursor, o1: g.frameStart - 1, d0: disp })\n disp += g.frameStart - cursor\n }\n cursor = g.frameEnd + 1 // skip the folded frames\n }\n if (cursor < N) {\n out.push({ o0: cursor, o1: N - 1, d0: disp })\n disp += N - cursor\n }\n return { runs: out, total: disp }\n})\n\n// Filter-mode items: `line` rows plus `gap` expander rows. `key` is a parallel, strictly-increasing lookup\n// array (lines → their orig, gaps → top + 0.5) so origToDisplay can binary-search a match's row.\ntype FItem = { t: 'line'; orig: number } | { t: 'gap'; top: number; bottom: number; size: number }\nconst filtered = computed<{ items: FItem[]; key: number[] } | null>(() => {\n const anc = anchors.value\n if (anc === null) return null\n const N = lines.value.length\n const gs = groups.value\n // Stacks kept whole around anchors — only when the search filter contributes (see keepStacks).\n const ranges: [number, number][] = []\n if (keepStacks.value) {\n const inc = new Set<number>()\n for (const a of anc) {\n const g = groupOf(a)\n if (g >= 0) inc.add(g)\n }\n for (const id of inc) ranges.push([gs[id].header, gs[id].frameEnd])\n ranges.sort((a, b) => a[0] - b[0])\n }\n const covered = (o: number): boolean => {\n let lo = 0\n let hi = ranges.length - 1\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n if (o < ranges[mid][0]) hi = mid - 1\n else if (o > ranges[mid][1]) lo = mid + 1\n else return true\n }\n return false\n }\n // Standalone shown lines = anchors + revealed context, minus whatever a kept-whole stack already covers.\n const extra: number[] = []\n for (const a of anc) if (!covered(a)) extra.push(a)\n for (const r of revealed.value) if (!covered(r)) extra.push(r)\n extra.sort((a, b) => a - b)\n // Merge (sorted) range indices with (sorted) standalone lines → sorted, de-duped base set.\n const base: number[] = []\n let ri = 0\n let si = 0\n let cur = ranges.length ? ranges[0][0] : Infinity\n let last = -1\n while (ri < ranges.length || si < extra.length) {\n const rn = ri < ranges.length ? cur : Infinity\n const sn = si < extra.length ? extra[si] : Infinity\n let v: number\n if (rn <= sn) {\n v = rn\n if (cur >= ranges[ri][1]) {\n ri++\n cur = ri < ranges.length ? ranges[ri][0] : Infinity\n } else cur++\n } else {\n v = sn\n si++\n }\n if (v !== last) {\n base.push(v)\n last = v\n }\n }\n // Weave in gap expanders between non-adjacent shown lines (and at the file's head/tail).\n const items: FItem[] = []\n const key: number[] = []\n let prev = -1\n for (const o of base) {\n if (o - prev > 1) {\n items.push({ t: 'gap', top: prev, bottom: o, size: o - prev - 1 })\n key.push(prev + 0.5)\n }\n items.push({ t: 'line', orig: o })\n key.push(o)\n prev = o\n }\n if (prev < N - 1) {\n items.push({ t: 'gap', top: prev, bottom: N, size: N - 1 - prev })\n key.push(prev + 0.5)\n }\n return { items, key }\n})\n\nconst inFilter = computed(() => filtered.value !== null)\nconst total = computed(() => (filtered.value ? filtered.value.items.length : runs.value.total))\n\ntype RowKind = 'plain' | 'header' | 'frame'\ntype RowInfo = {\n n: number\n text: string\n kind: RowKind\n collapsed: boolean\n count: number\n gid: number\n}\n\nfunction rowInfo(orig: number): RowInfo {\n const text = lines.value[orig] ?? ''\n const gid = headerMap.value.get(orig)\n if (gid !== undefined) {\n const g = groups.value[gid]\n // Filter mode always shows groups expanded (frames must be visible to keep the stack), so no fold chrome.\n return {\n n: orig + 1,\n text,\n kind: 'header',\n collapsed: filtered.value ? false : isCollapsed(gid),\n count: g.frameEnd - g.frameStart + 1,\n gid,\n }\n }\n const fg = groupOf(orig)\n const kind: RowKind = fg >= 0 && orig >= groups.value[fg].frameStart ? 'frame' : 'plain'\n return { n: orig + 1, text, kind, collapsed: false, count: 0, gid: -1 }\n}\n\n// Non-filter: display row index → original line index (binary search over the visible runs).\nfunction origInRuns(rs: { o0: number; o1: number; d0: number }[], d: number): number {\n let lo = 0\n let hi = rs.length - 1\n let pick = 0\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n if (d < rs[mid].d0) hi = mid - 1\n else {\n pick = mid\n lo = mid + 1\n }\n }\n const r = rs[pick]\n return r ? r.o0 + (d - r.d0) : d\n}\n\nconst startIdx = computed(() => Math.max(0, Math.floor(scrollTop.value / LINE_H) - OVERSCAN))\nconst endIdx = computed(() =>\n Math.min(total.value, Math.ceil((scrollTop.value + viewportH.value) / LINE_H) + OVERSCAN),\n)\n\n// One flat shape for every visible row (content OR gap) so the template needs no union narrowing. Content\n// rows default the gap fields; gap rows default the content fields.\ntype VisRow = {\n i: number\n isGap: boolean\n n: number\n text: string\n kind: RowKind\n collapsed: boolean\n count: number\n gid: number\n style: { bg: string; fg: string } | null\n context: boolean\n top: number\n bottom: number\n size: number\n step: number\n}\nfunction contentRow(i: number, orig: number): VisRow {\n const info = rowInfo(orig)\n return {\n i,\n isGap: false,\n ...info,\n style: styleFor(info.text),\n // \"Context\" dimming is a filter-mode cue for manually-revealed lines; never dim in the full (non-filter)\n // view, where leftover `revealed` entries are irrelevant.\n context:\n filtered.value !== null && revealed.value.has(orig) && !hitsQuery(info.text, debounced.value),\n top: 0,\n bottom: 0,\n size: 0,\n step: 0,\n }\n}\nconst visible = computed<VisRow[]>(() => {\n const rows: VisRow[] = []\n const s = startIdx.value\n const e = endIdx.value\n const f = filtered.value\n if (f) {\n for (let i = s; i < e; i++) {\n const it = f.items[i]\n if (!it) continue\n if (it.t === 'gap') {\n rows.push({\n i,\n isGap: true,\n n: 0,\n text: '',\n kind: 'plain',\n collapsed: false,\n count: 0,\n gid: -1,\n style: null,\n context: false,\n top: it.top,\n bottom: it.bottom,\n size: it.size,\n step: Math.min(STEP, it.size),\n })\n } else {\n rows.push(contentRow(i, it.orig))\n }\n }\n } else {\n const rs = runs.value.runs\n for (let i = s; i < e; i++) rows.push(contentRow(i, origInRuns(rs, i)))\n }\n return rows\n})\n\nfunction escapeHtml(s: string): string {\n return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')\n}\n\n// Escape the line and wrap query occurrences in <mark> (matching on raw text, escaping each piece → safe).\nfunction renderLine(text: string): string {\n const q = debounced.value\n if (!q) return escapeHtml(text)\n const lower = text.toLowerCase()\n const ql = q.toLowerCase()\n let out = ''\n let i = 0\n let idx = lower.indexOf(ql, i)\n while (idx !== -1) {\n out += escapeHtml(text.slice(i, idx))\n out += '<mark class=\"log__hit\">' + escapeHtml(text.slice(idx, idx + q.length)) + '</mark>'\n i = idx + q.length\n idx = lower.indexOf(ql, i)\n }\n out += escapeHtml(text.slice(i))\n return out\n}\n\n// ---- navigation ---------------------------------------------------------------------------------\n\nfunction scrollToDisplay(i: number): void {\n const el = scrollEl.value\n if (el) el.scrollTop = Math.max(0, i * LINE_H - viewportH.value / 2 + LINE_H)\n}\n\n// Original line index → current display row index (or -1 if not shown). Folded frames redirect to their header.\nfunction origToDisplay(orig: number): number {\n const f = filtered.value\n if (f) {\n const k = f.key\n let lo = 0\n let hi = k.length - 1\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n if (k[mid] === orig) return mid\n if (k[mid] < orig) lo = mid + 1\n else hi = mid - 1\n }\n return -1\n }\n const g = groupOf(orig)\n if (g >= 0 && orig >= groups.value[g].frameStart && isCollapsed(g)) orig = groups.value[g].header\n const rs = runs.value.runs\n let lo = 0\n let hi = rs.length - 1\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n if (orig < rs[mid].o0) hi = mid - 1\n else if (orig > rs[mid].o1) lo = mid + 1\n else return rs[mid].d0 + (orig - rs[mid].o0)\n }\n return -1\n}\n\nfunction gotoMatch(delta: number): void {\n const m = matchIdx.value\n if (!m.length) return\n currentMatch.value = (currentMatch.value + delta + m.length) % m.length\n const d = origToDisplay(m[currentMatch.value])\n if (d >= 0) scrollToDisplay(d)\n}\n\nconst gotoLineInput = ref('')\nfunction gotoLine(): void {\n const n = parseInt(gotoLineInput.value, 10)\n if (!Number.isFinite(n)) return\n const orig = Math.max(0, Math.min(lines.value.length - 1, n - 1))\n const d = origToDisplay(orig)\n if (d >= 0) scrollToDisplay(d)\n}\n\nfunction onScroll(): void {\n if (scrollEl.value) scrollTop.value = scrollEl.value.scrollTop\n}\n\n// ---- load ---------------------------------------------------------------------------------------\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\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 text = await res.text()\n lines.value = text.split('\\n')\n // Drop a single trailing empty line (file ended with \\n) so there's no phantom last row.\n if (lines.value.length > 1 && lines.value[lines.value.length - 1] === '') lines.value.pop()\n expanded.value = new Set() // stacks fold by default on each fresh file\n revealed.value = new Set()\n scrollTop.value = 0\n if (scrollEl.value) scrollEl.value.scrollTop = 0\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\n// scrollEl lives inside the `status === 'ready'` branch (a v-else), so it does NOT exist at mount time —\n// it appears only after the file finishes loading. Watch the ref and attach the ResizeObserver when the\n// element shows up (and re-attach if the pane is torn down + rebuilt on reload), so viewportH reflects the\n// REAL pane height. Without this it stayed at the 400px default and only ~half a screen of rows rendered,\n// leaving the rest blank until you scrolled. flush:'post' so clientHeight is read after the DOM is painted.\nwatch(\n scrollEl,\n (el) => {\n ro?.disconnect()\n if (el && typeof ResizeObserver !== 'undefined') {\n ro = new ResizeObserver(() => {\n if (scrollEl.value) viewportH.value = scrollEl.value.clientHeight\n })\n ro.observe(el)\n viewportH.value = el.clientHeight\n }\n },\n { flush: 'post' },\n)\nwatch(() => props.url, load, { immediate: true })\nonBeforeUnmount(() => {\n clearTimeout(debounceTimer)\n ro?.disconnect()\n})\n</script>\n\n<template>\n <div class=\"log\">\n <p v-if=\"status === 'loading'\" class=\"log__msg\">正在加载…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"log__msg log__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内加载。是否继续?</p>\n <button type=\"button\" class=\"log__btn\" @click=\"doLoad\">加载并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"log__msg log__msg--error\">\n <p>加载失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"log__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else>\n <!-- toolbar: search + match nav + filter + fold-all + color rules + go-to-line -->\n <div class=\"log__bar\">\n <div class=\"log__searchwrap\">\n <input\n v-model=\"query\"\n class=\"log__search\"\n type=\"text\"\n placeholder=\"搜索…\"\n aria-label=\"搜索\"\n @keydown.esc=\"clearSearch\"\n />\n <button\n v-if=\"query\"\n type=\"button\"\n class=\"log__clear\"\n title=\"清空搜索\"\n aria-label=\"清空搜索\"\n @click=\"clearSearch\"\n >\n <Icon name=\"close\" :size=\"12\" />\n </button>\n </div>\n <span v-if=\"debounced\" class=\"log__matches\">\n {{ matchIdx.length ? currentMatch + 1 : 0 }}/{{ matchIdx.length }}\n </span>\n <button\n type=\"button\"\n class=\"log__nav\"\n title=\"上一处\"\n :disabled=\"!matchIdx.length\"\n @click=\"gotoMatch(-1)\"\n >\n ↑\n </button>\n <button\n type=\"button\"\n class=\"log__nav\"\n title=\"下一处\"\n :disabled=\"!matchIdx.length\"\n @click=\"gotoMatch(1)\"\n >\n ↓\n </button>\n <button\n type=\"button\"\n class=\"log__nav log__nav--text\"\n :class=\"{ 'log__nav--on': filterMode }\"\n :disabled=\"!debounced\"\n title=\"只看匹配行(命中的堆栈整条保留,行间可展开上下文)\"\n @click=\"filterMode = !filterMode\"\n >\n 过滤\n </button>\n <button\n type=\"button\"\n class=\"log__nav log__nav--text\"\n :class=\"{ 'log__nav--on': errorOnly }\"\n title=\"只显示异常/错误行(异常头、ERROR/FATAL、*Exception/*Error 等;可与搜索过滤叠加)\"\n @click=\"errorOnly = !errorOnly\"\n >\n 只看异常\n </button>\n <button\n v-if=\"hasGroups\"\n type=\"button\"\n class=\"log__nav log__nav--text\"\n :class=\"{ 'log__nav--on': allExpanded }\"\n :title=\"`共 ${groups.length} 处调用堆栈,点击${allExpanded ? '折叠' : '展开'}全部`\"\n @click=\"toggleAll\"\n >\n {{ allExpanded ? '折叠堆栈' : '展开堆栈' }}\n </button>\n <button\n type=\"button\"\n class=\"log__nav log__nav--text\"\n :class=\"{ 'log__nav--on': rulesOpen || compiledRules.length > 0 }\"\n title=\"按规则给行上色\"\n @click=\"rulesOpen = !rulesOpen\"\n >\n 上色\n </button>\n <span class=\"log__spacer\" />\n <input\n v-model=\"gotoLineInput\"\n class=\"log__goto\"\n type=\"text\"\n inputmode=\"numeric\"\n placeholder=\"行号\"\n aria-label=\"跳转到行\"\n @keydown.enter=\"gotoLine\"\n />\n <span class=\"log__total\">\n {{ lines.length }} 行<template v-if=\"hasGroups\"> · {{ groups.length }} 堆栈</template>\n </span>\n\n <!-- color-rules editor (dropdown). Backdrop closes on outside click. -->\n <div v-if=\"rulesOpen\" class=\"log__overlay\" @click=\"rulesOpen = false\" />\n <div v-if=\"rulesOpen\" class=\"log__rules\" @click.stop>\n <div class=\"log__rules-head\">\n <span class=\"log__rules-title\">行上色规则</span>\n <span class=\"log__rules-hint\">从上到下,首个匹配生效</span>\n </div>\n <p v-if=\"!rules.length\" class=\"log__rules-empty\">\n 还没有规则。添加一条,用关键字或正则给匹配行上色(如 <code>ERROR</code> 标红)。\n </p>\n <div v-for=\"(r, idx) in rules\" :key=\"r.id\" class=\"log__rule\">\n <input v-model=\"r.enabled\" type=\"checkbox\" title=\"启用该规则\" />\n <input\n v-model=\"r.pattern\"\n class=\"log__rule-pat\"\n :class=\"{ 'log__rule-pat--bad': regexBad(r) }\"\n type=\"text\"\n placeholder=\"关键字或正则\"\n />\n <button\n type=\"button\"\n class=\"log__rule-tog\"\n :class=\"{ 'log__rule-tog--on': r.regex }\"\n title=\"按正则匹配\"\n @click=\"r.regex = !r.regex\"\n >\n .*\n </button>\n <button\n type=\"button\"\n class=\"log__rule-tog\"\n :class=\"{ 'log__rule-tog--on': r.caseSensitive }\"\n title=\"区分大小写\"\n @click=\"r.caseSensitive = !r.caseSensitive\"\n >\n Aa\n </button>\n <label class=\"log__rule-color\" title=\"背景色\">\n <input v-model=\"r.bgOn\" type=\"checkbox\" />\n <input v-model=\"r.bg\" type=\"color\" :disabled=\"!r.bgOn\" />\n <span>底</span>\n </label>\n <label class=\"log__rule-color\" title=\"字体色\">\n <input v-model=\"r.fgOn\" type=\"checkbox\" />\n <input v-model=\"r.fg\" type=\"color\" :disabled=\"!r.fgOn\" />\n <span>字</span>\n </label>\n <button\n type=\"button\"\n class=\"log__rule-ic\"\n title=\"上移\"\n :disabled=\"idx === 0\"\n @click=\"moveRule(r.id, -1)\"\n >\n <Icon name=\"arrowUp\" :size=\"12\" />\n </button>\n <button\n type=\"button\"\n class=\"log__rule-ic\"\n title=\"下移\"\n :disabled=\"idx === rules.length - 1\"\n @click=\"moveRule(r.id, 1)\"\n >\n <Icon name=\"arrowDown\" :size=\"12\" />\n </button>\n <button\n type=\"button\"\n class=\"log__rule-ic log__rule-del\"\n title=\"删除\"\n @click=\"removeRule(r.id)\"\n >\n <Icon name=\"trash\" :size=\"12\" />\n </button>\n </div>\n <button type=\"button\" class=\"log__rules-add\" @click=\"addRule\">\n <Icon name=\"plus\" :size=\"12\" /> 添加规则\n </button>\n </div>\n </div>\n\n <!-- virtual list: a fixed-height sizer drives the scrollbar; only the visible window is mounted -->\n <div ref=\"scrollEl\" class=\"log__scroll\" @scroll=\"onScroll\">\n <div class=\"log__sizer\" :style=\"{ height: total * LINE_H + 'px' }\">\n <div class=\"log__rows\" :style=\"{ paddingTop: startIdx * LINE_H + 'px' }\">\n <div\n v-for=\"row in visible\"\n :key=\"row.i\"\n class=\"log__row\"\n :class=\"{\n log__gaprow: row.isGap,\n 'log__row--active': !row.isGap && row.n - 1 === activeOrig,\n 'log__row--header': !row.isGap && row.kind === 'header',\n 'log__row--frame': !row.isGap && row.kind === 'frame',\n 'log__row--context': row.context,\n }\"\n :style=\"\n !row.isGap && row.style && row.style.bg ? { background: row.style.bg } : undefined\n \"\n >\n <!-- gap expander (filter mode): reveal hidden context around matches -->\n <template v-if=\"row.isGap\">\n <button\n v-if=\"row.size > STEP\"\n type=\"button\"\n class=\"log__gapbtn\"\n :title=\"`向下展开 ${row.step} 行(上方匹配之后)`\"\n @click=\"reveal(row.top, row.bottom, 'down')\"\n >\n <Icon name=\"chevronDown\" :size=\"12\" />\n </button>\n <button\n type=\"button\"\n class=\"log__gapmain\"\n :title=\"`展开全部 ${row.size} 行`\"\n @click=\"reveal(row.top, row.bottom, 'all')\"\n >\n ⋯ {{ row.size }} 行\n </button>\n <button\n v-if=\"row.size > STEP\"\n type=\"button\"\n class=\"log__gapbtn log__gapbtn--up\"\n :title=\"`向上展开 ${row.step} 行(下方匹配之前)`\"\n @click=\"reveal(row.top, row.bottom, 'up')\"\n >\n <Icon name=\"chevronDown\" :size=\"12\" />\n </button>\n </template>\n\n <!-- content row -->\n <template v-else>\n <span class=\"log__ln\">{{ row.n }}</span>\n <span v-if=\"hasGroups\" class=\"log__fold-cell\">\n <button\n v-if=\"row.kind === 'header' && !inFilter\"\n type=\"button\"\n class=\"log__fold\"\n :title=\"row.collapsed ? '展开堆栈' : '折叠堆栈'\"\n @click=\"toggleGroup(row.gid)\"\n >\n <Icon\n name=\"chevronRight\"\n :size=\"12\"\n class=\"log__chev\"\n :class=\"{ 'log__chev--open': !row.collapsed }\"\n />\n </button>\n </span>\n <span\n class=\"log__tx\"\n :style=\"row.style && row.style.fg ? { color: row.style.fg } : undefined\"\n v-html=\"renderLine(row.text)\"\n />\n <button\n v-if=\"row.kind === 'header' && row.collapsed && !inFilter\"\n type=\"button\"\n class=\"log__badge\"\n title=\"展开堆栈\"\n @click=\"toggleGroup(row.gid)\"\n >\n ⋯ {{ row.count }} 帧\n </button>\n </template>\n </div>\n </div>\n </div>\n </div>\n </template>\n </div>\n</template>\n\n<style scoped>\n.log {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--code-bg);\n}\n\n.log__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.log__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.log__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.log__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.log__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.log__bar {\n position: relative;\n display: flex;\n align-items: center;\n gap: 6px;\n flex-shrink: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.log__searchwrap {\n position: relative;\n flex: 1 1 auto;\n min-width: 60px;\n display: flex;\n}\n.log__search {\n flex: 1 1 auto;\n min-width: 0;\n padding: 5px 28px 5px 10px;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--code-bg);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n}\n.log__search:focus,\n.log__goto:focus {\n outline: none;\n border-color: var(--accent);\n}\n.log__clear {\n position: absolute;\n top: 50%;\n right: 5px;\n transform: translateY(-50%);\n display: flex;\n align-items: center;\n justify-content: center;\n width: 19px;\n height: 19px;\n padding: 0;\n color: var(--faint);\n background: none;\n border: none;\n border-radius: 5px;\n cursor: pointer;\n}\n.log__clear:hover {\n color: var(--text);\n background: var(--surface-2);\n}\n.log__matches {\n flex-shrink: 0;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--faint);\n min-width: 38px;\n text-align: center;\n}\n.log__nav {\n flex-shrink: 0;\n min-width: 26px;\n height: 26px;\n padding: 0 6px;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n cursor: pointer;\n}\n.log__nav:hover:not(:disabled) {\n border-color: var(--accent);\n color: var(--accent);\n}\n.log__nav:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.log__nav--on {\n color: var(--accent);\n border-color: var(--accent);\n background: var(--surface-2);\n}\n.log__spacer {\n flex: 1 1 auto;\n}\n.log__goto {\n flex-shrink: 0;\n width: 64px;\n padding: 5px 8px;\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--text);\n background: var(--code-bg);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n}\n.log__total {\n flex-shrink: 0;\n font-size: 11.5px;\n color: var(--faint);\n white-space: nowrap;\n}\n\n/* color-rules dropdown */\n.log__overlay {\n position: fixed;\n inset: 0;\n z-index: 40;\n background: transparent;\n}\n.log__rules {\n position: absolute;\n z-index: 41;\n top: calc(100% + 4px);\n right: 12px;\n width: min(520px, calc(100vw - 24px));\n max-height: 320px;\n overflow: auto;\n padding: 10px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 10px;\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);\n}\n.log__rules-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n}\n.log__rules-title {\n font-size: 12.5px;\n font-weight: 600;\n color: var(--text);\n}\n.log__rules-hint {\n font-size: 11px;\n color: var(--faint);\n}\n.log__rules-empty {\n font-size: 12px;\n color: var(--muted);\n line-height: 1.5;\n margin: 2px 0;\n}\n.log__rules-empty code {\n font-family: var(--font-mono);\n font-size: 11px;\n padding: 0 4px;\n background: var(--code-bg);\n border-radius: 4px;\n}\n.log__rule {\n display: flex;\n align-items: center;\n gap: 5px;\n}\n.log__rule-pat {\n flex: 1 1 auto;\n min-width: 80px;\n padding: 4px 8px;\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--text);\n background: var(--code-bg);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n}\n.log__rule-pat:focus {\n outline: none;\n border-color: var(--accent);\n}\n.log__rule-pat--bad {\n border-color: var(--danger);\n color: var(--danger);\n}\n.log__rule-tog {\n flex-shrink: 0;\n width: 26px;\n height: 26px;\n padding: 0;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--muted);\n background: var(--code-bg);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n cursor: pointer;\n}\n.log__rule-tog--on {\n color: var(--accent);\n border-color: var(--accent);\n background: var(--surface-2);\n}\n.log__rule-color {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n gap: 3px;\n font-size: 11px;\n color: var(--faint);\n cursor: pointer;\n}\n.log__rule-color input[type='color'] {\n width: 22px;\n height: 22px;\n padding: 0;\n border: 1px solid var(--border-2);\n border-radius: 5px;\n background: none;\n cursor: pointer;\n}\n.log__rule-color input[type='color']:disabled {\n opacity: 0.35;\n cursor: default;\n}\n.log__rule-ic {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 24px;\n padding: 0;\n color: var(--muted);\n background: none;\n border: 1px solid transparent;\n border-radius: 6px;\n cursor: pointer;\n}\n.log__rule-ic:hover:not(:disabled) {\n color: var(--accent);\n border-color: var(--border-2);\n}\n.log__rule-ic:disabled {\n opacity: 0.3;\n cursor: default;\n}\n.log__rule-del:hover:not(:disabled) {\n color: var(--danger);\n}\n.log__rules-add {\n align-self: flex-start;\n display: inline-flex;\n align-items: center;\n gap: 5px;\n margin-top: 2px;\n padding: 4px 10px;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: var(--code-bg);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n cursor: pointer;\n}\n.log__rules-add:hover {\n color: var(--accent);\n border-color: var(--accent);\n}\n\n.log__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n}\n.log__sizer {\n position: relative;\n min-width: 100%;\n}\n.log__row {\n display: flex;\n align-items: flex-start;\n min-width: 100%;\n height: 19px;\n line-height: 19px;\n white-space: nowrap;\n font-family: var(--font-mono);\n font-size: 12.5px;\n}\n.log__row--active {\n background: color-mix(in srgb, var(--accent) 14%, transparent);\n}\n.log__row--context .log__tx {\n opacity: 0.66;\n}\n.log__ln {\n position: sticky;\n left: 0;\n flex-shrink: 0;\n width: 64px;\n padding-right: 12px;\n text-align: right;\n color: var(--faint);\n background: var(--code-bg);\n user-select: none;\n}\n.log__row--active .log__ln {\n background: color-mix(in srgb, var(--accent) 14%, var(--code-bg));\n}\n\n/* fold gutter — a fixed cell right after the line number, present for every row (only when the file has\n stacks) so header/frame/plain text all stay column-aligned. Holds the chevron on headers; draws a faint\n guide line down the left edge of frame rows to read the run as a nested block. */\n.log__fold-cell {\n flex-shrink: 0;\n width: 16px;\n align-self: stretch;\n display: flex;\n align-items: center;\n justify-content: center;\n user-select: none;\n}\n.log__row--frame .log__fold-cell {\n box-shadow: inset 1px 0 0 var(--border-2);\n}\n.log__fold {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n padding: 0;\n border: none;\n background: none;\n color: var(--faint);\n cursor: pointer;\n}\n.log__chev {\n transition: transform 0.12s ease;\n}\n.log__chev--open {\n transform: rotate(90deg);\n}\n.log__fold:hover .log__chev {\n color: var(--accent);\n}\n\n.log__tx {\n flex: 1 1 auto;\n padding-right: 18px;\n color: var(--text);\n white-space: pre;\n}\n.log__row--header .log__tx {\n color: var(--text);\n font-weight: 600;\n}\n.log__row--frame .log__tx {\n color: var(--muted);\n}\n.log__tx :deep(.log__hit) {\n background: #ffd43b;\n color: #1a1a1a;\n border-radius: 2px;\n}\n\n.log__badge {\n flex-shrink: 0;\n align-self: center;\n margin-left: 8px;\n padding: 0 8px;\n height: 15px;\n font-family: var(--font-mono);\n font-size: 10.5px;\n line-height: 13px;\n color: var(--faint);\n background: var(--surface-2);\n border: 1px solid var(--border-2);\n border-radius: 9px;\n cursor: pointer;\n user-select: none;\n}\n.log__badge:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* filter-mode context expander bar (one virtual row tall) */\n.log__gaprow {\n position: sticky;\n left: 0;\n align-items: center;\n justify-content: center;\n gap: 10px;\n background: var(--surface-2);\n color: var(--faint);\n user-select: none;\n}\n.log__gapbtn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 22px;\n height: 15px;\n padding: 0;\n color: var(--muted);\n background: none;\n border: none;\n cursor: pointer;\n}\n.log__gapbtn:hover {\n color: var(--accent);\n}\n.log__gapbtn--up {\n transform: rotate(180deg);\n}\n.log__gapmain {\n padding: 0 10px;\n height: 15px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--faint);\n background: none;\n border: none;\n cursor: pointer;\n}\n.log__gapmain:hover {\n color: var(--accent);\n}\n</style>\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LrcViewer.js","names":[],"sources":["../../src/components/LrcViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { parseLrc } from '@/lib/lrc'\nimport { decodeTextSniff } from '@/lib/encoding'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer LRC lyrics renderer. Unlike the text-backed viewers, this one self-fetches the BYTES and sniffs\n * the encoding (UTF-8 → GBK → lenient, see lib/encoding.ts) — QQ Music's `.lrc` files are almost always GBK\n * and would be mojibake if read as UTF-8 text. The bytes are parsed in-browser (see lib/lrc.ts) into a synced\n * lyric list (timestamp + text); HTML numeric entities common in those files are decoded too.\n *\n * Self-contained 歌词/源码 toggle (the panel's own toggle is for text-backed kinds, which this isn't): render\n * shows the parsed lines, source shows the decoded raw text.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst raw = ref('')\nconst mode = ref<'render' | 'source'>('render')\n\nconst parsed = computed(() => parseLrc(raw.value))\n\n// ms → \"mm:ss\" for the time gutter.\nfunction fmtTime(ms: number): string {\n const total = Math.max(0, Math.floor(ms / 1000))\n const m = Math.floor(total / 60)\n const s = total % 60\n return `${m}:${String(s).padStart(2, '0')}`\n}\n\nasync function load(): Promise<void> {\n if (!props.url) return\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 = new Uint8Array(await res.arrayBuffer())\n raw.value = decodeTextSniff(buf)\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nwatch(() => props.url, load, { immediate: true })\nonBeforeUnmount(() => {})\n</script>\n\n<template>\n <div class=\"lrc\">\n <div class=\"lrc__head\">\n <span class=\"lrc__badge\">LRC</span>\n <span v-if=\"parsed.hasTimings\" class=\"lrc__count\">{{ parsed.lines.length }} 行</span>\n <span v-else class=\"lrc__count\">纯文本歌词</span>\n <span v-if=\"parsed.offsetMs\" class=\"lrc__dim\">offset {{ parsed.offsetMs }}ms</span>\n <div class=\"lrc__toggle\" role=\"group\" aria-label=\"渲染或源码\">\n <button\n type=\"button\"\n class=\"lrc__tbtn\"\n :class=\"{ 'lrc__tbtn--on': mode === 'render' }\"\n title=\"歌词\"\n @click=\"mode = 'render'\"\n >\n <Icon name=\"eye\" :size=\"14\" />\n </button>\n <button\n type=\"button\"\n class=\"lrc__tbtn\"\n :class=\"{ 'lrc__tbtn--on': mode === 'source' }\"\n title=\"源码\"\n @click=\"mode = 'source'\"\n >\n <Icon name=\"code\" :size=\"14\" />\n </button>\n </div>\n </div>\n\n <p v-if=\"status === 'loading'\" class=\"lrc__msg\">正在加载…</p>\n <div v-else-if=\"status === 'error'\" class=\"lrc__msg lrc__msg--error\">\n <p>加载失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"lrc__retry\" @click=\"load\">重试</button>\n </div>\n\n <!-- source: the decoded raw .lrc text -->\n <pre v-else-if=\"mode === 'source'\" class=\"lrc__source\">{{ raw }}</pre>\n\n <!-- render: title/artist meta + the synced lyric list -->\n <div v-else class=\"lrc__body\">\n <div v-if=\"parsed.title || parsed.artist || parsed.album || parsed.by\" class=\"lrc__info\">\n <div v-if=\"parsed.title\" class=\"lrc__title\">{{ parsed.title }}</div>\n <div class=\"lrc__sub\">\n <span v-if=\"parsed.artist\">{{ parsed.artist }}</span>\n <span v-if=\"parsed.album\" class=\"lrc__al\">{{ parsed.album }}</span>\n </div>\n </div>\n\n <div v-if=\"parsed.lines.length\" class=\"lrc__list\">\n <div v-for=\"(line, i) in parsed.lines\" :key=\"i\" class=\"lrc__line\">\n <span v-if=\"line.timeMs != null\" class=\"lrc__time\">{{ fmtTime(line.timeMs) }}</span>\n <span class=\"lrc__text\" :class=\"{ 'lrc__text--empty': !line.text }\">{{\n line.text || '♪'\n }}</span>\n </div>\n </div>\n <p v-else class=\"lrc__msg\">未解析到歌词内容(可切换到「源码」查看原始内容)。</p>\n </div>\n </div>\n</template>\n\n<style scoped>\n.lrc {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--code-bg);\n}\n\n.lrc__head {\n display: flex;\n align-items: center;\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.lrc__badge {\n padding: 2px 9px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--accent);\n font-weight: 600;\n letter-spacing: 0.02em;\n}\n.lrc__dim {\n color: var(--faint);\n}\n/* render/source toggle pinned right */\n.lrc__toggle {\n margin-left: auto;\n display: inline-flex;\n gap: 2px;\n padding: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.lrc__tbtn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 24px;\n padding: 0;\n background: transparent;\n border: none;\n border-radius: 6px;\n color: var(--faint);\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.lrc__tbtn:hover {\n color: var(--muted);\n}\n.lrc__tbtn--on {\n background: var(--surface);\n color: var(--accent);\n box-shadow: var(--shadow);\n}\n\n.lrc__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.lrc__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.lrc__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\n.lrc__source {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n margin: 0;\n padding: 14px 16px 60px;\n font-family: var(--font-mono);\n font-size: 12.5px;\n line-height: 1.7;\n color: var(--text);\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n.lrc__body {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 0 0 60px;\n}\n.lrc__info {\n padding: 16px 18px 12px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.lrc__title {\n font-size: 15px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.lrc__sub {\n margin-top: 3px;\n display: flex;\n gap: 12px;\n font-size: 12.5px;\n color: var(--muted);\n}\n.lrc__al {\n color: var(--faint);\n}\n.lrc__list {\n padding: 8px 0;\n}\n.lrc__line {\n display: flex;\n gap: 14px;\n padding: 6px 18px;\n}\n.lrc__line:hover {\n background: var(--surface);\n}\n.lrc__time {\n flex-shrink: 0;\n width: 46px;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--accent);\n white-space: nowrap;\n padding-top: 1px;\n}\n.lrc__text {\n flex: 1 1 auto;\n min-width: 0;\n font-size: 13.5px;\n line-height: 1.6;\n color: var(--text);\n word-break: break-word;\n}\n.lrc__text--empty {\n color: var(--faint);\n}\n</style>\n"],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LrcViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/LrcViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { parseLrc } from '@/lib/lrc'\nimport { decodeTextSniff } from '@/lib/encoding'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer LRC lyrics renderer. Unlike the text-backed viewers, this one self-fetches the BYTES and sniffs\n * the encoding (UTF-8 → GBK → lenient, see lib/encoding.ts) — QQ Music's `.lrc` files are almost always GBK\n * and would be mojibake if read as UTF-8 text. The bytes are parsed in-browser (see lib/lrc.ts) into a synced\n * lyric list (timestamp + text); HTML numeric entities common in those files are decoded too.\n *\n * Self-contained 歌词/源码 toggle (the panel's own toggle is for text-backed kinds, which this isn't): render\n * shows the parsed lines, source shows the decoded raw text.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst raw = ref('')\nconst mode = ref<'render' | 'source'>('render')\n\nconst parsed = computed(() => parseLrc(raw.value))\n\n// ms → \"mm:ss\" for the time gutter.\nfunction fmtTime(ms: number): string {\n const total = Math.max(0, Math.floor(ms / 1000))\n const m = Math.floor(total / 60)\n const s = total % 60\n return `${m}:${String(s).padStart(2, '0')}`\n}\n\nasync function load(): Promise<void> {\n if (!props.url) return\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 = new Uint8Array(await res.arrayBuffer())\n raw.value = decodeTextSniff(buf)\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nwatch(() => props.url, load, { immediate: true })\nonBeforeUnmount(() => {})\n</script>\n\n<template>\n <div class=\"lrc\">\n <div class=\"lrc__head\">\n <span class=\"lrc__badge\">LRC</span>\n <span v-if=\"parsed.hasTimings\" class=\"lrc__count\">{{ parsed.lines.length }} 行</span>\n <span v-else class=\"lrc__count\">纯文本歌词</span>\n <span v-if=\"parsed.offsetMs\" class=\"lrc__dim\">offset {{ parsed.offsetMs }}ms</span>\n <div class=\"lrc__toggle\" role=\"group\" aria-label=\"渲染或源码\">\n <button\n type=\"button\"\n class=\"lrc__tbtn\"\n :class=\"{ 'lrc__tbtn--on': mode === 'render' }\"\n title=\"歌词\"\n @click=\"mode = 'render'\"\n >\n <Icon name=\"eye\" :size=\"14\" />\n </button>\n <button\n type=\"button\"\n class=\"lrc__tbtn\"\n :class=\"{ 'lrc__tbtn--on': mode === 'source' }\"\n title=\"源码\"\n @click=\"mode = 'source'\"\n >\n <Icon name=\"code\" :size=\"14\" />\n </button>\n </div>\n </div>\n\n <p v-if=\"status === 'loading'\" class=\"lrc__msg\">正在加载…</p>\n <div v-else-if=\"status === 'error'\" class=\"lrc__msg lrc__msg--error\">\n <p>加载失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"lrc__retry\" @click=\"load\">重试</button>\n </div>\n\n <!-- source: the decoded raw .lrc text -->\n <pre v-else-if=\"mode === 'source'\" class=\"lrc__source\">{{ raw }}</pre>\n\n <!-- render: title/artist meta + the synced lyric list -->\n <div v-else class=\"lrc__body\">\n <div v-if=\"parsed.title || parsed.artist || parsed.album || parsed.by\" class=\"lrc__info\">\n <div v-if=\"parsed.title\" class=\"lrc__title\">{{ parsed.title }}</div>\n <div class=\"lrc__sub\">\n <span v-if=\"parsed.artist\">{{ parsed.artist }}</span>\n <span v-if=\"parsed.album\" class=\"lrc__al\">{{ parsed.album }}</span>\n </div>\n </div>\n\n <div v-if=\"parsed.lines.length\" class=\"lrc__list\">\n <div v-for=\"(line, i) in parsed.lines\" :key=\"i\" class=\"lrc__line\">\n <span v-if=\"line.timeMs != null\" class=\"lrc__time\">{{ fmtTime(line.timeMs) }}</span>\n <span class=\"lrc__text\" :class=\"{ 'lrc__text--empty': !line.text }\">{{\n line.text || '♪'\n }}</span>\n </div>\n </div>\n <p v-else class=\"lrc__msg\">未解析到歌词内容(可切换到「源码」查看原始内容)。</p>\n </div>\n </div>\n</template>\n\n<style scoped>\n.lrc {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--code-bg);\n}\n\n.lrc__head {\n display: flex;\n align-items: center;\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.lrc__badge {\n padding: 2px 9px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--accent);\n font-weight: 600;\n letter-spacing: 0.02em;\n}\n.lrc__dim {\n color: var(--faint);\n}\n/* render/source toggle pinned right */\n.lrc__toggle {\n margin-left: auto;\n display: inline-flex;\n gap: 2px;\n padding: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.lrc__tbtn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 24px;\n padding: 0;\n background: transparent;\n border: none;\n border-radius: 6px;\n color: var(--faint);\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.lrc__tbtn:hover {\n color: var(--muted);\n}\n.lrc__tbtn--on {\n background: var(--surface);\n color: var(--accent);\n box-shadow: var(--shadow);\n}\n\n.lrc__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.lrc__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.lrc__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\n.lrc__source {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n margin: 0;\n padding: 14px 16px 60px;\n font-family: var(--font-mono);\n font-size: 12.5px;\n line-height: 1.7;\n color: var(--text);\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n.lrc__body {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 0 0 60px;\n}\n.lrc__info {\n padding: 16px 18px 12px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.lrc__title {\n font-size: 15px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.lrc__sub {\n margin-top: 3px;\n display: flex;\n gap: 12px;\n font-size: 12.5px;\n color: var(--muted);\n}\n.lrc__al {\n color: var(--faint);\n}\n.lrc__list {\n padding: 8px 0;\n}\n.lrc__line {\n display: flex;\n gap: 14px;\n padding: 6px 18px;\n}\n.lrc__line:hover {\n background: var(--surface);\n}\n.lrc__time {\n flex-shrink: 0;\n width: 46px;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--accent);\n white-space: nowrap;\n padding-top: 1px;\n}\n.lrc__text {\n flex: 1 1 auto;\n min-width: 0;\n font-size: 13.5px;\n line-height: 1.6;\n color: var(--text);\n word-break: break-word;\n}\n.lrc__text--empty {\n color: var(--faint);\n}\n</style>\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MarkdownTable.js","names":[],"sources":["../../src/components/MarkdownTable.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport InlineText from '@/components/InlineText'\n\n/**\n * GFM pipe table rendered as a horizontally-scrollable card, with drag-to-resize columns.\n *\n * Column resize is opt-in and lazy: until you grab a header's right-edge handle the table renders exactly as\n * before — `width:100%`, auto layout, content-sized adaptive columns (also the untouched SSR output). On the\n * FIRST drag we snapshot the current rendered column widths ({@link colWidths}), switch to a fixed layout keyed\n * off a `<colgroup>`, and from then on each column is an explicit px width (table width = their sum, so the card\n * scrolls horizontally once you widen past it). Dragging changes only the grabbed column — same mechanics as the\n * CSV viewer. A change in column count (e.g. a table re-parsing while a message streams) drops back to adaptive.\n */\nconst props = defineProps<{\n header: string[]\n aligns: (string | null)[]\n rows: string[][]\n keyPrefix?: string\n}>()\n\nfunction al(ci: number): 'left' | 'center' | 'right' {\n return (props.aligns[ci] || 'left') as 'left' | 'center' | 'right'\n}\n\nconst MIN_COL_W = 40\n\n// null → adaptive default (no explicit widths); an array → user has taken manual control (fixed layout).\nconst colWidths = ref<number[] | null>(null)\nconst totalWidth = computed(() => colWidths.value?.reduce((a, w) => a + w, 0) ?? 0)\n\n// A structural change (column count differs) invalidates measured widths — revert to the adaptive default.\nwatch(\n () => props.header.length,\n () => {\n colWidths.value = null\n },\n)\n\nlet resizeIdx = -1\nlet startX = 0\nlet startW = 0\n\nfunction startResize(ci: number, e: MouseEvent): void {\n // First drag: freeze the columns at their current rendered widths, then switch to fixed layout so the drag is\n // pixel-exact. Measured from the header row's cells (one <tr>), which always has header.length columns.\n if (!colWidths.value) {\n const th = (e.currentTarget as HTMLElement).closest('th')\n const rowEl = th?.parentElement\n if (!rowEl) return\n const raw = Array.from(rowEl.children).map(\n (el) => (el as HTMLElement).getBoundingClientRect().width,\n )\n // Scale the per-cell widths to the table's actual width: with collapsed borders the cell widths overcount\n // the shared internal borders, so their raw sum slightly exceeds the table — normalizing avoids a spurious\n // scrollbar the moment the table locks to a fixed width.\n const tableW = th?.closest('table')?.getBoundingClientRect().width ?? 0\n const rawSum = raw.reduce((a, w) => a + w, 0) || 1\n const scale = tableW > 0 ? tableW / rawSum : 1\n colWidths.value = raw.map((w) => Math.max(MIN_COL_W, Math.round(w * scale)))\n }\n const widths = colWidths.value\n if (!widths) return\n resizeIdx = ci\n startX = e.clientX\n startW = widths[ci]\n window.addEventListener('mousemove', onResizeMove)\n window.addEventListener('mouseup', endResize)\n document.body.style.userSelect = 'none'\n document.body.style.cursor = 'col-resize'\n}\nfunction onResizeMove(e: MouseEvent): void {\n const widths = colWidths.value\n if (resizeIdx < 0 || !widths) return\n const next = widths.slice()\n next[resizeIdx] = Math.max(MIN_COL_W, Math.round(startW + (e.clientX - startX)))\n colWidths.value = next\n}\nfunction endResize(): void {\n resizeIdx = -1\n window.removeEventListener('mousemove', onResizeMove)\n window.removeEventListener('mouseup', endResize)\n document.body.style.userSelect = ''\n document.body.style.cursor = ''\n}\n\nonBeforeUnmount(endResize)\n</script>\n\n<template>\n <div class=\"md-table\">\n <div class=\"md-table__scroll\">\n <table\n class=\"md-table__table\"\n :class=\"{ 'md-table__table--fixed': colWidths }\"\n :style=\"colWidths ? { width: totalWidth + 'px' } : undefined\"\n >\n <colgroup v-if=\"colWidths\">\n <col v-for=\"(w, ci) in colWidths\" :key=\"ci\" :style=\"{ width: w + 'px' }\" />\n </colgroup>\n <thead>\n <tr>\n <th v-for=\"(c, ci) in header\" :key=\"ci\" :style=\"{ textAlign: al(ci) }\">\n <InlineText :text=\"c\" :kp=\"`${keyPrefix}-th${ci}`\" />\n <span\n class=\"md-table__resize\"\n title=\"拖动调整列宽\"\n @mousedown.prevent.stop=\"startResize(ci, $event)\"\n />\n </th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"(row, ri) in rows\" :key=\"ri\">\n <td v-for=\"(_, ci) in header\" :key=\"ci\" :style=\"{ textAlign: al(ci) }\">\n <InlineText :text=\"row[ci] || ''\" :kp=\"`${keyPrefix}-r${ri}c${ci}`\" />\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n</template>\n\n<style scoped>\n.md-table {\n margin: 4px 0 22px;\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n box-shadow: var(--shadow);\n background: var(--surface);\n}\n.md-table__scroll {\n overflow-x: auto;\n}\n.md-table__table {\n border-collapse: collapse;\n width: 100%;\n}\n/* Fixed layout kicks in only after the first resize — column widths then come straight from <colgroup>, and\n cells wrap / break so a narrowed column never spills over its neighbour. */\n.md-table__table--fixed {\n table-layout: fixed;\n}\n.md-table__table--fixed th,\n.md-table__table--fixed td {\n white-space: normal;\n overflow-wrap: anywhere;\n}\n.md-table__table th {\n position: relative;\n font-family: var(--font-sans);\n font-size: 14px;\n font-weight: 600;\n color: var(--text-strong);\n padding: 10px 14px;\n background: var(--code-head);\n border-bottom: 1px solid var(--border-2);\n white-space: nowrap;\n}\n.md-table__table td {\n font-family: var(--font-sans);\n font-size: 14px;\n line-height: 1.55;\n color: var(--text);\n padding: 9px 14px;\n border-top: 1px solid var(--border);\n vertical-align: top;\n}\n/* Column-resize handle over each header cell's right edge; the grip line fades in on hover. */\n.md-table__resize {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n width: 9px;\n cursor: col-resize;\n user-select: none;\n touch-action: none;\n}\n.md-table__resize::after {\n content: '';\n position: absolute;\n top: 20%;\n bottom: 20%;\n right: 3px;\n width: 2px;\n border-radius: 1px;\n background: var(--border-2);\n opacity: 0;\n transition: opacity 0.12s ease;\n}\n.md-table__table th:hover .md-table__resize::after {\n opacity: 1;\n}\n.md-table__resize:hover::after {\n opacity: 1;\n background: var(--accent);\n}\n</style>\n"],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MarkdownTable.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/MarkdownTable.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport InlineText from '@/components/InlineText'\n\n/**\n * GFM pipe table rendered as a horizontally-scrollable card, with drag-to-resize columns.\n *\n * Column resize is opt-in and lazy: until you grab a header's right-edge handle the table renders exactly as\n * before — `width:100%`, auto layout, content-sized adaptive columns (also the untouched SSR output). On the\n * FIRST drag we snapshot the current rendered column widths ({@link colWidths}), switch to a fixed layout keyed\n * off a `<colgroup>`, and from then on each column is an explicit px width (table width = their sum, so the card\n * scrolls horizontally once you widen past it). Dragging changes only the grabbed column — same mechanics as the\n * CSV viewer. A change in column count (e.g. a table re-parsing while a message streams) drops back to adaptive.\n */\nconst props = defineProps<{\n header: string[]\n aligns: (string | null)[]\n rows: string[][]\n keyPrefix?: string\n}>()\n\nfunction al(ci: number): 'left' | 'center' | 'right' {\n return (props.aligns[ci] || 'left') as 'left' | 'center' | 'right'\n}\n\nconst MIN_COL_W = 40\n\n// null → adaptive default (no explicit widths); an array → user has taken manual control (fixed layout).\nconst colWidths = ref<number[] | null>(null)\nconst totalWidth = computed(() => colWidths.value?.reduce((a, w) => a + w, 0) ?? 0)\n\n// A structural change (column count differs) invalidates measured widths — revert to the adaptive default.\nwatch(\n () => props.header.length,\n () => {\n colWidths.value = null\n },\n)\n\nlet resizeIdx = -1\nlet startX = 0\nlet startW = 0\n\nfunction startResize(ci: number, e: MouseEvent): void {\n // First drag: freeze the columns at their current rendered widths, then switch to fixed layout so the drag is\n // pixel-exact. Measured from the header row's cells (one <tr>), which always has header.length columns.\n if (!colWidths.value) {\n const th = (e.currentTarget as HTMLElement).closest('th')\n const rowEl = th?.parentElement\n if (!rowEl) return\n const raw = Array.from(rowEl.children).map(\n (el) => (el as HTMLElement).getBoundingClientRect().width,\n )\n // Scale the per-cell widths to the table's actual width: with collapsed borders the cell widths overcount\n // the shared internal borders, so their raw sum slightly exceeds the table — normalizing avoids a spurious\n // scrollbar the moment the table locks to a fixed width.\n const tableW = th?.closest('table')?.getBoundingClientRect().width ?? 0\n const rawSum = raw.reduce((a, w) => a + w, 0) || 1\n const scale = tableW > 0 ? tableW / rawSum : 1\n colWidths.value = raw.map((w) => Math.max(MIN_COL_W, Math.round(w * scale)))\n }\n const widths = colWidths.value\n if (!widths) return\n resizeIdx = ci\n startX = e.clientX\n startW = widths[ci]\n window.addEventListener('mousemove', onResizeMove)\n window.addEventListener('mouseup', endResize)\n document.body.style.userSelect = 'none'\n document.body.style.cursor = 'col-resize'\n}\nfunction onResizeMove(e: MouseEvent): void {\n const widths = colWidths.value\n if (resizeIdx < 0 || !widths) return\n const next = widths.slice()\n next[resizeIdx] = Math.max(MIN_COL_W, Math.round(startW + (e.clientX - startX)))\n colWidths.value = next\n}\nfunction endResize(): void {\n resizeIdx = -1\n window.removeEventListener('mousemove', onResizeMove)\n window.removeEventListener('mouseup', endResize)\n document.body.style.userSelect = ''\n document.body.style.cursor = ''\n}\n\nonBeforeUnmount(endResize)\n</script>\n\n<template>\n <div class=\"md-table\">\n <div class=\"md-table__scroll\">\n <table\n class=\"md-table__table\"\n :class=\"{ 'md-table__table--fixed': colWidths }\"\n :style=\"colWidths ? { width: totalWidth + 'px' } : undefined\"\n >\n <colgroup v-if=\"colWidths\">\n <col v-for=\"(w, ci) in colWidths\" :key=\"ci\" :style=\"{ width: w + 'px' }\" />\n </colgroup>\n <thead>\n <tr>\n <th v-for=\"(c, ci) in header\" :key=\"ci\" :style=\"{ textAlign: al(ci) }\">\n <InlineText :text=\"c\" :kp=\"`${keyPrefix}-th${ci}`\" />\n <span\n class=\"md-table__resize\"\n title=\"拖动调整列宽\"\n @mousedown.prevent.stop=\"startResize(ci, $event)\"\n />\n </th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"(row, ri) in rows\" :key=\"ri\">\n <td v-for=\"(_, ci) in header\" :key=\"ci\" :style=\"{ textAlign: al(ci) }\">\n <InlineText :text=\"row[ci] || ''\" :kp=\"`${keyPrefix}-r${ri}c${ci}`\" />\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n</template>\n\n<style scoped>\n.md-table {\n margin: 4px 0 22px;\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n box-shadow: var(--shadow);\n background: var(--surface);\n}\n.md-table__scroll {\n overflow-x: auto;\n}\n.md-table__table {\n border-collapse: collapse;\n width: 100%;\n}\n/* Fixed layout kicks in only after the first resize — column widths then come straight from <colgroup>, and\n cells wrap / break so a narrowed column never spills over its neighbour. */\n.md-table__table--fixed {\n table-layout: fixed;\n}\n.md-table__table--fixed th,\n.md-table__table--fixed td {\n white-space: normal;\n overflow-wrap: anywhere;\n}\n.md-table__table th {\n position: relative;\n font-family: var(--font-sans);\n font-size: 14px;\n font-weight: 600;\n color: var(--text-strong);\n padding: 10px 14px;\n background: var(--code-head);\n border-bottom: 1px solid var(--border-2);\n white-space: nowrap;\n}\n.md-table__table td {\n font-family: var(--font-sans);\n font-size: 14px;\n line-height: 1.55;\n color: var(--text);\n padding: 9px 14px;\n border-top: 1px solid var(--border);\n vertical-align: top;\n}\n/* Column-resize handle over each header cell's right edge; the grip line fades in on hover. */\n.md-table__resize {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n width: 9px;\n cursor: col-resize;\n user-select: none;\n touch-action: none;\n}\n.md-table__resize::after {\n content: '';\n position: absolute;\n top: 20%;\n bottom: 20%;\n right: 3px;\n width: 2px;\n border-radius: 1px;\n background: var(--border-2);\n opacity: 0;\n transition: opacity 0.12s ease;\n}\n.md-table__table th:hover .md-table__resize::after {\n opacity: 1;\n}\n.md-table__resize:hover::after {\n opacity: 1;\n background: var(--accent);\n}\n</style>\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MarkdownText.js","names":[],"sources":["../../src/components/MarkdownText.ts"],"sourcesContent":["import { defineComponent } from 'vue'\nimport { md } from '@/lib/markdown/blocks'\n\n/**\n * Renders block-level markdown (paragraphs, headings, lists, tables, code) as a\n * fragment — the nodes flatten into the parent so they sit as siblings of other\n * message blocks, exactly like the original `this.md(...)` spread.\n */\nexport default defineComponent({\n name: 'MarkdownText',\n props: {\n text: { type: String, default: '' },\n kp: { type: String, default: 'md' },\n },\n setup(props) {\n return () => md(props.text, props.kp)\n },\n})\n"],"mappings":";;;AAQA,IAAA,IAAe,EAAgB;CAC7B,MAAM;CACN,OAAO;EACL,MAAM;GAAE,MAAM;GAAQ,SAAS;EAAG;EAClC,IAAI;GAAE,MAAM;GAAQ,SAAS;EAAK;CACpC;CACA,MAAM,GAAO;EACX,aAAa,EAAG,EAAM,MAAM,EAAM,EAAE;CACtC;AACF,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MhtViewer.js","names":[],"sources":["../../src/components/MhtViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { parseMht, rewriteHtml } from '@/lib/mht'\nimport { formatBytes } from '@/lib/format'\n\n/**\n * Side-viewer web-archive (`.mht`/`.mhtml`) renderer. Fetches the archive bytes and parses the MIME container\n * entirely in-browser (see lib/mht.ts) — picks the root HTML, indexes the page resources, and rewrites the\n * HTML's resource references to blob URLs before handing it to a sandboxed iframe.\n *\n * Safety: a saved page is hostile markup. It renders in a sandboxed iframe that NEVER gets `allow-scripts`,\n * and the rewritten HTML carries an injected CSP (`script-src 'none'` + `default-src 'none'`, see MHT_CSP) —\n * so NO upstream script runs (two independent gates) and the page can't phone home (only blob:/data:\n * resources are allowed; external requests are blocked). Unlike the panel's HTML preview / EmlViewer (which\n * use the strictest opaque-origin `sandbox=\"\"` + data URLs), this frame keeps `allow-same-origin` so the\n * page's many resources can load as same-origin blob URLs instead of being inlined as memory-heavy data URLs\n * — the deliberate trade for opening large archives. With scripts hard-disabled, `allow-same-origin` is inert.\n */\nconst props = defineProps<{\n /** Download URL of the .mht/.mhtml bytes. */\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\n\ninterface MhtMeta {\n title: string\n sourceUrl: string\n date: string\n resourceCount: number\n}\nconst meta = ref<MhtMeta | null>(null)\nconst docUrl = ref('')\nlet objectUrls: string[] = []\n\n// A web archive bundles every page resource, so it can be large; gate very big ones behind a confirm. The\n// blob-URL strategy keeps memory ≈ file size, so the bar is higher than EmlViewer's in-memory data-URL gate.\nconst SIZE_LIMIT = 30 * 1024 * 1024\n\nfunction revokeAll(): void {\n for (const u of objectUrls) URL.revokeObjectURL(u)\n objectUrls = []\n docUrl.value = ''\n}\n\n/** Blob-URL factory handed to rewriteHtml (and reused for the document itself); tracks URLs for revocation. */\nfunction makeBlobUrl(bytes: Uint8Array, contentType: string): string {\n const u = URL.createObjectURL(new Blob([bytes], { type: contentType }))\n objectUrls.push(u)\n return u\n}\n\n// Human-friendly Date header (falls back to the raw value when it doesn't parse).\nconst dateDisplay = computed(() => {\n const d = meta.value?.date\n if (!d) return ''\n const t = Date.parse(d)\n if (Number.isNaN(t)) return d\n try {\n return new Date(t).toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })\n } catch {\n return d\n }\n})\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n revokeAll()\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n const parsed = parseMht(new Uint8Array(buf))\n\n meta.value = {\n title: parsed.title,\n sourceUrl: parsed.sourceUrl,\n date: parsed.date,\n resourceCount: new Set([...parsed.byLocation.values(), ...parsed.byCid.values()]).size,\n }\n\n if (parsed.html !== null) {\n // rewriteHtml mints blob URLs for the page resources via makeBlobUrl as it walks; then blob the doc too.\n const html = rewriteHtml(parsed, makeBlobUrl)\n docUrl.value = makeBlobUrl(new TextEncoder().encode(html), 'text/html')\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 // Gate oversized files behind a confirm; otherwise fetch + parse straight away.\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(revokeAll)\n</script>\n\n<template>\n <div class=\"mht\">\n <p v-if=\"status === 'loading'\" class=\"mht__msg\">正在解析网页存档…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"mht__msg mht__gate\">\n <p>该存档较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"mht__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"mht__msg mht__msg--error\">\n <p>解析失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"mht__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else-if=\"status === 'ready' && meta\">\n <!-- header card: page title + original URL + date -->\n <div class=\"mht__head\">\n <div class=\"mht__title\">{{ meta.title || props.name || '(无标题)' }}</div>\n <dl class=\"mht__fields\">\n <div v-if=\"meta.sourceUrl\" class=\"mht__field\">\n <dt>来源</dt>\n <dd class=\"mht__src\">{{ meta.sourceUrl }}</dd>\n </div>\n <div v-if=\"dateDisplay\" class=\"mht__field\">\n <dt>保存</dt>\n <dd>{{ dateDisplay }}</dd>\n </div>\n <div v-if=\"meta.resourceCount\" class=\"mht__field\">\n <dt>资源</dt>\n <dd>{{ meta.resourceCount }} 项</dd>\n </div>\n </dl>\n </div>\n\n <!-- body: the rewritten page in a sandboxed iframe (allow-same-origin, NO allow-scripts; CSP blocks JS) -->\n <div v-if=\"docUrl\" class=\"mht__body\">\n <iframe\n class=\"mht__frame\"\n :src=\"docUrl\"\n title=\"网页存档正文\"\n sandbox=\"allow-same-origin\"\n loading=\"lazy\"\n referrerpolicy=\"no-referrer\"\n />\n </div>\n <p v-else class=\"mht__msg\">(此存档没有可显示的网页正文)</p>\n </template>\n </div>\n</template>\n\n<style scoped>\n.mht {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.mht__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.mht__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.mht__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.mht__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.mht__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* Header card: title + source + date, fixed above the scrolling page. */\n.mht__head {\n flex-shrink: 0;\n padding: 14px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.mht__title {\n font-size: 15px;\n font-weight: 600;\n line-height: 1.4;\n color: var(--text-strong);\n word-break: break-word;\n}\n.mht__fields {\n margin: 10px 0 0;\n display: flex;\n flex-direction: column;\n gap: 3px;\n}\n.mht__field {\n display: flex;\n gap: 8px;\n font-size: 12.5px;\n line-height: 1.5;\n}\n.mht__field dt {\n flex-shrink: 0;\n width: 36px;\n color: var(--faint);\n}\n.mht__field dd {\n margin: 0;\n min-width: 0;\n color: var(--text);\n word-break: break-word;\n}\n.mht__src {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--muted);\n}\n\n/* Page body: the iframe fills the remaining height on a white canvas (like the HTML file preview). */\n.mht__body {\n flex: 1 1 auto;\n min-height: 0;\n background: #fff;\n}\n.mht__frame {\n display: block;\n width: 100%;\n height: 100%;\n border: none;\n background: #fff;\n}\n</style>\n"],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MhtViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/MhtViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { parseMht, rewriteHtml } from '@/lib/mht'\nimport { formatBytes } from '@/lib/format'\n\n/**\n * Side-viewer web-archive (`.mht`/`.mhtml`) renderer. Fetches the archive bytes and parses the MIME container\n * entirely in-browser (see lib/mht.ts) — picks the root HTML, indexes the page resources, and rewrites the\n * HTML's resource references to blob URLs before handing it to a sandboxed iframe.\n *\n * Safety: a saved page is hostile markup. It renders in a sandboxed iframe that NEVER gets `allow-scripts`,\n * and the rewritten HTML carries an injected CSP (`script-src 'none'` + `default-src 'none'`, see MHT_CSP) —\n * so NO upstream script runs (two independent gates) and the page can't phone home (only blob:/data:\n * resources are allowed; external requests are blocked). Unlike the panel's HTML preview / EmlViewer (which\n * use the strictest opaque-origin `sandbox=\"\"` + data URLs), this frame keeps `allow-same-origin` so the\n * page's many resources can load as same-origin blob URLs instead of being inlined as memory-heavy data URLs\n * — the deliberate trade for opening large archives. With scripts hard-disabled, `allow-same-origin` is inert.\n */\nconst props = defineProps<{\n /** Download URL of the .mht/.mhtml bytes. */\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\n\ninterface MhtMeta {\n title: string\n sourceUrl: string\n date: string\n resourceCount: number\n}\nconst meta = ref<MhtMeta | null>(null)\nconst docUrl = ref('')\nlet objectUrls: string[] = []\n\n// A web archive bundles every page resource, so it can be large; gate very big ones behind a confirm. The\n// blob-URL strategy keeps memory ≈ file size, so the bar is higher than EmlViewer's in-memory data-URL gate.\nconst SIZE_LIMIT = 30 * 1024 * 1024\n\nfunction revokeAll(): void {\n for (const u of objectUrls) URL.revokeObjectURL(u)\n objectUrls = []\n docUrl.value = ''\n}\n\n/** Blob-URL factory handed to rewriteHtml (and reused for the document itself); tracks URLs for revocation. */\nfunction makeBlobUrl(bytes: Uint8Array, contentType: string): string {\n const u = URL.createObjectURL(new Blob([bytes], { type: contentType }))\n objectUrls.push(u)\n return u\n}\n\n// Human-friendly Date header (falls back to the raw value when it doesn't parse).\nconst dateDisplay = computed(() => {\n const d = meta.value?.date\n if (!d) return ''\n const t = Date.parse(d)\n if (Number.isNaN(t)) return d\n try {\n return new Date(t).toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })\n } catch {\n return d\n }\n})\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n revokeAll()\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n const parsed = parseMht(new Uint8Array(buf))\n\n meta.value = {\n title: parsed.title,\n sourceUrl: parsed.sourceUrl,\n date: parsed.date,\n resourceCount: new Set([...parsed.byLocation.values(), ...parsed.byCid.values()]).size,\n }\n\n if (parsed.html !== null) {\n // rewriteHtml mints blob URLs for the page resources via makeBlobUrl as it walks; then blob the doc too.\n const html = rewriteHtml(parsed, makeBlobUrl)\n docUrl.value = makeBlobUrl(new TextEncoder().encode(html), 'text/html')\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 // Gate oversized files behind a confirm; otherwise fetch + parse straight away.\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(revokeAll)\n</script>\n\n<template>\n <div class=\"mht\">\n <p v-if=\"status === 'loading'\" class=\"mht__msg\">正在解析网页存档…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"mht__msg mht__gate\">\n <p>该存档较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"mht__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"mht__msg mht__msg--error\">\n <p>解析失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"mht__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else-if=\"status === 'ready' && meta\">\n <!-- header card: page title + original URL + date -->\n <div class=\"mht__head\">\n <div class=\"mht__title\">{{ meta.title || props.name || '(无标题)' }}</div>\n <dl class=\"mht__fields\">\n <div v-if=\"meta.sourceUrl\" class=\"mht__field\">\n <dt>来源</dt>\n <dd class=\"mht__src\">{{ meta.sourceUrl }}</dd>\n </div>\n <div v-if=\"dateDisplay\" class=\"mht__field\">\n <dt>保存</dt>\n <dd>{{ dateDisplay }}</dd>\n </div>\n <div v-if=\"meta.resourceCount\" class=\"mht__field\">\n <dt>资源</dt>\n <dd>{{ meta.resourceCount }} 项</dd>\n </div>\n </dl>\n </div>\n\n <!-- body: the rewritten page in a sandboxed iframe (allow-same-origin, NO allow-scripts; CSP blocks JS) -->\n <div v-if=\"docUrl\" class=\"mht__body\">\n <iframe\n class=\"mht__frame\"\n :src=\"docUrl\"\n title=\"网页存档正文\"\n sandbox=\"allow-same-origin\"\n loading=\"lazy\"\n referrerpolicy=\"no-referrer\"\n />\n </div>\n <p v-else class=\"mht__msg\">(此存档没有可显示的网页正文)</p>\n </template>\n </div>\n</template>\n\n<style scoped>\n.mht {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.mht__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.mht__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.mht__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.mht__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.mht__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* Header card: title + source + date, fixed above the scrolling page. */\n.mht__head {\n flex-shrink: 0;\n padding: 14px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.mht__title {\n font-size: 15px;\n font-weight: 600;\n line-height: 1.4;\n color: var(--text-strong);\n word-break: break-word;\n}\n.mht__fields {\n margin: 10px 0 0;\n display: flex;\n flex-direction: column;\n gap: 3px;\n}\n.mht__field {\n display: flex;\n gap: 8px;\n font-size: 12.5px;\n line-height: 1.5;\n}\n.mht__field dt {\n flex-shrink: 0;\n width: 36px;\n color: var(--faint);\n}\n.mht__field dd {\n margin: 0;\n min-width: 0;\n color: var(--text);\n word-break: break-word;\n}\n.mht__src {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--muted);\n}\n\n/* Page body: the iframe fills the remaining height on a white canvas (like the HTML file preview). */\n.mht__body {\n flex: 1 1 auto;\n min-height: 0;\n background: #fff;\n}\n.mht__frame {\n display: block;\n width: 100%;\n height: 100%;\n border: none;\n background: #fff;\n}\n</style>\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MindMapView.js","names":[],"sources":["../../src/components/MindMapView.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'\nimport { fileExt } from '@/lib/fileType'\nimport { parseMindMap, type MindMap, type MindNode } from '@/lib/mindmap'\n\n/**\n * The side viewer's mind-map mode: render a parsed OPML / FreeMind tree (see lib/mindmap.ts) as an interactive,\n * hand-rolled SVG mind map — no third-party graph library (consistent with the codebase's hand-rolled renderers).\n *\n * Layout is a tidy left-to-right tree (root at the left, branches growing right) — the clearest fit for a narrow\n * side panel, and what web mind-map viewers (e.g. markmap) do. FreeMind's left/right `POSITION` split is folded\n * into a single right-growing tree on purpose. Nodes wrap to a max width and pack vertically (leaves take the next\n * slot; a parent centers on its children). Click a node with children to collapse/expand; drag the background to\n * pan, wheel to zoom (around the cursor), and the toolbar zooms / fits. The XML source stays one toggle away in\n * the panel header.\n *\n * Text is sized deterministically (a CJK-aware per-char width estimate, no DOM measurement) so layout matches the\n * rendered `<tspan>`s without a measure pass — SSR-safe and reflow-free.\n */\n// `text` drives the OPML/FreeMind path (parsed here); `map` lets a caller (e.g. XmindViewer) hand in an\n// already-parsed tree so the same renderer draws XMind too.\nconst props = defineProps<{ text?: string; name: string; map?: MindMap }>()\n\n// Geometry constants (px). Exposed to the template via <script setup> top-level bindings.\nconst FONT = 13\nconst LINE_H = 19\nconst PAD_X = 11\nconst PAD_Y = 7\nconst MAX_W = 240 // max inner (text) width before wrapping\nconst MIN_W = 22\nconst H_GAP = 46 // horizontal gap between depth columns\nconst V_GAP = 14 // vertical gap between sibling subtrees\n\n// Depth-cycling palette for edges + branch accents (used when a node has no explicit color).\nconst PALETTE = [\n '#6ea8fe',\n '#63e6be',\n '#ffd43b',\n '#ff8787',\n '#da77f2',\n '#4dabf7',\n '#ffa94d',\n '#9775fa',\n]\n\nconst baseName = computed(() => {\n const n = props.name || ''\n const e = fileExt(n)\n return e ? n.slice(0, n.length - e.length - 1) : n\n})\n\n// Parse is memoized by `props.text`; while the text is unchanged the same tree (and thus node ids) is returned,\n// so the collapse Set stays valid. A parse failure is surfaced as `error`.\nconst result = computed<{ map?: MindMap; error?: string }>(() => {\n if (props.map) return { map: props.map }\n if (!props.text) return {}\n try {\n const ext = fileExt(props.name).toLowerCase()\n return { map: parseMindMap(props.text, ext === 'opml' ? 'opml' : 'mm', baseName.value) }\n } catch (e) {\n return { error: e instanceof Error ? e.message : String(e) }\n }\n})\n\n// Collapsed node ids (reactive Set — Vue tracks has/add/delete, so the layout recomputes on toggle).\nconst collapsed = reactive(new Set<number>())\n\nfunction seedFolded(n: MindNode): void {\n if (n.foldedInit && n.children.length) collapsed.add(n.id)\n for (const c of n.children) seedFolded(c)\n}\n\n// New file (or first parse): reset collapse to the authored fold state and fit the view once laid out.\nwatch(\n () => result.value.map,\n (map) => {\n collapsed.clear()\n if (map) seedFolded(map.root)\n void nextTick(fit)\n },\n { immediate: true },\n)\n\n// ---- Layout --------------------------------------------------------------------------------------------------\ninterface LaidNode {\n node: MindNode\n x: number\n y: number // vertical center\n w: number\n h: number\n depth: number\n lines: string[]\n hasChildren: boolean\n collapsed: boolean\n}\ninterface LaidEdge {\n x1: number\n y1: number\n x2: number\n y2: number\n color: string\n}\n\n// CJK / fullwidth / emoji read as ~1em; Latin as ~0.56em. Good enough to size boxes without measuring the DOM.\nfunction isWide(ch: string): boolean {\n const c = ch.codePointAt(0) ?? 0\n return (\n (c >= 0x1100 && c <= 0x115f) ||\n (c >= 0x2e80 && c <= 0xa4cf) ||\n (c >= 0xac00 && c <= 0xd7a3) ||\n (c >= 0xf900 && c <= 0xfaff) ||\n (c >= 0xfe30 && c <= 0xfe4f) ||\n (c >= 0xff00 && c <= 0xff60) ||\n (c >= 0xffe0 && c <= 0xffe6) ||\n (c >= 0x1f300 && c <= 0x1faff)\n )\n}\nfunction strW(s: string): number {\n let w = 0\n for (const ch of s) w += isWide(ch) ? FONT * 1.02 : FONT * 0.56\n return w\n}\n// Break a line into wrap units: each wide char is its own unit; Latin runs break after spaces / - / _ / /.\nfunction units(line: string): string[] {\n const out: string[] = []\n let buf = ''\n for (const ch of line) {\n if (isWide(ch)) {\n if (buf) {\n out.push(buf)\n buf = ''\n }\n out.push(ch)\n } else {\n buf += ch\n if (ch === ' ' || ch === '-' || ch === '_' || ch === '/') {\n out.push(buf)\n buf = ''\n }\n }\n }\n if (buf) out.push(buf)\n return out\n}\nfunction wrapText(text: string): { lines: string[]; width: number } {\n const src = (text || '').replace(/\\s+/g, ' ').trim() || '(无标题)'\n const lines: string[] = []\n let cur = ''\n let curW = 0\n for (const u of units(src)) {\n const uw = strW(u)\n if (uw > MAX_W) {\n // A single unit wider than the box (e.g. a long URL) — hard-break by characters.\n if (cur) {\n lines.push(cur.trimEnd())\n cur = ''\n curW = 0\n }\n let part = ''\n for (const ch of u) {\n if (strW(part + ch) > MAX_W) {\n lines.push(part)\n part = ch\n } else part += ch\n }\n cur = part\n curW = strW(part)\n continue\n }\n if (cur && curW + uw > MAX_W) {\n lines.push(cur.trimEnd())\n cur = u.trimStart()\n curW = strW(cur)\n } else {\n cur += u\n curW += uw\n }\n }\n if (cur.trim()) lines.push(cur.trimEnd())\n if (!lines.length) lines.push('(无标题)')\n let width = 0\n for (const ln of lines) width = Math.max(width, strW(ln))\n return { lines, width: Math.max(MIN_W, Math.min(MAX_W, Math.ceil(width))) }\n}\n\nconst layout = computed(() => {\n const map = result.value.map\n if (!map) return { nodes: [] as LaidNode[], edges: [] as LaidEdge[], width: 0, height: 0 }\n\n // Measure pass: text → wrapped lines + box size, pruning collapsed subtrees.\n interface M {\n node: MindNode\n lines: string[]\n w: number\n h: number\n depth: number\n children: M[]\n hasChildren: boolean\n }\n function build(node: MindNode, depth: number): M {\n const { lines, width } = wrapText(node.text)\n const isCollapsed = collapsed.has(node.id)\n const children =\n !isCollapsed && node.children.length ? node.children.map((c) => build(c, depth + 1)) : []\n return {\n node,\n lines,\n w: width + PAD_X * 2,\n h: lines.length * LINE_H + PAD_Y * 2,\n depth,\n children,\n hasChildren: node.children.length > 0,\n }\n }\n const rootM = build(map.root, 0)\n\n // Column x: each depth starts after the previous column's widest box.\n const colW: number[] = []\n ;(function scan(m: M) {\n colW[m.depth] = Math.max(colW[m.depth] ?? 0, m.w)\n m.children.forEach(scan)\n })(rootM)\n const colX: number[] = [0]\n for (let d = 1; d < colW.length; d++) colX[d] = colX[d - 1] + (colW[d - 1] ?? 0) + H_GAP\n\n // Vertical packing: leaves take the running cursor; parents center on their children.\n const nodes: LaidNode[] = []\n const edges: LaidEdge[] = []\n let cursor = 0\n function place(m: M): number {\n const x = colX[m.depth]\n let y: number\n if (m.children.length === 0) {\n y = cursor + m.h / 2\n cursor += m.h + V_GAP\n } else {\n const ys = m.children.map(place)\n y = (ys[0] + ys[ys.length - 1]) / 2\n for (let i = 0; i < m.children.length; i++) {\n const cm = m.children[i]\n edges.push({\n x1: x + m.w,\n y1: y,\n x2: colX[cm.depth],\n y2: ys[i],\n color: cm.node.color || cm.node.bg || PALETTE[cm.depth % PALETTE.length],\n })\n }\n }\n nodes.push({\n node: m.node,\n x,\n y,\n w: m.w,\n h: m.h,\n depth: m.depth,\n lines: m.lines,\n hasChildren: m.hasChildren,\n collapsed: collapsed.has(m.node.id),\n })\n return y\n }\n place(rootM)\n\n let width = 0\n let height = 0\n for (const n of nodes) {\n width = Math.max(width, n.x + n.w)\n height = Math.max(height, n.y + n.h / 2)\n }\n return { nodes, edges, width, height }\n})\n\nfunction edgePath(e: LaidEdge): string {\n const dx = Math.max(18, (e.x2 - e.x1) * 0.5)\n return `M${e.x1},${e.y1} C${e.x1 + dx},${e.y1} ${e.x2 - dx},${e.y2} ${e.x2},${e.y2}`\n}\nfunction boxStyle(n: LaidNode): Record<string, string> {\n const s: Record<string, string> = {}\n if (n.node.bg) s.fill = n.node.bg\n if (n.node.color) s.stroke = n.node.color\n return s\n}\nfunction textStyle(n: LaidNode): Record<string, string> {\n return n.node.color ? { fill: n.node.color } : {}\n}\n\n// ---- Pan / zoom ----------------------------------------------------------------------------------------------\nconst viewport = ref<HTMLElement>()\nconst tx = ref(0)\nconst ty = ref(0)\nconst k = ref(1)\n\nfunction clamp(v: number, lo: number, hi: number): number {\n return Math.min(hi, Math.max(lo, v))\n}\n\n// Fit the laid-out content into the viewport (centered, a touch of padding), capped so tiny maps aren't huge.\nfunction fit(): void {\n const el = viewport.value\n const L = layout.value\n if (!el || !L.nodes.length) return\n const pad = 24\n const cw = el.clientWidth || 1\n const ch = el.clientHeight || 1\n const scale = clamp(\n Math.min((cw - pad * 2) / Math.max(L.width, 1), (ch - pad * 2) / Math.max(L.height, 1)),\n 0.2,\n 1.25,\n )\n k.value = scale\n tx.value = (cw - L.width * scale) / 2\n ty.value = (ch - L.height * scale) / 2\n}\n\nfunction zoomAround(cx: number, cy: number, factor: number): void {\n const nk = clamp(k.value * factor, 0.1, 4)\n const wx = (cx - tx.value) / k.value\n const wy = (cy - ty.value) / k.value\n tx.value = cx - wx * nk\n ty.value = cy - wy * nk\n k.value = nk\n}\nfunction zoomBy(factor: number): void {\n const el = viewport.value\n if (!el) return\n zoomAround(el.clientWidth / 2, el.clientHeight / 2, factor)\n}\nfunction onWheel(e: WheelEvent): void {\n e.preventDefault()\n const el = viewport.value\n if (!el) return\n const r = el.getBoundingClientRect()\n zoomAround(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * 0.0015))\n}\n\n// Pan only when the drag starts on empty canvas, so a click that lands on a node toggles it instead of panning.\nlet panning = false\nlet sx = 0\nlet sy = 0\nlet stx = 0\nlet sty = 0\nfunction onBgPointerDown(e: PointerEvent): void {\n if (e.button !== 0) return\n if ((e.target as Element)?.closest('[data-node]')) return\n panning = true\n sx = e.clientX\n sy = e.clientY\n stx = tx.value\n sty = ty.value\n ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)\n}\nfunction onPointerMove(e: PointerEvent): void {\n if (!panning) return\n tx.value = stx + (e.clientX - sx)\n ty.value = sty + (e.clientY - sy)\n}\nfunction onPointerUp(e: PointerEvent): void {\n if (!panning) return\n panning = false\n try {\n ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)\n } catch {\n /* capture already released */\n }\n}\n\nfunction toggle(n: LaidNode): void {\n if (!n.hasChildren) return\n if (collapsed.has(n.node.id)) collapsed.delete(n.node.id)\n else collapsed.add(n.node.id)\n}\n\nconst summary = computed(() => {\n const map = result.value.map\n if (!map) return ''\n const fmt = map.format === 'opml' ? 'OPML' : map.format === 'xmind' ? 'XMind' : 'FreeMind'\n return `${fmt} · ${map.count} 个节点`\n})\n\n// Refit when the panel is resized (the splitter drag changes our width).\nlet ro: ResizeObserver | undefined\nonMounted(() => {\n fit()\n if (typeof ResizeObserver !== 'undefined' && viewport.value) {\n ro = new ResizeObserver(() => fit())\n ro.observe(viewport.value)\n }\n})\nonBeforeUnmount(() => ro?.disconnect())\n</script>\n\n<template>\n <div class=\"mm\">\n <p v-if=\"result.error\" class=\"mm__msg mm__msg--error\">{{ result.error }}</p>\n <p v-else-if=\"!layout.nodes.length\" class=\"mm__msg\">空的思维导图</p>\n\n <template v-else>\n <div\n ref=\"viewport\"\n class=\"mm__canvas\"\n :class=\"{ 'mm__canvas--panning': panning }\"\n @wheel=\"onWheel\"\n @pointerdown=\"onBgPointerDown\"\n @pointermove=\"onPointerMove\"\n @pointerup=\"onPointerUp\"\n @pointerleave=\"onPointerUp\"\n >\n <svg class=\"mm__svg\" width=\"100%\" height=\"100%\">\n <g :transform=\"`translate(${tx},${ty}) scale(${k})`\">\n <path\n v-for=\"(e, i) in layout.edges\"\n :key=\"'e' + i\"\n class=\"mm__edge\"\n :d=\"edgePath(e)\"\n :stroke=\"e.color\"\n />\n <g\n v-for=\"n in layout.nodes\"\n :key=\"n.node.id\"\n data-node\n :transform=\"`translate(${n.x},${n.y - n.h / 2})`\"\n :class=\"{ 'mm__node--clickable': n.hasChildren }\"\n >\n <rect\n class=\"mm__box\"\n :class=\"{\n 'mm__box--root': n.depth === 0,\n 'mm__box--branch': n.depth > 0 && n.hasChildren,\n }\"\n :width=\"n.w\"\n :height=\"n.h\"\n rx=\"8\"\n :style=\"boxStyle(n)\"\n @click=\"toggle(n)\"\n />\n <!-- each line is centered in its LINE_H band (dominant-baseline:central) → the whole block,\n box height = lines*LINE_H + 2*PAD_Y, sits vertically centered without a measure pass -->\n <text class=\"mm__text\" :style=\"textStyle(n)\" @click=\"toggle(n)\">\n <tspan\n v-for=\"(ln, li) in n.lines\"\n :key=\"li\"\n :x=\"PAD_X\"\n :y=\"PAD_Y + li * LINE_H + LINE_H / 2\"\n >\n {{ ln }}\n </tspan>\n </text>\n <title v-if=\"n.node.note\">{{ n.node.note }}</title>\n\n <!-- collapse/expand pip on the right edge (only when the node has children).\n The +/− are drawn as stroked lines (not glyphs) so they stay perfectly centered in the\n circle regardless of font metrics. -->\n <g v-if=\"n.hasChildren\" class=\"mm__pip\" @click.stop=\"toggle(n)\">\n <circle :cx=\"n.w\" :cy=\"n.h / 2\" r=\"7.5\" />\n <line class=\"mm__pipsign\" :x1=\"n.w - 4\" :y1=\"n.h / 2\" :x2=\"n.w + 4\" :y2=\"n.h / 2\" />\n <line\n v-if=\"n.collapsed\"\n class=\"mm__pipsign\"\n :x1=\"n.w\"\n :y1=\"n.h / 2 - 4\"\n :x2=\"n.w\"\n :y2=\"n.h / 2 + 4\"\n />\n <text\n v-if=\"n.collapsed\"\n class=\"mm__pipcount\"\n :x=\"n.w + 12\"\n :y=\"n.h / 2\"\n dominant-baseline=\"central\"\n >\n {{ n.node.children.length }}\n </text>\n </g>\n\n <!-- external link glyph (top-right), opens in a new tab without triggering the toggle -->\n <a\n v-if=\"n.node.link\"\n :href=\"n.node.link\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n @click.stop\n @pointerdown.stop\n >\n <text class=\"mm__link\" :x=\"n.w - 5\" y=\"-3\" text-anchor=\"end\">↗</text>\n </a>\n </g>\n </g>\n </svg>\n </div>\n\n <div class=\"mm__toolbar\">\n <span class=\"mm__summary\">{{ summary }}</span>\n <span class=\"mm__zoom\">\n <button type=\"button\" class=\"mm__tbtn\" title=\"放大\" @click=\"zoomBy(1.2)\">+</button>\n <button type=\"button\" class=\"mm__tbtn\" title=\"缩小\" @click=\"zoomBy(1 / 1.2)\">−</button>\n <button type=\"button\" class=\"mm__tbtn mm__tbtn--text\" title=\"适应窗口\" @click=\"fit\">\n 适应\n </button>\n </span>\n </div>\n </template>\n </div>\n</template>\n\n<style scoped>\n.mm {\n position: relative;\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface-2);\n}\n.mm__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.mm__msg--error {\n color: var(--danger);\n}\n\n.mm__canvas {\n flex: 1 1 auto;\n min-height: 0;\n overflow: hidden;\n cursor: grab;\n touch-action: none;\n}\n.mm__canvas--panning {\n cursor: grabbing;\n}\n.mm__svg {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n/* Edges: stroke color comes from the node/depth palette (inline `stroke`); width/fill fixed here. */\n.mm__edge {\n fill: none;\n stroke-width: 2;\n stroke-opacity: 0.55;\n}\n\n.mm__node--clickable .mm__box,\n.mm__node--clickable .mm__text {\n cursor: pointer;\n}\n.mm__box {\n fill: var(--surface);\n stroke: var(--border-2);\n stroke-width: 1.2;\n transition: filter 0.12s;\n}\n.mm__node--clickable:hover .mm__box {\n filter: brightness(1.04);\n stroke: var(--accent);\n}\n.mm__box--root {\n fill: var(--accent);\n stroke: var(--accent);\n}\n.mm__box--branch {\n stroke-width: 1.5;\n}\n.mm__text {\n font-family: var(--font-sans, inherit);\n font-size: 13px;\n fill: var(--text);\n dominant-baseline: central;\n}\n/* Root box is filled with the accent → its label must read on that fill. */\n.mm__box--root + .mm__text {\n fill: #fff;\n font-weight: 600;\n}\n\n.mm__pip {\n cursor: pointer;\n}\n.mm__pip circle {\n fill: var(--surface);\n stroke: var(--border-2);\n stroke-width: 1.2;\n}\n.mm__pip:hover circle {\n stroke: var(--accent);\n}\n.mm__pipsign {\n stroke: var(--muted);\n stroke-width: 1.6;\n stroke-linecap: round;\n}\n.mm__pip:hover .mm__pipsign {\n stroke: var(--accent);\n}\n.mm__pipcount {\n font-family: var(--font-mono, monospace);\n font-size: 10.5px;\n fill: var(--faint);\n user-select: none;\n}\n\n.mm__link {\n font-size: 12px;\n fill: var(--accent);\n cursor: pointer;\n}\n\n/* Floating toolbar, bottom-right of the canvas. */\n.mm__toolbar {\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n padding: 7px 12px;\n background: color-mix(in srgb, var(--surface) 86%, transparent);\n border-top: 1px solid var(--border);\n backdrop-filter: blur(4px);\n}\n.mm__summary {\n font-size: 11.5px;\n letter-spacing: 0.02em;\n color: var(--faint);\n}\n.mm__zoom {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n}\n.mm__tbtn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 28px;\n height: 26px;\n padding: 0 6px;\n font-family: inherit;\n font-size: 14px;\n line-height: 1;\n color: var(--muted);\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 7px;\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s,\n border-color 0.15s;\n}\n.mm__tbtn:hover {\n color: var(--accent);\n border-color: var(--accent);\n}\n.mm__tbtn--text {\n font-size: 12px;\n}\n</style>\n"],"mappings":""}
|