@l1yp/file-viewer 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +55 -6
  2. package/dist/assets/step.worker-CfDTYOf_.js.map +1 -0
  3. package/dist/components/CertViewer.js +1 -1
  4. package/dist/components/CertViewer.js.map +1 -1
  5. package/dist/components/CertViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  6. package/dist/components/DocxNavigationPane.js +1 -1
  7. package/dist/components/DocxNavigationPane.js.map +1 -1
  8. package/dist/components/DocxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  9. package/dist/components/DocxViewer.js +1 -1
  10. package/dist/components/DocxViewer.js.map +1 -1
  11. package/dist/components/DocxViewer.vue.d.ts +1 -1
  12. package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js +80 -78
  13. package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  14. package/dist/components/EplViewer.js +1 -1
  15. package/dist/components/EplViewer.js.map +1 -1
  16. package/dist/components/EplViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  17. package/dist/components/FileViewerBody.js +1 -1
  18. package/dist/components/FileViewerBody.js.map +1 -1
  19. package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js +265 -257
  20. package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js.map +1 -1
  21. package/dist/components/FileViewerPanel.js +1 -1
  22. package/dist/components/FileViewerPanel.js.map +1 -1
  23. package/dist/components/FileViewerPanel.vue.d.ts +6 -1
  24. package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js +42 -40
  25. package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js.map +1 -1
  26. package/dist/components/HarViewer.js +1 -1
  27. package/dist/components/HarViewer.js.map +1 -1
  28. package/dist/components/HarViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  29. package/dist/components/JksViewer.js +1 -1
  30. package/dist/components/JksViewer.js.map +1 -1
  31. package/dist/components/JksViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  32. package/dist/components/ModelViewModes.js +9 -0
  33. package/dist/components/ModelViewModes.js.map +1 -0
  34. package/dist/components/ModelViewModes.vue.d.ts +11 -0
  35. package/dist/components/ModelViewModes.vue_vue_type_script_setup_true_lang.js +49 -0
  36. package/dist/components/ModelViewModes.vue_vue_type_script_setup_true_lang.js.map +1 -0
  37. package/dist/components/ModelViewer.js +1 -1
  38. package/dist/components/ModelViewer.js.map +1 -1
  39. package/dist/components/ModelViewer.vue.d.ts +2 -1
  40. package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js +183 -96
  41. package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  42. package/dist/components/PdfNavigationPane.js +1 -1
  43. package/dist/components/PdfNavigationPane.js.map +1 -1
  44. package/dist/components/PdfNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  45. package/dist/components/PdfViewer.js +1 -1
  46. package/dist/components/PdfViewer.js.map +1 -1
  47. package/dist/components/PdfViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  48. package/dist/components/PptxNavigationPane.js +1 -1
  49. package/dist/components/PptxNavigationPane.js.map +1 -1
  50. package/dist/components/PptxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  51. package/dist/components/PsdViewer.js +1 -1
  52. package/dist/components/PsdViewer.js.map +1 -1
  53. package/dist/components/PsdViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  54. package/dist/components/SqliteViewer.js +1 -1
  55. package/dist/components/SqliteViewer.js.map +1 -1
  56. package/dist/components/SqliteViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  57. package/dist/components/ViewerLoadError.js +9 -0
  58. package/dist/components/ViewerLoadError.js.map +1 -0
  59. package/dist/components/ViewerLoadError.vue.d.ts +17 -0
  60. package/dist/components/ViewerLoadError.vue_vue_type_script_setup_true_lang.js +58 -0
  61. package/dist/components/ViewerLoadError.vue_vue_type_script_setup_true_lang.js.map +1 -0
  62. package/dist/components/XlsxChart.js +1 -1
  63. package/dist/components/XlsxChart.js.map +1 -1
  64. package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js +9 -9
  65. package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js.map +1 -1
  66. package/dist/components/XlsxImageLayer.js +9 -0
  67. package/dist/components/XlsxImageLayer.js.map +1 -0
  68. package/dist/components/XlsxImageLayer.vue.d.ts +9 -0
  69. package/dist/components/XlsxImageLayer.vue_vue_type_script_setup_true_lang.js +46 -0
  70. package/dist/components/XlsxImageLayer.vue_vue_type_script_setup_true_lang.js.map +1 -0
  71. package/dist/components/XlsxViewer.js +1 -1
  72. package/dist/components/XlsxViewer.js.map +1 -1
  73. package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js +230 -216
  74. package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  75. package/dist/components/epl/EplResource.js +1 -1
  76. package/dist/components/epl/EplResource.js.map +1 -1
  77. package/dist/components/epl/EplResource.vue_vue_type_script_setup_true_lang.js.map +1 -1
  78. package/dist/components/optionalViewer.d.ts +3 -2
  79. package/dist/components/optionalViewer.js +6 -3
  80. package/dist/components/optionalViewer.js.map +1 -1
  81. package/dist/composables/useFileViewer.d.ts +11 -3
  82. package/dist/composables/useFileViewer.js.map +1 -1
  83. package/dist/composables/useTeleportTheme.d.ts +7 -0
  84. package/dist/composables/useTeleportTheme.js +42 -0
  85. package/dist/composables/useTeleportTheme.js.map +1 -0
  86. package/dist/index.d.ts +1 -0
  87. package/dist/index.js +29 -28
  88. package/dist/lib/docxTheme.d.ts +6 -0
  89. package/dist/lib/docxTheme.js +64 -0
  90. package/dist/lib/docxTheme.js.map +1 -0
  91. package/dist/lib/epl/code/structures.js +82 -0
  92. package/dist/lib/epl/code/structures.js.map +1 -0
  93. package/dist/lib/epl/code/variable.js +16 -0
  94. package/dist/lib/epl/code/variable.js.map +1 -0
  95. package/dist/lib/epl/eplDocument.js +28 -22
  96. package/dist/lib/epl/eplDocument.js.map +1 -1
  97. package/dist/lib/epl/index.d.ts +2 -1
  98. package/dist/lib/epl/index.js.map +1 -1
  99. package/dist/lib/epl/sections/code.js +37 -0
  100. package/dist/lib/epl/sections/code.js.map +1 -0
  101. package/dist/lib/epl/sections/esystemInfo.js +29 -0
  102. package/dist/lib/epl/sections/esystemInfo.js.map +1 -0
  103. package/dist/lib/epl/sections/losable.js +33 -0
  104. package/dist/lib/epl/sections/losable.js.map +1 -0
  105. package/dist/lib/epl/sections/projectConfig.js +39 -0
  106. package/dist/lib/epl/sections/projectConfig.js.map +1 -0
  107. package/dist/lib/epl/sections/resource.js +84 -0
  108. package/dist/lib/epl/sections/resource.js.map +1 -0
  109. package/dist/lib/fileType.d.ts +3 -1
  110. package/dist/lib/fileType.js +150 -137
  111. package/dist/lib/fileType.js.map +1 -1
  112. package/dist/lib/modelAppearance.d.ts +7 -0
  113. package/dist/lib/modelAppearance.js +41 -0
  114. package/dist/lib/modelAppearance.js.map +1 -0
  115. package/dist/lib/step.d.ts +10 -0
  116. package/dist/lib/step.js +35 -0
  117. package/dist/lib/step.js.map +1 -0
  118. package/dist/lib/step.worker.d.ts +1 -0
  119. package/dist/lib/step.worker.js +18 -0
  120. package/dist/lib/step.worker.js.map +1 -0
  121. package/dist/lib/stepObject.d.ts +4 -0
  122. package/dist/lib/stepObject.js +52 -0
  123. package/dist/lib/stepObject.js.map +1 -0
  124. package/dist/lib/stepTypes.d.ts +60 -0
  125. package/dist/lib/viewerKind.js +10 -5
  126. package/dist/lib/viewerKind.js.map +1 -1
  127. package/dist/lib/xlsx.d.ts +1 -1
  128. package/dist/lib/xlsx.js +106 -89
  129. package/dist/lib/xlsx.js.map +1 -1
  130. package/dist/lib/xlsxCharts.js +71 -119
  131. package/dist/lib/xlsxCharts.js.map +1 -1
  132. package/dist/lib/xlsxImages.d.ts +43 -0
  133. package/dist/lib/xlsxImages.js +114 -0
  134. package/dist/lib/xlsxImages.js.map +1 -0
  135. package/dist/lib/xlsxParts.d.ts +17 -0
  136. package/dist/lib/xlsxParts.js +86 -0
  137. package/dist/lib/xlsxParts.js.map +1 -0
  138. package/dist/style.css +1 -1
  139. package/package.json +8 -3
package/README.md CHANGED
@@ -9,10 +9,12 @@
9
9
  - **60+ 格式**:docx/xlsx/pptx/pdf、zip/7z/rar/tar、png/tiff/psd/heic/dcm、csv/jsonl/sqlite、markdown/代码高亮、eml/mht/har、字体/证书/密钥库、epub/mob、drawio/dxf/geojson、hprof/.class/.so/.dll、易语言 .e/.ec 等。
10
10
  - **Word 内嵌文件**:识别 DOCX 中被 `docx-preview` 跳过的嵌入包(含直接嵌入的 XLSX 与 OLE 包装的 PPTX),把可用的 Word/WPS 内容快照插回正文原位置;遇到浏览器不支持的 EMF 快照时,按需用 PPTX renderer 渲染第一页。点击正文预览即可打开完整文件,也可下载原始附件。
11
11
  - **终端日志还原**:日志和纯文本可解释 ANSI SGR 前景色、背景色、16/256/真彩色及常见文字装饰;搜索、折叠、复制使用净化后的纯文本,其他终端控制命令不会在页面执行。
12
+ - **STEP 三维预览**:`.stp/.step` 在独立 Worker 内解析,保留可用的零件/面颜色,支持旋转、缩放、平移与自动适配视口;可取消,切换文件和关闭预览会终止解析。
13
+ - **三维查看模式**:所有网格模型均支持「原色 / 白模 / 线框」切换。原色保留导入的材质、贴图及顶点/面颜色,白模使用统一浅色哑光材质,线框沿用模型原有的材质颜色、贴图及顶点/面颜色,显示包含背面及已有内部结构的三角网格。切换无需重新下载或解析,保持当前视角;线框展示的是离散后的三角边,不等同于 CAD 特征边或剖切面。
12
14
  - **ZIP 网页预览**:打开压缩包里的 HTML 时,自动解析并加载同包的 JS、CSS、图片、字体等相对依赖;源码保持原样,脚本仍需用户主动开启后才会在不透明源沙箱中运行。
13
15
  - **零后端**:上游字节只在浏览器解析;HTML/SVG 走沙箱 iframe、下载端点无法内联执行。
14
16
  - **按需分包**:每个查看器是独立的懒加载 chunk,打开 PDF 才拉 PDF 引擎;用不到的格式零成本。
15
- - **重型依赖可选**:只装你需要的格式的引擎;没装时显示「请安装 X」提示卡,不崩溃。
17
+ - **重型依赖可选**:只装你需要的格式的引擎;明确缺少依赖时显示安装提示,网络、缓存或模块执行失败时显示真实错误与刷新入口。
16
18
  - **主题可定制**:CSS 变量作用域限定在组件根,不污染宿主;`theme` prop 或覆盖变量即可换肤。
17
19
 
18
20
  ## 安装
@@ -33,6 +35,7 @@ npm i @l1yp/file-viewer vue
33
35
  | PowerPoint `.pptx` | `@aiden0z/pptx-renderer pdfjs-dist` |
34
36
  | SQLite `.sqlite/.db` | `sql.js` |
35
37
  | 3D 模型 `.stl/.obj/.glb/…` | `three` |
38
+ | STEP 模型 `.stp/.step` | `three occt-import-js` |
36
39
  | 地图 `.geojson/.kml/.gpx` | `maplibre-gl` |
37
40
  | Photoshop `.psd` | `ag-psd` |
38
41
  | TIFF `.tif/.tiff` | `utif2` |
@@ -43,10 +46,24 @@ npm i @l1yp/file-viewer vue
43
46
  | Brotli 归档 `.br` | `brotli-dec-wasm` |
44
47
  | 微信/QQ 语音(SILK/AMR) | `silk-wasm @audio/decode-amr` |
45
48
 
46
- 未安装某格式的引擎时,打开该文件会显示一张「预览 X 需要安装 `<pkg>`」的卡片(含一键复制安装命令 + 下载原文件),不会报错崩溃。
49
+ 加载器明确报告缺少某格式的引擎时,打开该文件会显示一张安装提示卡(含一键复制安装命令 + 下载原文件)。模块下载失败、开发缓存失效或模块执行异常会显示「预览暂时无法加载」、错误详情与刷新入口,不会误报需要安装依赖。
47
50
 
48
51
  > **打包器提示**:这些可选依赖在库构建里是 `external`。若你的打包器(Vite/webpack)对未安装的可选依赖在**构建期**报「无法解析」,把你不用的那几个加进 bundler 的 external / `optimizeDeps.exclude` 即可。
49
52
 
53
+ ### STEP / STP
54
+
55
+ ```vue
56
+ <FileViewer :blob="file" name="part.step" />
57
+ <!-- 与其他附件一样支持下载 URL、鉴权 fetchConfig、load 和压缩包内的 Blob URL。 -->
58
+ <FileViewer :src="downloadUrl" name="assembly.stp" :size="fileSize" />
59
+ ```
60
+
61
+ STEP 使用 `occt-import-js`(OpenCascade WASM)进行曲面离散,再交给现有 Three.js 查看器显示。解析器只在打开 STEP 时加载;其 JS/WASM 通过 Vite `?url` 交由宿主打包成静态资源,不访问 CDN、不上传模型、不需要后端转换接口。发布的库内包含 Worker 启动代码,消费者无需复制 Worker 文件或配置固定资源路径;使用其他打包器时需支持这些资源 URL 导入。
62
+
63
+ 超过 **20 MiB** 或大小未知的 STEP 文件需点击后再开始加载,解析任务最多 **120 秒**,可以主动取消;每次解析使用独立 Worker,成功、失败、取消及超时都会终止 Worker 并释放 WASM 内存。复杂装配体的耗时与内存还取决于几何复杂度,大小提示不是兼容性保证。只读预览不提供 CAD 编辑、工程精度测量或装配树操作;输出单位统一为毫米,保留解析器读出的层级、零件名称及颜色。
64
+
65
+ 宿主若配置了严格 CSP,需允许 Blob Worker(`worker-src blob:`)、自托管解析器脚本及 WASM 执行;不依赖 `SharedArrayBuffer` 或 COOP/COEP。`occt-import-js` 为可选外部依赖,使用其 [LGPL-2.1 许可](https://github.com/kovacsv/occt-import-js/blob/main/LICENSE.md),与本包自身的 MIT 许可分开。
66
+
50
67
  ## 使用
51
68
 
52
69
  ```ts
@@ -103,7 +120,7 @@ import {
103
120
 
104
121
  所有样式的 CSS 变量作用域限定在组件根 `.fv-root`,**不碰宿主全局样式**。三种定制方式:
105
122
 
106
- 1. **`theme` prop**:`theme="light | dark | system | inherit"`。`inherit` = 不设主题、直接吃宿主已定义的变量。
123
+ 1. **`theme` prop**:`theme="light | dark | system | inherit"`。`FileViewer` 默认 `system`,跟随系统偏好并实时响应切换;显式 `light` / `dark` 只控制当前实例,可在同一页面并排使用。`inherit` 不声明配色与字体变量,直接继承最近宿主或父查看器的变量及 `color-scheme`。
107
124
  2. **覆盖 CSS 变量**:在你自己的 `.fv-root` 规则里改,例如
108
125
  ```css
109
126
  .fv-root {
@@ -114,20 +131,52 @@ import {
114
131
  3. 复制 `dist` 里的令牌整套替换。
115
132
 
116
133
  > **数学公式**:如需 Markdown 里的数学公式正常显示,宿主需自行 `import 'katex/dist/katex.min.css'`。
117
- > 直接用低层的 `<FileViewerPanel>`(不带 `.fv-root` 包裹)时,请自己定义那批 CSS 变量,或改用带 `.fv-root` 的 `<FileViewer>`。
134
+
135
+ `FileViewerPanel` 同样带 `.fv-root` 并接受上述 `theme` prop,默认 `inherit` 以延续宿主页面主题。单独使用侧栏且宿主未定义变量时,传 `theme="system"`。只用 `FileViewerBody` 时,可在外层添加 `<div class="fv-root" data-theme="system">`,或提供自己的变量。
136
+
137
+ ```vue
138
+ <!-- 宿主维护 light / dark / system 时,直接绑定,主题切换不会重新读取文件。 -->
139
+ <FileViewer :blob="file" :name="file.name" :theme="appTheme" />
140
+
141
+ <!-- 宿主已有完整配色变量时:字体、配色和原生控件都继承宿主。 -->
142
+ <FileViewer :src="url" name="report.csv" theme="inherit" />
143
+ ```
144
+
145
+ 使用 `inherit` 的宿主应同步设置 `color-scheme: light` / `dark`(跟随系统时为 `light dark`),让滚动条、下拉框等原生控件与 CSS 配色一致。代码高亮与 ANSI 基础色随包内样式提供,不再依赖宿主额外导入高亮样式。HTML 全屏工具栏保留来源查看器的主题和自定义变量,并在打开期间响应主题切换。
146
+
147
+ 主题覆盖查看器外壳、导航、工具栏及状态提示。XLSX 的默认表格、行列标题、冻结区域、选区和图表也跟随主题;自动文字色与未调色的 Office 默认文字色(`theme: 1`)随背景切换。文件明确指定的字体色和填充色保持原样,这些单元格缺省的一侧沿用原稿的黑字/白底,避免深色背景与原稿字体冲突;图表中明确指定的数据系列颜色同样保留。
148
+
149
+ DOCX 的黑白正文页会自动适配纸张、文字和表格线,切换主题无需重新解析;图片像素保留原样,透明部分保留原白纸底色。检测到彩色文字、底纹、复杂绘图或生成内容的页面会整页保留原稿,避免局部改色造成黑底黑字或丢失颜色含义。
150
+
151
+ PDF、PPTX、DOCX 的自实现目录、页面导航、页码、选中状态、缩略图容器及外围区域都跟随主题。PDF 和 PPTX 正文及缩略图保留原稿:PDF 的页面配色接口会对整页画布施加滤镜,PPTX 的文件主题与版式配色也不能可靠地当作界面主题替换。RTF 纸张、图片、视频和沙箱网页同样保留原稿配色。
118
152
 
119
153
  ## 开发
120
154
 
121
155
  ```sh
122
156
  pnpm -C packages/file-viewer type-check # vue-tsc 类型检查
123
- pnpm -C packages/file-viewer build # vp build(JS 分包 + style.css)+ vue-tsc(.d.ts)
157
+ pnpm -C packages/file-viewer build # vp build(JS 分包 + style.css)+ 产物冒烟 + vue-tsc(.d.ts)
158
+ ```
159
+
160
+ 构建与 `prepack` 会直接运行易语言解析器的 JS 产物,检查按模块分包后的依赖是否完整,并覆盖源码/模块元数据、GBK 解码和异常类型。也可额外传入本地 `.e` / `.ec` 文件验证(文件仅在本机读取):
161
+
162
+ ```sh
163
+ node packages/file-viewer/scripts/epl-build-smoke.mjs /path/to/example.e
164
+ ```
165
+
166
+ 在本仓库中,STEP 浏览器回归页会用自生成的带面颜色立方体运行真实 OCCT/WASM,覆盖类型识别、毫米尺寸、面颜色、Worker 取消/超时、文件切换与 WebGL 卸载。先构建组件库,再启动 `pnpm dev` 并打开 `/scripts/step-smoke.html`。验证消费者生产打包(包括相对 `base` 下的 Worker/JS/WASM 地址):
167
+
168
+ ```sh
169
+ pnpm exec vp build --config scripts/step-smoke.config.ts
170
+ pnpm exec vp preview --config scripts/step-smoke.config.ts --port 4174
171
+ # 打开 http://localhost:4174/scripts/step-smoke.html,等待 STEP smoke PASSED。
124
172
  ```
125
173
 
126
174
  ## 发布
127
175
 
128
176
  ```sh
129
177
  npm pack --dry-run # 预览将发布的文件(dist + README + LICENSE)
130
- npm publish # publishConfig.access=public,已配好公开发布
178
+ npm login --registry=https://registry.npmjs.org/
179
+ npm publish --registry=https://registry.npmjs.org/ # publishConfig.access=public,已配好公开发布
131
180
  ```
132
181
 
133
182
  `prepack` 脚本会在打包/发布前自动重新构建。发布前记得在 `package.json` 补上 `repository` / `homepage` 字段。
@@ -0,0 +1 @@
1
+ {"version":3,"file":"step.worker-CfDTYOf_.js","names":[],"sources":["../../src/lib/step.worker.ts"],"sourcesContent":["/// <reference lib=\"webworker\" />\nimport type { OcctResult, StepReply, StepRequest, StepModel } from './stepTypes'\n\ndeclare const self: DedicatedWorkerGlobalScope & {\n occtimportjs: (options: {\n locateFile: (path: string) => string\n print: () => void\n printErr: () => void\n }) => Promise<{\n ReadStepFile: (bytes: Uint8Array, params: Record<string, string | number>) => OcctResult\n }>\n}\n\nfunction send(reply: StepReply, transfer: Transferable[] = []): void {\n self.postMessage(reply, transfer)\n}\n\n// One worker per import: terminating it cancels synchronous OCCT work and releases the entire WASM heap.\nself.onmessage = async ({ data }: MessageEvent<StepRequest>) => {\n try {\n send({ type: 'phase', phase: 'engine' })\n // OCCT ships a classic Emscripten script. Keeping it as an asset avoids bundling its Node fs/path branch.\n self.importScripts(data.scriptUrl)\n const occt = await self.occtimportjs({\n locateFile: () => data.wasmUrl,\n print: () => {},\n printErr: () => {},\n })\n send({ type: 'phase', phase: 'parse' })\n const result = occt.ReadStepFile(new Uint8Array(data.buffer), {\n linearUnit: 'millimeter',\n linearDeflectionType: 'bounding_box_ratio',\n linearDeflection: 0.001,\n angularDeflection: 0.5,\n })\n if (!result.success) throw new Error('无法解析 STEP 文件,文件可能已损坏或格式不受支持。')\n if (!result.meshes?.some((mesh) => mesh.index.array.length > 0)) {\n throw new Error('STEP 文件中没有可预览的曲面或实体。')\n }\n // Transfer typed buffers, rather than copying OCCT's large JS number arrays onto the UI thread.\n const transfer: ArrayBuffer[] = []\n const model: StepModel = {\n root: result.root,\n meshes: result.meshes.map((mesh) => {\n const positions = new Float32Array(mesh.attributes.position.array)\n const normals = mesh.attributes.normal\n ? new Float32Array(mesh.attributes.normal.array)\n : undefined\n const indices = new Uint32Array(mesh.index.array)\n transfer.push(positions.buffer, indices.buffer)\n if (normals) transfer.push(normals.buffer)\n return {\n name: mesh.name,\n color: mesh.color,\n faces: mesh.brep_faces ?? [],\n positions,\n normals,\n indices,\n }\n }),\n }\n send({ type: 'result', model }, transfer)\n } catch (error) {\n send({ type: 'error', message: error instanceof Error ? error.message : String(error) })\n }\n}\n"],"mappings":"YAaA,SAAS,EAAK,EAAkB,EAA2B,CAAC,EAAS,CACnE,KAAK,YAAY,EAAO,CAAQ,CAClC,CAGA,KAAK,UAAY,MAAO,CAAE,UAAsC,CAC9D,GAAI,CACF,EAAK,CAAE,KAAM,QAAS,MAAO,QAAS,CAAC,EAEvC,KAAK,cAAc,EAAK,SAAS,EACjC,IAAM,EAAO,MAAM,KAAK,aAAa,CACnC,eAAkB,EAAK,QACvB,UAAa,CAAC,EACd,aAAgB,CAAC,CACnB,CAAC,EACD,EAAK,CAAE,KAAM,QAAS,MAAO,OAAQ,CAAC,EACtC,IAAM,EAAS,EAAK,aAAa,IAAI,WAAW,EAAK,MAAM,EAAG,CAC5D,WAAY,aACZ,qBAAsB,qBACtB,iBAAkB,KAClB,kBAAmB,EACrB,CAAC,EACD,GAAI,CAAC,EAAO,QAAS,MAAU,MAAM,8BAA8B,EACnE,GAAI,CAAC,EAAO,QAAQ,KAAM,GAAS,EAAK,MAAM,MAAM,OAAS,CAAC,EAC5D,MAAU,MAAM,sBAAsB,EAGxC,IAAM,EAA0B,CAAC,EAqBjC,EAAK,CAAE,KAAM,SAAU,MAAA,CAnBrB,KAAM,EAAO,KACb,OAAQ,EAAO,OAAO,IAAK,GAAS,CAClC,IAAM,EAAY,IAAI,aAAa,EAAK,WAAW,SAAS,KAAK,EAC3D,EAAU,EAAK,WAAW,OAC5B,IAAI,aAAa,EAAK,WAAW,OAAO,KAAK,EAC7C,IAAA,GACE,EAAU,IAAI,YAAY,EAAK,MAAM,KAAK,EAGhD,OAFA,EAAS,KAAK,EAAU,OAAQ,EAAQ,MAAM,EAC1C,GAAS,EAAS,KAAK,EAAQ,MAAM,EAClC,CACL,KAAM,EAAK,KACX,MAAO,EAAK,MACZ,MAAO,EAAK,YAAc,CAAC,EAC3B,YACA,UACA,SACF,CACF,CAAC,CAEoB,CAAM,EAAG,CAAQ,CAC1C,OAAS,EAAO,CACd,EAAK,CAAE,KAAM,QAAS,QAAS,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAAE,CAAC,CACzF,CACF"}
@@ -2,7 +2,7 @@ import e from "../_virtual/_plugin-vue_export-helper.js";
2
2
  import t from "./CertViewer.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/CertViewer.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-adece100"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-1b2096e6"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8
 
@@ -1 +1 @@
1
- {"version":3,"file":"CertViewer.js","names":[],"sources":["../../src/components/CertViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport {\n asn1String,\n asn1Time,\n bytesToHex,\n decodeAsn1,\n oidToString,\n tagName,\n TAG,\n type Asn1Node,\n} from '@/lib/asn1'\nimport {\n looksLikePem,\n oidName,\n parseCertificate,\n parseCsr,\n parsePemBlocks,\n sha256Hex,\n summarizeBlock,\n type CertSummary,\n} from '@/lib/x509'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for certificates / keys (pem / crt / cer / der / csr / key / p12 / pfx). Fetches the BYTES\n * itself (binary-safe — a .der/.p12 would be mangled by the panel's text load), sniffs PEM vs raw DER, and\n * for each block shows the human-meaningful X.509 fields (subject / issuer, validity with an expiry badge,\n * serial, signature + public-key algorithm, SANs, key usages, SHA-256 fingerprint) plus a collapsible raw\n * ASN.1 structure tree. Parsing is hand-rolled and READ-ONLY (lib/asn1.ts + lib/x509.ts) — it never verifies\n * signatures or trust. PKCS#12 (.p12/.pfx) containers are usually encrypted, so they're shown as structure\n * only with a \"use openssl to extract\" note (best effort).\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ninterface CertItem {\n summary: CertSummary\n forest: Asn1Node[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst items = ref<CertItem[]>([])\nconst fingerprints = reactive<Record<number, string>>({})\nconst expanded = reactive(new Set<string>())\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nfunction buildItem(summary: CertSummary): CertItem {\n return { summary, forest: decodeAsn1(summary.der) }\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const ext = fileExt(props.name)\n const head = new TextDecoder('latin1').decode(bytes.subarray(0, Math.min(64, bytes.length)))\n const list: CertItem[] = []\n\n if (head.includes('-----BEGIN') || looksLikePem(new TextDecoder('latin1').decode(bytes))) {\n const text = new TextDecoder('utf-8').decode(bytes)\n for (const block of parsePemBlocks(text))\n list.push(buildItem(summarizeBlock(block.label, block.der)))\n }\n if (!list.length) {\n // Raw DER (or a PEM that yielded no blocks). Pick a parser from the extension.\n if (ext === 'p12' || ext === 'pfx') {\n const s = summarizeP12(bytes)\n list.push(buildItem(s))\n } else if (ext === 'csr' || ext === 'req') {\n list.push(buildItem(parseCsr(bytes)))\n } else {\n const cert = parseCertificate(bytes)\n list.push(buildItem(cert.ok ? cert : fallbackStructure(bytes, ext)))\n }\n }\n\n items.value = list\n status.value = 'ready'\n // Fingerprint each certificate asynchronously (SHA-256 of its DER).\n list.forEach((it, i) => {\n if (it.summary.kind === 'certificate' && it.summary.ok) {\n void sha256Hex(it.summary.der)\n .then((hex) => {\n fingerprints[i] = hex\n })\n .catch(() => {})\n }\n })\n // Open the first level of the first item so there's something to see without a click.\n if (list.length && list[0].forest.length) expanded.add('0.0')\n}\n\nfunction summarizeP12(bytes: Uint8Array): CertSummary {\n return {\n ok: true,\n kind: 'pkcs12',\n label: 'PKCS#12',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: 'PKCS#12 容器通常已加密,浏览器内仅展示 ASN.1 结构;如需提取证书/私钥请用 openssl pkcs12 -info。',\n }\n}\n\nfunction fallbackStructure(bytes: Uint8Array, ext: string): CertSummary {\n return {\n ok: true,\n kind: 'unknown',\n label: ext ? `.${ext}` : '二进制',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: '无法识别为 X.509 证书 —— 展开下方结构树查看原始 ASN.1。',\n }\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n items.value = []\n expanded.clear()\n for (const k of Object.keys(fingerprints)) delete fingerprints[Number(k)]\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\n// ── friendly-field formatting ────────────────────────────────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst KIND_LABEL: Record<CertSummary['kind'], string> = {\n certificate: '证书',\n request: '证书请求 (CSR)',\n key: '密钥',\n crl: 'CRL',\n pkcs12: 'PKCS#12',\n pkcs7: 'PKCS#7',\n unknown: '未知',\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'cert__badge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'cert__badge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'cert__badge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'cert__badge--ok' }\n}\n\n// ── ASN.1 structure tree (flattened with an expand set) ──────────────────────────────────────────\ninterface TreeRow {\n node: Asn1Node\n depth: number\n path: string\n hasChildren: boolean\n}\n\nfunction flatten(forest: Asn1Node[], prefix: string): TreeRow[] {\n const rows: TreeRow[] = []\n const walk = (nodes: Asn1Node[], depth: number, pre: string): void => {\n nodes.forEach((node, i) => {\n const path = `${pre}.${i}`\n const hasChildren = node.children.length > 0\n rows.push({ node, depth, path, hasChildren })\n if (hasChildren && expanded.has(path)) walk(node.children, depth + 1, path)\n })\n }\n walk(forest, 0, prefix)\n return rows\n}\n\nconst treeRows = computed(() => items.value.map((it, i) => flatten(it.forest, String(i))))\n\nfunction toggleNode(path: string): void {\n if (expanded.has(path)) expanded.delete(path)\n else expanded.add(path)\n}\n\n// A short, human value preview for a primitive node (constructed nodes show only their tag).\nfunction nodePreview(node: Asn1Node): string {\n if (node.constructed) return ''\n switch (node.tagNumber) {\n case TAG.OID: {\n const oid = oidToString(node.content)\n const nm = oidName(oid)\n return nm === oid ? oid : `${nm} (${oid})`\n }\n case TAG.BOOLEAN:\n return node.content[0] ? 'true' : 'false'\n case TAG.INTEGER: {\n if (node.content.length <= 4) {\n let v = 0\n for (const b of node.content) v = v * 256 + b\n return String(v)\n }\n return `0x${bytesToHex(node.content).slice(0, 32)}${node.content.length > 16 ? '…' : ''}`\n }\n case TAG.UTF8String:\n case TAG.PrintableString:\n case TAG.IA5String:\n return asn1String(node)\n case TAG.UTCTime:\n case TAG.GeneralizedTime:\n return asn1Time(node)?.text ?? asn1String(node)\n case TAG.NULL:\n return ''\n default: {\n if (node.tagClass === 2) {\n // context-specific primitive (e.g. a SAN name) — show as text if printable, else hex\n const txt = new TextDecoder('latin1').decode(node.content)\n if (/^[\\x20-\\x7e]*$/.test(txt)) return txt\n }\n const hex = bytesToHex(node.content)\n return hex.length > 32 ? `${hex.slice(0, 32)}… (${node.length}B)` : hex\n }\n }\n}\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"cert\">\n <p v-if=\"status === 'loading'\" class=\"cert__msg\">正在解析证书…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"cert__msg cert__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"cert__msg cert__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else class=\"cert__scroll\">\n <section v-for=\"(it, i) in items\" :key=\"i\" class=\"cert__card\">\n <header class=\"cert__cardhead\">\n <span class=\"cert__kind\">{{ KIND_LABEL[it.summary.kind] }}</span>\n <span class=\"cert__label\">{{ it.summary.subjectCN || it.summary.label }}</span>\n <span v-if=\"expiry(it.summary)\" class=\"cert__badge\" :class=\"expiry(it.summary)?.cls\">\n {{ expiry(it.summary)?.text }}\n </span>\n </header>\n\n <p v-if=\"it.summary.note\" class=\"cert__note\">{{ it.summary.note }}</p>\n\n <dl v-if=\"it.summary.subject.length || it.summary.kind === 'certificate'\" class=\"cert__dl\">\n <template v-if=\"it.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(it.summary.subject) }}</dd>\n </template>\n <template v-if=\"it.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(it.summary.issuer) }}</dd>\n </template>\n <template v-if=\"it.summary.notBefore || it.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ it.summary.notBefore ?? '?' }} → {{ it.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(it.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(it.summary) }}</dd>\n </template>\n <template v-if=\"it.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ it.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"it.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"cert__mono\">{{ it.summary.serialHex }}</dd>\n </template>\n <template v-if=\"it.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ it.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.keyUsages.length\">\n <dt>密钥用法</dt>\n <dd>{{ it.summary.keyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.extKeyUsages.length\">\n <dt>扩展用法</dt>\n <dd>{{ it.summary.extKeyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.isCa != null\">\n <dt>CA</dt>\n <dd>\n {{ it.summary.isCa ? '是' : '否'\n }}{{ it.summary.pathLen != null ? `(pathLen ${it.summary.pathLen})` : '' }}\n </dd>\n </template>\n <template v-if=\"fingerprints[i]\">\n <dt>SHA-256</dt>\n <dd class=\"cert__mono cert__fp\">{{ fingerprints[i] }}</dd>\n </template>\n </dl>\n\n <details class=\"cert__tree\">\n <summary class=\"cert__treehead\">ASN.1 结构</summary>\n <div class=\"cert__rows\">\n <button\n v-for=\"row in treeRows[i]\"\n :key=\"row.path\"\n type=\"button\"\n class=\"cert__row\"\n :style=\"{ paddingLeft: `${8 + row.depth * 16}px` }\"\n @click=\"row.hasChildren && toggleNode(row.path)\"\n >\n <span class=\"cert__twisty\">\n <Icon\n v-if=\"row.hasChildren\"\n name=\"chevronRight\"\n :size=\"12\"\n :class=\"{ 'cert__twisty--open': expanded.has(row.path) }\"\n />\n </span>\n <span class=\"cert__tag\">{{ tagName(row.node) }}</span>\n <span v-if=\"nodePreview(row.node)\" class=\"cert__val\">{{\n nodePreview(row.node)\n }}</span>\n </button>\n </div>\n </details>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cert {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.cert__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.cert__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.cert__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.cert__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.cert__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.cert__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n.cert__card {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.cert__cardhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.cert__kind {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 5px;\n padding: 2px 7px;\n}\n.cert__label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.cert__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.cert__badge--ok {\n color: #15803d;\n background: color-mix(in srgb, #22c55e 16%, transparent);\n}\n.cert__badge--warn {\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 18%, transparent);\n}\n.cert__badge--bad {\n color: #b91c1c;\n background: color-mix(in srgb, #ef4444 16%, transparent);\n}\n\n.cert__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n border-bottom: 1px solid var(--border);\n}\n\n.cert__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 12px;\n}\n.cert__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.cert__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.cert__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.cert__fp {\n word-break: break-all;\n}\n\n.cert__tree {\n border-top: 1px solid var(--border);\n}\n.cert__treehead {\n padding: 8px 12px;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n}\n.cert__treehead:hover {\n color: var(--accent);\n}\n.cert__rows {\n padding: 4px 0 10px;\n background: var(--code-bg);\n border-top: 1px solid var(--code-border);\n overflow-x: auto;\n}\n.cert__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 3px 12px 3px 8px;\n background: transparent;\n border: none;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text);\n text-align: left;\n white-space: nowrap;\n cursor: default;\n}\n.cert__row:hover {\n background: var(--surface-2);\n}\n.cert__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 12px;\n color: var(--faint);\n}\n.cert__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.cert__tag {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cert__val {\n min-width: 0;\n color: var(--muted);\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"CertViewer.js","names":[],"sources":["../../src/components/CertViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport {\n asn1String,\n asn1Time,\n bytesToHex,\n decodeAsn1,\n oidToString,\n tagName,\n TAG,\n type Asn1Node,\n} from '@/lib/asn1'\nimport {\n looksLikePem,\n oidName,\n parseCertificate,\n parseCsr,\n parsePemBlocks,\n sha256Hex,\n summarizeBlock,\n type CertSummary,\n} from '@/lib/x509'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for certificates / keys (pem / crt / cer / der / csr / key / p12 / pfx). Fetches the BYTES\n * itself (binary-safe — a .der/.p12 would be mangled by the panel's text load), sniffs PEM vs raw DER, and\n * for each block shows the human-meaningful X.509 fields (subject / issuer, validity with an expiry badge,\n * serial, signature + public-key algorithm, SANs, key usages, SHA-256 fingerprint) plus a collapsible raw\n * ASN.1 structure tree. Parsing is hand-rolled and READ-ONLY (lib/asn1.ts + lib/x509.ts) — it never verifies\n * signatures or trust. PKCS#12 (.p12/.pfx) containers are usually encrypted, so they're shown as structure\n * only with a \"use openssl to extract\" note (best effort).\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ninterface CertItem {\n summary: CertSummary\n forest: Asn1Node[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst items = ref<CertItem[]>([])\nconst fingerprints = reactive<Record<number, string>>({})\nconst expanded = reactive(new Set<string>())\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nfunction buildItem(summary: CertSummary): CertItem {\n return { summary, forest: decodeAsn1(summary.der) }\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const ext = fileExt(props.name)\n const head = new TextDecoder('latin1').decode(bytes.subarray(0, Math.min(64, bytes.length)))\n const list: CertItem[] = []\n\n if (head.includes('-----BEGIN') || looksLikePem(new TextDecoder('latin1').decode(bytes))) {\n const text = new TextDecoder('utf-8').decode(bytes)\n for (const block of parsePemBlocks(text))\n list.push(buildItem(summarizeBlock(block.label, block.der)))\n }\n if (!list.length) {\n // Raw DER (or a PEM that yielded no blocks). Pick a parser from the extension.\n if (ext === 'p12' || ext === 'pfx') {\n const s = summarizeP12(bytes)\n list.push(buildItem(s))\n } else if (ext === 'csr' || ext === 'req') {\n list.push(buildItem(parseCsr(bytes)))\n } else {\n const cert = parseCertificate(bytes)\n list.push(buildItem(cert.ok ? cert : fallbackStructure(bytes, ext)))\n }\n }\n\n items.value = list\n status.value = 'ready'\n // Fingerprint each certificate asynchronously (SHA-256 of its DER).\n list.forEach((it, i) => {\n if (it.summary.kind === 'certificate' && it.summary.ok) {\n void sha256Hex(it.summary.der)\n .then((hex) => {\n fingerprints[i] = hex\n })\n .catch(() => {})\n }\n })\n // Open the first level of the first item so there's something to see without a click.\n if (list.length && list[0].forest.length) expanded.add('0.0')\n}\n\nfunction summarizeP12(bytes: Uint8Array): CertSummary {\n return {\n ok: true,\n kind: 'pkcs12',\n label: 'PKCS#12',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: 'PKCS#12 容器通常已加密,浏览器内仅展示 ASN.1 结构;如需提取证书/私钥请用 openssl pkcs12 -info。',\n }\n}\n\nfunction fallbackStructure(bytes: Uint8Array, ext: string): CertSummary {\n return {\n ok: true,\n kind: 'unknown',\n label: ext ? `.${ext}` : '二进制',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: '无法识别为 X.509 证书 —— 展开下方结构树查看原始 ASN.1。',\n }\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n items.value = []\n expanded.clear()\n for (const k of Object.keys(fingerprints)) delete fingerprints[Number(k)]\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\n// ── friendly-field formatting ────────────────────────────────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst KIND_LABEL: Record<CertSummary['kind'], string> = {\n certificate: '证书',\n request: '证书请求 (CSR)',\n key: '密钥',\n crl: 'CRL',\n pkcs12: 'PKCS#12',\n pkcs7: 'PKCS#7',\n unknown: '未知',\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'cert__badge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'cert__badge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'cert__badge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'cert__badge--ok' }\n}\n\n// ── ASN.1 structure tree (flattened with an expand set) ──────────────────────────────────────────\ninterface TreeRow {\n node: Asn1Node\n depth: number\n path: string\n hasChildren: boolean\n}\n\nfunction flatten(forest: Asn1Node[], prefix: string): TreeRow[] {\n const rows: TreeRow[] = []\n const walk = (nodes: Asn1Node[], depth: number, pre: string): void => {\n nodes.forEach((node, i) => {\n const path = `${pre}.${i}`\n const hasChildren = node.children.length > 0\n rows.push({ node, depth, path, hasChildren })\n if (hasChildren && expanded.has(path)) walk(node.children, depth + 1, path)\n })\n }\n walk(forest, 0, prefix)\n return rows\n}\n\nconst treeRows = computed(() => items.value.map((it, i) => flatten(it.forest, String(i))))\n\nfunction toggleNode(path: string): void {\n if (expanded.has(path)) expanded.delete(path)\n else expanded.add(path)\n}\n\n// A short, human value preview for a primitive node (constructed nodes show only their tag).\nfunction nodePreview(node: Asn1Node): string {\n if (node.constructed) return ''\n switch (node.tagNumber) {\n case TAG.OID: {\n const oid = oidToString(node.content)\n const nm = oidName(oid)\n return nm === oid ? oid : `${nm} (${oid})`\n }\n case TAG.BOOLEAN:\n return node.content[0] ? 'true' : 'false'\n case TAG.INTEGER: {\n if (node.content.length <= 4) {\n let v = 0\n for (const b of node.content) v = v * 256 + b\n return String(v)\n }\n return `0x${bytesToHex(node.content).slice(0, 32)}${node.content.length > 16 ? '…' : ''}`\n }\n case TAG.UTF8String:\n case TAG.PrintableString:\n case TAG.IA5String:\n return asn1String(node)\n case TAG.UTCTime:\n case TAG.GeneralizedTime:\n return asn1Time(node)?.text ?? asn1String(node)\n case TAG.NULL:\n return ''\n default: {\n if (node.tagClass === 2) {\n // context-specific primitive (e.g. a SAN name) — show as text if printable, else hex\n const txt = new TextDecoder('latin1').decode(node.content)\n if (/^[\\x20-\\x7e]*$/.test(txt)) return txt\n }\n const hex = bytesToHex(node.content)\n return hex.length > 32 ? `${hex.slice(0, 32)}… (${node.length}B)` : hex\n }\n }\n}\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"cert\">\n <p v-if=\"status === 'loading'\" class=\"cert__msg\">正在解析证书…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"cert__msg cert__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"cert__msg cert__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else class=\"cert__scroll\">\n <section v-for=\"(it, i) in items\" :key=\"i\" class=\"cert__card\">\n <header class=\"cert__cardhead\">\n <span class=\"cert__kind\">{{ KIND_LABEL[it.summary.kind] }}</span>\n <span class=\"cert__label\">{{ it.summary.subjectCN || it.summary.label }}</span>\n <span v-if=\"expiry(it.summary)\" class=\"cert__badge\" :class=\"expiry(it.summary)?.cls\">\n {{ expiry(it.summary)?.text }}\n </span>\n </header>\n\n <p v-if=\"it.summary.note\" class=\"cert__note\">{{ it.summary.note }}</p>\n\n <dl v-if=\"it.summary.subject.length || it.summary.kind === 'certificate'\" class=\"cert__dl\">\n <template v-if=\"it.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(it.summary.subject) }}</dd>\n </template>\n <template v-if=\"it.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(it.summary.issuer) }}</dd>\n </template>\n <template v-if=\"it.summary.notBefore || it.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ it.summary.notBefore ?? '?' }} → {{ it.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(it.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(it.summary) }}</dd>\n </template>\n <template v-if=\"it.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ it.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"it.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"cert__mono\">{{ it.summary.serialHex }}</dd>\n </template>\n <template v-if=\"it.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ it.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.keyUsages.length\">\n <dt>密钥用法</dt>\n <dd>{{ it.summary.keyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.extKeyUsages.length\">\n <dt>扩展用法</dt>\n <dd>{{ it.summary.extKeyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.isCa != null\">\n <dt>CA</dt>\n <dd>\n {{ it.summary.isCa ? '是' : '否'\n }}{{ it.summary.pathLen != null ? `(pathLen ${it.summary.pathLen})` : '' }}\n </dd>\n </template>\n <template v-if=\"fingerprints[i]\">\n <dt>SHA-256</dt>\n <dd class=\"cert__mono cert__fp\">{{ fingerprints[i] }}</dd>\n </template>\n </dl>\n\n <details class=\"cert__tree\">\n <summary class=\"cert__treehead\">ASN.1 结构</summary>\n <div class=\"cert__rows\">\n <button\n v-for=\"row in treeRows[i]\"\n :key=\"row.path\"\n type=\"button\"\n class=\"cert__row\"\n :style=\"{ paddingLeft: `${8 + row.depth * 16}px` }\"\n @click=\"row.hasChildren && toggleNode(row.path)\"\n >\n <span class=\"cert__twisty\">\n <Icon\n v-if=\"row.hasChildren\"\n name=\"chevronRight\"\n :size=\"12\"\n :class=\"{ 'cert__twisty--open': expanded.has(row.path) }\"\n />\n </span>\n <span class=\"cert__tag\">{{ tagName(row.node) }}</span>\n <span v-if=\"nodePreview(row.node)\" class=\"cert__val\">{{\n nodePreview(row.node)\n }}</span>\n </button>\n </div>\n </details>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cert {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.cert__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.cert__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.cert__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.cert__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.cert__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.cert__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n.cert__card {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.cert__cardhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.cert__kind {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 5px;\n padding: 2px 7px;\n}\n.cert__label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.cert__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.cert__badge--ok {\n color: var(--hl-name);\n background: color-mix(in srgb, var(--hl-name) 16%, transparent);\n}\n.cert__badge--warn {\n color: var(--ansi-yellow);\n background: color-mix(in srgb, var(--ansi-yellow) 18%, transparent);\n}\n.cert__badge--bad {\n color: var(--danger);\n background: color-mix(in srgb, var(--danger) 16%, transparent);\n}\n\n.cert__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n border-bottom: 1px solid var(--border);\n}\n\n.cert__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 12px;\n}\n.cert__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.cert__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.cert__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.cert__fp {\n word-break: break-all;\n}\n\n.cert__tree {\n border-top: 1px solid var(--border);\n}\n.cert__treehead {\n padding: 8px 12px;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n}\n.cert__treehead:hover {\n color: var(--accent);\n}\n.cert__rows {\n padding: 4px 0 10px;\n background: var(--code-bg);\n border-top: 1px solid var(--code-border);\n overflow-x: auto;\n}\n.cert__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 3px 12px 3px 8px;\n background: transparent;\n border: none;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text);\n text-align: left;\n white-space: nowrap;\n cursor: default;\n}\n.cert__row:hover {\n background: var(--surface-2);\n}\n.cert__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 12px;\n color: var(--faint);\n}\n.cert__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.cert__tag {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cert__val {\n min-width: 0;\n color: var(--muted);\n}\n</style>\n"],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"CertViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/CertViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport {\n asn1String,\n asn1Time,\n bytesToHex,\n decodeAsn1,\n oidToString,\n tagName,\n TAG,\n type Asn1Node,\n} from '@/lib/asn1'\nimport {\n looksLikePem,\n oidName,\n parseCertificate,\n parseCsr,\n parsePemBlocks,\n sha256Hex,\n summarizeBlock,\n type CertSummary,\n} from '@/lib/x509'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for certificates / keys (pem / crt / cer / der / csr / key / p12 / pfx). Fetches the BYTES\n * itself (binary-safe — a .der/.p12 would be mangled by the panel's text load), sniffs PEM vs raw DER, and\n * for each block shows the human-meaningful X.509 fields (subject / issuer, validity with an expiry badge,\n * serial, signature + public-key algorithm, SANs, key usages, SHA-256 fingerprint) plus a collapsible raw\n * ASN.1 structure tree. Parsing is hand-rolled and READ-ONLY (lib/asn1.ts + lib/x509.ts) — it never verifies\n * signatures or trust. PKCS#12 (.p12/.pfx) containers are usually encrypted, so they're shown as structure\n * only with a \"use openssl to extract\" note (best effort).\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ninterface CertItem {\n summary: CertSummary\n forest: Asn1Node[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst items = ref<CertItem[]>([])\nconst fingerprints = reactive<Record<number, string>>({})\nconst expanded = reactive(new Set<string>())\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nfunction buildItem(summary: CertSummary): CertItem {\n return { summary, forest: decodeAsn1(summary.der) }\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const ext = fileExt(props.name)\n const head = new TextDecoder('latin1').decode(bytes.subarray(0, Math.min(64, bytes.length)))\n const list: CertItem[] = []\n\n if (head.includes('-----BEGIN') || looksLikePem(new TextDecoder('latin1').decode(bytes))) {\n const text = new TextDecoder('utf-8').decode(bytes)\n for (const block of parsePemBlocks(text))\n list.push(buildItem(summarizeBlock(block.label, block.der)))\n }\n if (!list.length) {\n // Raw DER (or a PEM that yielded no blocks). Pick a parser from the extension.\n if (ext === 'p12' || ext === 'pfx') {\n const s = summarizeP12(bytes)\n list.push(buildItem(s))\n } else if (ext === 'csr' || ext === 'req') {\n list.push(buildItem(parseCsr(bytes)))\n } else {\n const cert = parseCertificate(bytes)\n list.push(buildItem(cert.ok ? cert : fallbackStructure(bytes, ext)))\n }\n }\n\n items.value = list\n status.value = 'ready'\n // Fingerprint each certificate asynchronously (SHA-256 of its DER).\n list.forEach((it, i) => {\n if (it.summary.kind === 'certificate' && it.summary.ok) {\n void sha256Hex(it.summary.der)\n .then((hex) => {\n fingerprints[i] = hex\n })\n .catch(() => {})\n }\n })\n // Open the first level of the first item so there's something to see without a click.\n if (list.length && list[0].forest.length) expanded.add('0.0')\n}\n\nfunction summarizeP12(bytes: Uint8Array): CertSummary {\n return {\n ok: true,\n kind: 'pkcs12',\n label: 'PKCS#12',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: 'PKCS#12 容器通常已加密,浏览器内仅展示 ASN.1 结构;如需提取证书/私钥请用 openssl pkcs12 -info。',\n }\n}\n\nfunction fallbackStructure(bytes: Uint8Array, ext: string): CertSummary {\n return {\n ok: true,\n kind: 'unknown',\n label: ext ? `.${ext}` : '二进制',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: '无法识别为 X.509 证书 —— 展开下方结构树查看原始 ASN.1。',\n }\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n items.value = []\n expanded.clear()\n for (const k of Object.keys(fingerprints)) delete fingerprints[Number(k)]\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\n// ── friendly-field formatting ────────────────────────────────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst KIND_LABEL: Record<CertSummary['kind'], string> = {\n certificate: '证书',\n request: '证书请求 (CSR)',\n key: '密钥',\n crl: 'CRL',\n pkcs12: 'PKCS#12',\n pkcs7: 'PKCS#7',\n unknown: '未知',\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'cert__badge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'cert__badge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'cert__badge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'cert__badge--ok' }\n}\n\n// ── ASN.1 structure tree (flattened with an expand set) ──────────────────────────────────────────\ninterface TreeRow {\n node: Asn1Node\n depth: number\n path: string\n hasChildren: boolean\n}\n\nfunction flatten(forest: Asn1Node[], prefix: string): TreeRow[] {\n const rows: TreeRow[] = []\n const walk = (nodes: Asn1Node[], depth: number, pre: string): void => {\n nodes.forEach((node, i) => {\n const path = `${pre}.${i}`\n const hasChildren = node.children.length > 0\n rows.push({ node, depth, path, hasChildren })\n if (hasChildren && expanded.has(path)) walk(node.children, depth + 1, path)\n })\n }\n walk(forest, 0, prefix)\n return rows\n}\n\nconst treeRows = computed(() => items.value.map((it, i) => flatten(it.forest, String(i))))\n\nfunction toggleNode(path: string): void {\n if (expanded.has(path)) expanded.delete(path)\n else expanded.add(path)\n}\n\n// A short, human value preview for a primitive node (constructed nodes show only their tag).\nfunction nodePreview(node: Asn1Node): string {\n if (node.constructed) return ''\n switch (node.tagNumber) {\n case TAG.OID: {\n const oid = oidToString(node.content)\n const nm = oidName(oid)\n return nm === oid ? oid : `${nm} (${oid})`\n }\n case TAG.BOOLEAN:\n return node.content[0] ? 'true' : 'false'\n case TAG.INTEGER: {\n if (node.content.length <= 4) {\n let v = 0\n for (const b of node.content) v = v * 256 + b\n return String(v)\n }\n return `0x${bytesToHex(node.content).slice(0, 32)}${node.content.length > 16 ? '…' : ''}`\n }\n case TAG.UTF8String:\n case TAG.PrintableString:\n case TAG.IA5String:\n return asn1String(node)\n case TAG.UTCTime:\n case TAG.GeneralizedTime:\n return asn1Time(node)?.text ?? asn1String(node)\n case TAG.NULL:\n return ''\n default: {\n if (node.tagClass === 2) {\n // context-specific primitive (e.g. a SAN name) — show as text if printable, else hex\n const txt = new TextDecoder('latin1').decode(node.content)\n if (/^[\\x20-\\x7e]*$/.test(txt)) return txt\n }\n const hex = bytesToHex(node.content)\n return hex.length > 32 ? `${hex.slice(0, 32)}… (${node.length}B)` : hex\n }\n }\n}\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"cert\">\n <p v-if=\"status === 'loading'\" class=\"cert__msg\">正在解析证书…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"cert__msg cert__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"cert__msg cert__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else class=\"cert__scroll\">\n <section v-for=\"(it, i) in items\" :key=\"i\" class=\"cert__card\">\n <header class=\"cert__cardhead\">\n <span class=\"cert__kind\">{{ KIND_LABEL[it.summary.kind] }}</span>\n <span class=\"cert__label\">{{ it.summary.subjectCN || it.summary.label }}</span>\n <span v-if=\"expiry(it.summary)\" class=\"cert__badge\" :class=\"expiry(it.summary)?.cls\">\n {{ expiry(it.summary)?.text }}\n </span>\n </header>\n\n <p v-if=\"it.summary.note\" class=\"cert__note\">{{ it.summary.note }}</p>\n\n <dl v-if=\"it.summary.subject.length || it.summary.kind === 'certificate'\" class=\"cert__dl\">\n <template v-if=\"it.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(it.summary.subject) }}</dd>\n </template>\n <template v-if=\"it.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(it.summary.issuer) }}</dd>\n </template>\n <template v-if=\"it.summary.notBefore || it.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ it.summary.notBefore ?? '?' }} → {{ it.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(it.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(it.summary) }}</dd>\n </template>\n <template v-if=\"it.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ it.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"it.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"cert__mono\">{{ it.summary.serialHex }}</dd>\n </template>\n <template v-if=\"it.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ it.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.keyUsages.length\">\n <dt>密钥用法</dt>\n <dd>{{ it.summary.keyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.extKeyUsages.length\">\n <dt>扩展用法</dt>\n <dd>{{ it.summary.extKeyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.isCa != null\">\n <dt>CA</dt>\n <dd>\n {{ it.summary.isCa ? '是' : '否'\n }}{{ it.summary.pathLen != null ? `(pathLen ${it.summary.pathLen})` : '' }}\n </dd>\n </template>\n <template v-if=\"fingerprints[i]\">\n <dt>SHA-256</dt>\n <dd class=\"cert__mono cert__fp\">{{ fingerprints[i] }}</dd>\n </template>\n </dl>\n\n <details class=\"cert__tree\">\n <summary class=\"cert__treehead\">ASN.1 结构</summary>\n <div class=\"cert__rows\">\n <button\n v-for=\"row in treeRows[i]\"\n :key=\"row.path\"\n type=\"button\"\n class=\"cert__row\"\n :style=\"{ paddingLeft: `${8 + row.depth * 16}px` }\"\n @click=\"row.hasChildren && toggleNode(row.path)\"\n >\n <span class=\"cert__twisty\">\n <Icon\n v-if=\"row.hasChildren\"\n name=\"chevronRight\"\n :size=\"12\"\n :class=\"{ 'cert__twisty--open': expanded.has(row.path) }\"\n />\n </span>\n <span class=\"cert__tag\">{{ tagName(row.node) }}</span>\n <span v-if=\"nodePreview(row.node)\" class=\"cert__val\">{{\n nodePreview(row.node)\n }}</span>\n </button>\n </div>\n </details>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cert {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.cert__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.cert__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.cert__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.cert__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.cert__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.cert__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n.cert__card {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.cert__cardhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.cert__kind {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 5px;\n padding: 2px 7px;\n}\n.cert__label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.cert__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.cert__badge--ok {\n color: #15803d;\n background: color-mix(in srgb, #22c55e 16%, transparent);\n}\n.cert__badge--warn {\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 18%, transparent);\n}\n.cert__badge--bad {\n color: #b91c1c;\n background: color-mix(in srgb, #ef4444 16%, transparent);\n}\n\n.cert__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n border-bottom: 1px solid var(--border);\n}\n\n.cert__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 12px;\n}\n.cert__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.cert__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.cert__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.cert__fp {\n word-break: break-all;\n}\n\n.cert__tree {\n border-top: 1px solid var(--border);\n}\n.cert__treehead {\n padding: 8px 12px;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n}\n.cert__treehead:hover {\n color: var(--accent);\n}\n.cert__rows {\n padding: 4px 0 10px;\n background: var(--code-bg);\n border-top: 1px solid var(--code-border);\n overflow-x: auto;\n}\n.cert__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 3px 12px 3px 8px;\n background: transparent;\n border: none;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text);\n text-align: left;\n white-space: nowrap;\n cursor: default;\n}\n.cert__row:hover {\n background: var(--surface-2);\n}\n.cert__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 12px;\n color: var(--faint);\n}\n.cert__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.cert__tag {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cert__val {\n min-width: 0;\n color: var(--muted);\n}\n</style>\n"],"mappings":"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"}
1
+ {"version":3,"file":"CertViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/CertViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport {\n asn1String,\n asn1Time,\n bytesToHex,\n decodeAsn1,\n oidToString,\n tagName,\n TAG,\n type Asn1Node,\n} from '@/lib/asn1'\nimport {\n looksLikePem,\n oidName,\n parseCertificate,\n parseCsr,\n parsePemBlocks,\n sha256Hex,\n summarizeBlock,\n type CertSummary,\n} from '@/lib/x509'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for certificates / keys (pem / crt / cer / der / csr / key / p12 / pfx). Fetches the BYTES\n * itself (binary-safe — a .der/.p12 would be mangled by the panel's text load), sniffs PEM vs raw DER, and\n * for each block shows the human-meaningful X.509 fields (subject / issuer, validity with an expiry badge,\n * serial, signature + public-key algorithm, SANs, key usages, SHA-256 fingerprint) plus a collapsible raw\n * ASN.1 structure tree. Parsing is hand-rolled and READ-ONLY (lib/asn1.ts + lib/x509.ts) — it never verifies\n * signatures or trust. PKCS#12 (.p12/.pfx) containers are usually encrypted, so they're shown as structure\n * only with a \"use openssl to extract\" note (best effort).\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ninterface CertItem {\n summary: CertSummary\n forest: Asn1Node[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst items = ref<CertItem[]>([])\nconst fingerprints = reactive<Record<number, string>>({})\nconst expanded = reactive(new Set<string>())\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nfunction buildItem(summary: CertSummary): CertItem {\n return { summary, forest: decodeAsn1(summary.der) }\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const ext = fileExt(props.name)\n const head = new TextDecoder('latin1').decode(bytes.subarray(0, Math.min(64, bytes.length)))\n const list: CertItem[] = []\n\n if (head.includes('-----BEGIN') || looksLikePem(new TextDecoder('latin1').decode(bytes))) {\n const text = new TextDecoder('utf-8').decode(bytes)\n for (const block of parsePemBlocks(text))\n list.push(buildItem(summarizeBlock(block.label, block.der)))\n }\n if (!list.length) {\n // Raw DER (or a PEM that yielded no blocks). Pick a parser from the extension.\n if (ext === 'p12' || ext === 'pfx') {\n const s = summarizeP12(bytes)\n list.push(buildItem(s))\n } else if (ext === 'csr' || ext === 'req') {\n list.push(buildItem(parseCsr(bytes)))\n } else {\n const cert = parseCertificate(bytes)\n list.push(buildItem(cert.ok ? cert : fallbackStructure(bytes, ext)))\n }\n }\n\n items.value = list\n status.value = 'ready'\n // Fingerprint each certificate asynchronously (SHA-256 of its DER).\n list.forEach((it, i) => {\n if (it.summary.kind === 'certificate' && it.summary.ok) {\n void sha256Hex(it.summary.der)\n .then((hex) => {\n fingerprints[i] = hex\n })\n .catch(() => {})\n }\n })\n // Open the first level of the first item so there's something to see without a click.\n if (list.length && list[0].forest.length) expanded.add('0.0')\n}\n\nfunction summarizeP12(bytes: Uint8Array): CertSummary {\n return {\n ok: true,\n kind: 'pkcs12',\n label: 'PKCS#12',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: 'PKCS#12 容器通常已加密,浏览器内仅展示 ASN.1 结构;如需提取证书/私钥请用 openssl pkcs12 -info。',\n }\n}\n\nfunction fallbackStructure(bytes: Uint8Array, ext: string): CertSummary {\n return {\n ok: true,\n kind: 'unknown',\n label: ext ? `.${ext}` : '二进制',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: '无法识别为 X.509 证书 —— 展开下方结构树查看原始 ASN.1。',\n }\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n items.value = []\n expanded.clear()\n for (const k of Object.keys(fingerprints)) delete fingerprints[Number(k)]\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\n// ── friendly-field formatting ────────────────────────────────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst KIND_LABEL: Record<CertSummary['kind'], string> = {\n certificate: '证书',\n request: '证书请求 (CSR)',\n key: '密钥',\n crl: 'CRL',\n pkcs12: 'PKCS#12',\n pkcs7: 'PKCS#7',\n unknown: '未知',\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'cert__badge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'cert__badge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'cert__badge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'cert__badge--ok' }\n}\n\n// ── ASN.1 structure tree (flattened with an expand set) ──────────────────────────────────────────\ninterface TreeRow {\n node: Asn1Node\n depth: number\n path: string\n hasChildren: boolean\n}\n\nfunction flatten(forest: Asn1Node[], prefix: string): TreeRow[] {\n const rows: TreeRow[] = []\n const walk = (nodes: Asn1Node[], depth: number, pre: string): void => {\n nodes.forEach((node, i) => {\n const path = `${pre}.${i}`\n const hasChildren = node.children.length > 0\n rows.push({ node, depth, path, hasChildren })\n if (hasChildren && expanded.has(path)) walk(node.children, depth + 1, path)\n })\n }\n walk(forest, 0, prefix)\n return rows\n}\n\nconst treeRows = computed(() => items.value.map((it, i) => flatten(it.forest, String(i))))\n\nfunction toggleNode(path: string): void {\n if (expanded.has(path)) expanded.delete(path)\n else expanded.add(path)\n}\n\n// A short, human value preview for a primitive node (constructed nodes show only their tag).\nfunction nodePreview(node: Asn1Node): string {\n if (node.constructed) return ''\n switch (node.tagNumber) {\n case TAG.OID: {\n const oid = oidToString(node.content)\n const nm = oidName(oid)\n return nm === oid ? oid : `${nm} (${oid})`\n }\n case TAG.BOOLEAN:\n return node.content[0] ? 'true' : 'false'\n case TAG.INTEGER: {\n if (node.content.length <= 4) {\n let v = 0\n for (const b of node.content) v = v * 256 + b\n return String(v)\n }\n return `0x${bytesToHex(node.content).slice(0, 32)}${node.content.length > 16 ? '…' : ''}`\n }\n case TAG.UTF8String:\n case TAG.PrintableString:\n case TAG.IA5String:\n return asn1String(node)\n case TAG.UTCTime:\n case TAG.GeneralizedTime:\n return asn1Time(node)?.text ?? asn1String(node)\n case TAG.NULL:\n return ''\n default: {\n if (node.tagClass === 2) {\n // context-specific primitive (e.g. a SAN name) — show as text if printable, else hex\n const txt = new TextDecoder('latin1').decode(node.content)\n if (/^[\\x20-\\x7e]*$/.test(txt)) return txt\n }\n const hex = bytesToHex(node.content)\n return hex.length > 32 ? `${hex.slice(0, 32)}… (${node.length}B)` : hex\n }\n }\n}\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"cert\">\n <p v-if=\"status === 'loading'\" class=\"cert__msg\">正在解析证书…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"cert__msg cert__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"cert__msg cert__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else class=\"cert__scroll\">\n <section v-for=\"(it, i) in items\" :key=\"i\" class=\"cert__card\">\n <header class=\"cert__cardhead\">\n <span class=\"cert__kind\">{{ KIND_LABEL[it.summary.kind] }}</span>\n <span class=\"cert__label\">{{ it.summary.subjectCN || it.summary.label }}</span>\n <span v-if=\"expiry(it.summary)\" class=\"cert__badge\" :class=\"expiry(it.summary)?.cls\">\n {{ expiry(it.summary)?.text }}\n </span>\n </header>\n\n <p v-if=\"it.summary.note\" class=\"cert__note\">{{ it.summary.note }}</p>\n\n <dl v-if=\"it.summary.subject.length || it.summary.kind === 'certificate'\" class=\"cert__dl\">\n <template v-if=\"it.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(it.summary.subject) }}</dd>\n </template>\n <template v-if=\"it.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(it.summary.issuer) }}</dd>\n </template>\n <template v-if=\"it.summary.notBefore || it.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ it.summary.notBefore ?? '?' }} → {{ it.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(it.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(it.summary) }}</dd>\n </template>\n <template v-if=\"it.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ it.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"it.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"cert__mono\">{{ it.summary.serialHex }}</dd>\n </template>\n <template v-if=\"it.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ it.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.keyUsages.length\">\n <dt>密钥用法</dt>\n <dd>{{ it.summary.keyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.extKeyUsages.length\">\n <dt>扩展用法</dt>\n <dd>{{ it.summary.extKeyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.isCa != null\">\n <dt>CA</dt>\n <dd>\n {{ it.summary.isCa ? '是' : '否'\n }}{{ it.summary.pathLen != null ? `(pathLen ${it.summary.pathLen})` : '' }}\n </dd>\n </template>\n <template v-if=\"fingerprints[i]\">\n <dt>SHA-256</dt>\n <dd class=\"cert__mono cert__fp\">{{ fingerprints[i] }}</dd>\n </template>\n </dl>\n\n <details class=\"cert__tree\">\n <summary class=\"cert__treehead\">ASN.1 结构</summary>\n <div class=\"cert__rows\">\n <button\n v-for=\"row in treeRows[i]\"\n :key=\"row.path\"\n type=\"button\"\n class=\"cert__row\"\n :style=\"{ paddingLeft: `${8 + row.depth * 16}px` }\"\n @click=\"row.hasChildren && toggleNode(row.path)\"\n >\n <span class=\"cert__twisty\">\n <Icon\n v-if=\"row.hasChildren\"\n name=\"chevronRight\"\n :size=\"12\"\n :class=\"{ 'cert__twisty--open': expanded.has(row.path) }\"\n />\n </span>\n <span class=\"cert__tag\">{{ tagName(row.node) }}</span>\n <span v-if=\"nodePreview(row.node)\" class=\"cert__val\">{{\n nodePreview(row.node)\n }}</span>\n </button>\n </div>\n </details>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cert {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.cert__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.cert__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.cert__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.cert__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.cert__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.cert__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n.cert__card {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.cert__cardhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.cert__kind {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 5px;\n padding: 2px 7px;\n}\n.cert__label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.cert__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.cert__badge--ok {\n color: var(--hl-name);\n background: color-mix(in srgb, var(--hl-name) 16%, transparent);\n}\n.cert__badge--warn {\n color: var(--ansi-yellow);\n background: color-mix(in srgb, var(--ansi-yellow) 18%, transparent);\n}\n.cert__badge--bad {\n color: var(--danger);\n background: color-mix(in srgb, var(--danger) 16%, transparent);\n}\n\n.cert__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n border-bottom: 1px solid var(--border);\n}\n\n.cert__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 12px;\n}\n.cert__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.cert__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.cert__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.cert__fp {\n word-break: break-all;\n}\n\n.cert__tree {\n border-top: 1px solid var(--border);\n}\n.cert__treehead {\n padding: 8px 12px;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n}\n.cert__treehead:hover {\n color: var(--accent);\n}\n.cert__rows {\n padding: 4px 0 10px;\n background: var(--code-bg);\n border-top: 1px solid var(--code-border);\n overflow-x: auto;\n}\n.cert__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 3px 12px 3px 8px;\n background: transparent;\n border: none;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text);\n text-align: left;\n white-space: nowrap;\n cursor: default;\n}\n.cert__row:hover {\n background: var(--surface-2);\n}\n.cert__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 12px;\n color: var(--faint);\n}\n.cert__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.cert__tag {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cert__val {\n min-width: 0;\n color: var(--muted);\n}\n</style>\n"],"mappings":"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"}
@@ -2,7 +2,7 @@ import e from "../_virtual/_plugin-vue_export-helper.js";
2
2
  import t from "./DocxNavigationPane.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/DocxNavigationPane.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-82c26438"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-47c3c806"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8
 
@@ -1 +1 @@
1
- {"version":3,"file":"DocxNavigationPane.js","names":[],"sources":["../../src/components/DocxNavigationPane.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport type { ElementNavigationItem } from '@/composables/useElementNavigation'\nimport Icon from '@/components/icons/Icon.vue'\n\nconst props = defineProps<{\n outlineItems: readonly ElementNavigationItem[]\n pageItems: readonly ElementNavigationItem[]\n activeOutlineId: string\n activePageId: string\n}>()\n\nconst emit = defineEmits<{ navigate: [item: ElementNavigationItem] }>()\nconst collapsed = defineModel<boolean>('collapsed', { default: false })\nconst view = defineModel<'outline' | 'pages'>('view', { default: 'outline' })\n\nfunction isActive(item: ElementNavigationItem): boolean {\n return (view.value === 'outline' ? props.activeOutlineId : props.activePageId) === item.id\n}\n</script>\n\n<template>\n <aside class=\"dn\" :class=\"{ 'dn--collapsed': collapsed }\" aria-label=\"文档导航\">\n <button\n v-if=\"collapsed\"\n type=\"button\"\n class=\"dn__reopen\"\n aria-label=\"展开文档导航\"\n title=\"展开文档导航\"\n @click=\"collapsed = false\"\n >\n <Icon name=\"chevronRight\" :size=\"15\" />\n </button>\n\n <template v-else>\n <header class=\"dn__header\">\n <div>\n <strong class=\"dn__title\">导航</strong>\n <span class=\"dn__count\">{{ pageItems.length }} 页</span>\n </div>\n <button\n type=\"button\"\n class=\"dn__collapse\"\n aria-label=\"收起文档导航\"\n title=\"收起文档导航\"\n @click=\"collapsed = true\"\n >\n <Icon name=\"chevronLeft\" :size=\"15\" />\n </button>\n </header>\n\n <div class=\"dn__tabs\" role=\"tablist\" aria-label=\"导航方式\">\n <button\n type=\"button\"\n role=\"tab\"\n class=\"dn__tab\"\n :class=\"{ 'dn__tab--active': view === 'outline' }\"\n :aria-selected=\"view === 'outline'\"\n @click=\"view = 'outline'\"\n >\n 目录\n </button>\n <button\n type=\"button\"\n role=\"tab\"\n class=\"dn__tab\"\n :class=\"{ 'dn__tab--active': view === 'pages' }\"\n :aria-selected=\"view === 'pages'\"\n @click=\"view = 'pages'\"\n >\n 页面\n </button>\n </div>\n\n <nav class=\"dn__list\" aria-label=\"文档目录\">\n <template v-if=\"view === 'outline'\">\n <button\n v-for=\"item in outlineItems\"\n :key=\"item.id\"\n type=\"button\"\n class=\"dn__outline-item\"\n :class=\"{ 'dn__item--active': isActive(item) }\"\n :style=\"{ paddingLeft: `${10 + item.level * 12}px` }\"\n :aria-current=\"isActive(item) ? 'location' : undefined\"\n :title=\"item.title\"\n @click=\"emit('navigate', item)\"\n >\n <span class=\"dn__marker\" />\n <span class=\"dn__label\">{{ item.title }}</span>\n </button>\n <p v-if=\"!outlineItems.length\" class=\"dn__empty\">\n 文档未设置标题层级,可切换到“页面”导航。\n </p>\n </template>\n\n <template v-else>\n <button\n v-for=\"(item, index) in pageItems\"\n :key=\"item.id\"\n type=\"button\"\n class=\"dn__page-item\"\n :class=\"{ 'dn__item--active': isActive(item) }\"\n :aria-current=\"isActive(item) ? 'page' : undefined\"\n @click=\"emit('navigate', item)\"\n >\n <span class=\"dn__page-sheet\">{{ index + 1 }}</span>\n <span class=\"dn__label\">{{ item.title }}</span>\n </button>\n </template>\n </nav>\n </template>\n </aside>\n</template>\n\n<style scoped>\n.dn {\n position: relative;\n flex: 0 0 166px;\n width: 166px;\n min-width: 0;\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: color-mix(in srgb, var(--surface) 94%, var(--surface-2));\n transition: flex-basis 160ms ease;\n}\n.dn--collapsed {\n flex-basis: 0;\n width: 0;\n border-right: 0;\n}\n.dn__header {\n min-height: 45px;\n padding: 9px 8px 8px 12px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 6px;\n border-bottom: 1px solid var(--border);\n}\n.dn__title {\n display: block;\n color: var(--text-strong);\n font-size: 12.5px;\n font-weight: 650;\n}\n.dn__count {\n display: block;\n margin-top: 1px;\n color: var(--faint);\n font-size: 10.5px;\n}\n.dn__collapse,\n.dn__reopen {\n border: 0;\n color: var(--muted);\n background: transparent;\n cursor: pointer;\n}\n.dn__collapse {\n width: 27px;\n height: 27px;\n display: grid;\n place-items: center;\n border-radius: 7px;\n}\n.dn__collapse:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__reopen {\n position: absolute;\n z-index: 3;\n top: 12px;\n right: -28px;\n width: 28px;\n height: 36px;\n padding: 0;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 0 9px 9px 0;\n background: var(--surface);\n box-shadow: 2px 1px 6px rgba(0, 0, 0, 0.12);\n}\n.dn__reopen:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__tabs {\n margin: 8px 8px 4px;\n padding: 2px;\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.dn__tab {\n min-width: 0;\n padding: 5px 4px;\n border: 0;\n border-radius: 6px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n font-size: 11px;\n cursor: pointer;\n}\n.dn__tab--active {\n color: var(--text-strong);\n background: var(--surface);\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n}\n.dn__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n padding: 4px 6px 18px;\n scrollbar-width: thin;\n}\n.dn__outline-item,\n.dn__page-item {\n position: relative;\n width: 100%;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 7px;\n border: 0;\n border-radius: 7px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dn__outline-item {\n min-height: 31px;\n padding-top: 5px;\n padding-right: 7px;\n padding-bottom: 5px;\n}\n.dn__page-item {\n padding: 6px;\n}\n.dn__outline-item:hover,\n.dn__page-item:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__item--active {\n color: var(--text-strong);\n background: var(--accent-soft);\n}\n.dn__marker {\n flex: 0 0 auto;\n width: 3px;\n height: 15px;\n border-radius: 999px;\n background: var(--border-2);\n}\n.dn__item--active .dn__marker {\n background: var(--accent);\n}\n.dn__page-sheet {\n flex: 0 0 auto;\n width: 26px;\n height: 34px;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 3px;\n color: var(--faint);\n background: var(--surface);\n font-size: 9.5px;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);\n}\n.dn__item--active .dn__page-sheet {\n border-color: var(--accent);\n color: var(--accent);\n}\n.dn__label {\n min-width: 0;\n overflow: hidden;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n font-size: 11.5px;\n line-height: 1.35;\n}\n.dn__empty {\n margin: 10px 8px;\n color: var(--faint);\n font-size: 10.5px;\n line-height: 1.55;\n}\n\n@container (max-width: 500px) {\n .dn {\n flex-basis: 136px;\n width: 136px;\n }\n .dn--collapsed {\n flex-basis: 0;\n width: 0;\n }\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"DocxNavigationPane.js","names":[],"sources":["../../src/components/DocxNavigationPane.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport type { ElementNavigationItem } from '@/composables/useElementNavigation'\nimport Icon from '@/components/icons/Icon.vue'\n\nconst props = defineProps<{\n outlineItems: readonly ElementNavigationItem[]\n pageItems: readonly ElementNavigationItem[]\n activeOutlineId: string\n activePageId: string\n}>()\n\nconst emit = defineEmits<{ navigate: [item: ElementNavigationItem] }>()\nconst collapsed = defineModel<boolean>('collapsed', { default: false })\nconst view = defineModel<'outline' | 'pages'>('view', { default: 'outline' })\n\nfunction isActive(item: ElementNavigationItem): boolean {\n return (view.value === 'outline' ? props.activeOutlineId : props.activePageId) === item.id\n}\n</script>\n\n<template>\n <aside class=\"dn\" :class=\"{ 'dn--collapsed': collapsed }\" aria-label=\"文档导航\">\n <button\n v-if=\"collapsed\"\n type=\"button\"\n class=\"dn__reopen\"\n aria-label=\"展开文档导航\"\n title=\"展开文档导航\"\n @click=\"collapsed = false\"\n >\n <Icon name=\"chevronRight\" :size=\"15\" />\n </button>\n\n <template v-else>\n <header class=\"dn__header\">\n <div>\n <strong class=\"dn__title\">导航</strong>\n <span class=\"dn__count\">{{ pageItems.length }} 页</span>\n </div>\n <button\n type=\"button\"\n class=\"dn__collapse\"\n aria-label=\"收起文档导航\"\n title=\"收起文档导航\"\n @click=\"collapsed = true\"\n >\n <Icon name=\"chevronLeft\" :size=\"15\" />\n </button>\n </header>\n\n <div class=\"dn__tabs\" role=\"tablist\" aria-label=\"导航方式\">\n <button\n type=\"button\"\n role=\"tab\"\n class=\"dn__tab\"\n :class=\"{ 'dn__tab--active': view === 'outline' }\"\n :aria-selected=\"view === 'outline'\"\n @click=\"view = 'outline'\"\n >\n 目录\n </button>\n <button\n type=\"button\"\n role=\"tab\"\n class=\"dn__tab\"\n :class=\"{ 'dn__tab--active': view === 'pages' }\"\n :aria-selected=\"view === 'pages'\"\n @click=\"view = 'pages'\"\n >\n 页面\n </button>\n </div>\n\n <nav class=\"dn__list\" aria-label=\"文档目录\">\n <template v-if=\"view === 'outline'\">\n <button\n v-for=\"item in outlineItems\"\n :key=\"item.id\"\n type=\"button\"\n class=\"dn__outline-item\"\n :class=\"{ 'dn__item--active': isActive(item) }\"\n :style=\"{ paddingLeft: `${10 + item.level * 12}px` }\"\n :aria-current=\"isActive(item) ? 'location' : undefined\"\n :title=\"item.title\"\n @click=\"emit('navigate', item)\"\n >\n <span class=\"dn__marker\" />\n <span class=\"dn__label\">{{ item.title }}</span>\n </button>\n <p v-if=\"!outlineItems.length\" class=\"dn__empty\">\n 文档未设置标题层级,可切换到“页面”导航。\n </p>\n </template>\n\n <template v-else>\n <button\n v-for=\"(item, index) in pageItems\"\n :key=\"item.id\"\n type=\"button\"\n class=\"dn__page-item\"\n :class=\"{ 'dn__item--active': isActive(item) }\"\n :aria-current=\"isActive(item) ? 'page' : undefined\"\n @click=\"emit('navigate', item)\"\n >\n <span class=\"dn__page-sheet\">{{ index + 1 }}</span>\n <span class=\"dn__label\">{{ item.title }}</span>\n </button>\n </template>\n </nav>\n </template>\n </aside>\n</template>\n\n<style scoped>\n.dn {\n position: relative;\n flex: 0 0 166px;\n width: 166px;\n min-width: 0;\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: color-mix(in srgb, var(--surface) 94%, var(--surface-2));\n transition: flex-basis 160ms ease;\n}\n.dn--collapsed {\n flex-basis: 0;\n width: 0;\n border-right: 0;\n}\n.dn__header {\n min-height: 45px;\n padding: 9px 8px 8px 12px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 6px;\n border-bottom: 1px solid var(--border);\n}\n.dn__title {\n display: block;\n color: var(--text-strong);\n font-size: 12.5px;\n font-weight: 650;\n}\n.dn__count {\n display: block;\n margin-top: 1px;\n color: var(--faint);\n font-size: 10.5px;\n}\n.dn__collapse,\n.dn__reopen {\n border: 0;\n color: var(--muted);\n background: transparent;\n cursor: pointer;\n}\n.dn__collapse {\n width: 27px;\n height: 27px;\n display: grid;\n place-items: center;\n border-radius: 7px;\n}\n.dn__collapse:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__reopen {\n position: absolute;\n z-index: 3;\n top: 12px;\n right: -28px;\n width: 28px;\n height: 36px;\n padding: 0;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 0 9px 9px 0;\n background: var(--surface);\n box-shadow: var(--shadow);\n}\n.dn__reopen:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__tabs {\n margin: 8px 8px 4px;\n padding: 2px;\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.dn__tab {\n min-width: 0;\n padding: 5px 4px;\n border: 0;\n border-radius: 6px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n font-size: 11px;\n cursor: pointer;\n}\n.dn__tab--active {\n color: var(--text-strong);\n background: var(--surface);\n box-shadow: var(--shadow);\n}\n.dn__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n padding: 4px 6px 18px;\n scrollbar-width: thin;\n}\n.dn__outline-item,\n.dn__page-item {\n position: relative;\n width: 100%;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 7px;\n border: 0;\n border-radius: 7px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dn__outline-item {\n min-height: 31px;\n padding-top: 5px;\n padding-right: 7px;\n padding-bottom: 5px;\n}\n.dn__page-item {\n padding: 6px;\n}\n.dn__outline-item:hover,\n.dn__page-item:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__item--active {\n color: var(--text-strong);\n background: var(--accent-soft);\n}\n.dn__marker {\n flex: 0 0 auto;\n width: 3px;\n height: 15px;\n border-radius: 999px;\n background: var(--border-2);\n}\n.dn__item--active .dn__marker {\n background: var(--accent);\n}\n.dn__page-sheet {\n flex: 0 0 auto;\n width: 26px;\n height: 34px;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 3px;\n color: var(--muted);\n background: var(--surface);\n font-size: 9.5px;\n box-shadow: var(--shadow);\n}\n.dn__item--active .dn__page-sheet {\n border-color: var(--accent);\n color: var(--accent);\n}\n.dn__label {\n min-width: 0;\n overflow: hidden;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n font-size: 11.5px;\n line-height: 1.35;\n}\n.dn__empty {\n margin: 10px 8px;\n color: var(--faint);\n font-size: 10.5px;\n line-height: 1.55;\n}\n\n@container (max-width: 500px) {\n .dn {\n flex-basis: 136px;\n width: 136px;\n }\n .dn--collapsed {\n flex-basis: 0;\n width: 0;\n }\n}\n</style>\n"],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"DocxNavigationPane.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/DocxNavigationPane.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport type { ElementNavigationItem } from '@/composables/useElementNavigation'\nimport Icon from '@/components/icons/Icon.vue'\n\nconst props = defineProps<{\n outlineItems: readonly ElementNavigationItem[]\n pageItems: readonly ElementNavigationItem[]\n activeOutlineId: string\n activePageId: string\n}>()\n\nconst emit = defineEmits<{ navigate: [item: ElementNavigationItem] }>()\nconst collapsed = defineModel<boolean>('collapsed', { default: false })\nconst view = defineModel<'outline' | 'pages'>('view', { default: 'outline' })\n\nfunction isActive(item: ElementNavigationItem): boolean {\n return (view.value === 'outline' ? props.activeOutlineId : props.activePageId) === item.id\n}\n</script>\n\n<template>\n <aside class=\"dn\" :class=\"{ 'dn--collapsed': collapsed }\" aria-label=\"文档导航\">\n <button\n v-if=\"collapsed\"\n type=\"button\"\n class=\"dn__reopen\"\n aria-label=\"展开文档导航\"\n title=\"展开文档导航\"\n @click=\"collapsed = false\"\n >\n <Icon name=\"chevronRight\" :size=\"15\" />\n </button>\n\n <template v-else>\n <header class=\"dn__header\">\n <div>\n <strong class=\"dn__title\">导航</strong>\n <span class=\"dn__count\">{{ pageItems.length }} 页</span>\n </div>\n <button\n type=\"button\"\n class=\"dn__collapse\"\n aria-label=\"收起文档导航\"\n title=\"收起文档导航\"\n @click=\"collapsed = true\"\n >\n <Icon name=\"chevronLeft\" :size=\"15\" />\n </button>\n </header>\n\n <div class=\"dn__tabs\" role=\"tablist\" aria-label=\"导航方式\">\n <button\n type=\"button\"\n role=\"tab\"\n class=\"dn__tab\"\n :class=\"{ 'dn__tab--active': view === 'outline' }\"\n :aria-selected=\"view === 'outline'\"\n @click=\"view = 'outline'\"\n >\n 目录\n </button>\n <button\n type=\"button\"\n role=\"tab\"\n class=\"dn__tab\"\n :class=\"{ 'dn__tab--active': view === 'pages' }\"\n :aria-selected=\"view === 'pages'\"\n @click=\"view = 'pages'\"\n >\n 页面\n </button>\n </div>\n\n <nav class=\"dn__list\" aria-label=\"文档目录\">\n <template v-if=\"view === 'outline'\">\n <button\n v-for=\"item in outlineItems\"\n :key=\"item.id\"\n type=\"button\"\n class=\"dn__outline-item\"\n :class=\"{ 'dn__item--active': isActive(item) }\"\n :style=\"{ paddingLeft: `${10 + item.level * 12}px` }\"\n :aria-current=\"isActive(item) ? 'location' : undefined\"\n :title=\"item.title\"\n @click=\"emit('navigate', item)\"\n >\n <span class=\"dn__marker\" />\n <span class=\"dn__label\">{{ item.title }}</span>\n </button>\n <p v-if=\"!outlineItems.length\" class=\"dn__empty\">\n 文档未设置标题层级,可切换到“页面”导航。\n </p>\n </template>\n\n <template v-else>\n <button\n v-for=\"(item, index) in pageItems\"\n :key=\"item.id\"\n type=\"button\"\n class=\"dn__page-item\"\n :class=\"{ 'dn__item--active': isActive(item) }\"\n :aria-current=\"isActive(item) ? 'page' : undefined\"\n @click=\"emit('navigate', item)\"\n >\n <span class=\"dn__page-sheet\">{{ index + 1 }}</span>\n <span class=\"dn__label\">{{ item.title }}</span>\n </button>\n </template>\n </nav>\n </template>\n </aside>\n</template>\n\n<style scoped>\n.dn {\n position: relative;\n flex: 0 0 166px;\n width: 166px;\n min-width: 0;\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: color-mix(in srgb, var(--surface) 94%, var(--surface-2));\n transition: flex-basis 160ms ease;\n}\n.dn--collapsed {\n flex-basis: 0;\n width: 0;\n border-right: 0;\n}\n.dn__header {\n min-height: 45px;\n padding: 9px 8px 8px 12px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 6px;\n border-bottom: 1px solid var(--border);\n}\n.dn__title {\n display: block;\n color: var(--text-strong);\n font-size: 12.5px;\n font-weight: 650;\n}\n.dn__count {\n display: block;\n margin-top: 1px;\n color: var(--faint);\n font-size: 10.5px;\n}\n.dn__collapse,\n.dn__reopen {\n border: 0;\n color: var(--muted);\n background: transparent;\n cursor: pointer;\n}\n.dn__collapse {\n width: 27px;\n height: 27px;\n display: grid;\n place-items: center;\n border-radius: 7px;\n}\n.dn__collapse:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__reopen {\n position: absolute;\n z-index: 3;\n top: 12px;\n right: -28px;\n width: 28px;\n height: 36px;\n padding: 0;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 0 9px 9px 0;\n background: var(--surface);\n box-shadow: 2px 1px 6px rgba(0, 0, 0, 0.12);\n}\n.dn__reopen:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__tabs {\n margin: 8px 8px 4px;\n padding: 2px;\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.dn__tab {\n min-width: 0;\n padding: 5px 4px;\n border: 0;\n border-radius: 6px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n font-size: 11px;\n cursor: pointer;\n}\n.dn__tab--active {\n color: var(--text-strong);\n background: var(--surface);\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n}\n.dn__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n padding: 4px 6px 18px;\n scrollbar-width: thin;\n}\n.dn__outline-item,\n.dn__page-item {\n position: relative;\n width: 100%;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 7px;\n border: 0;\n border-radius: 7px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dn__outline-item {\n min-height: 31px;\n padding-top: 5px;\n padding-right: 7px;\n padding-bottom: 5px;\n}\n.dn__page-item {\n padding: 6px;\n}\n.dn__outline-item:hover,\n.dn__page-item:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__item--active {\n color: var(--text-strong);\n background: var(--accent-soft);\n}\n.dn__marker {\n flex: 0 0 auto;\n width: 3px;\n height: 15px;\n border-radius: 999px;\n background: var(--border-2);\n}\n.dn__item--active .dn__marker {\n background: var(--accent);\n}\n.dn__page-sheet {\n flex: 0 0 auto;\n width: 26px;\n height: 34px;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 3px;\n color: var(--faint);\n background: var(--surface);\n font-size: 9.5px;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);\n}\n.dn__item--active .dn__page-sheet {\n border-color: var(--accent);\n color: var(--accent);\n}\n.dn__label {\n min-width: 0;\n overflow: hidden;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n font-size: 11.5px;\n line-height: 1.35;\n}\n.dn__empty {\n margin: 10px 8px;\n color: var(--faint);\n font-size: 10.5px;\n line-height: 1.55;\n}\n\n@container (max-width: 500px) {\n .dn {\n flex-basis: 136px;\n width: 136px;\n }\n .dn--collapsed {\n flex-basis: 0;\n width: 0;\n }\n}\n</style>\n"],"mappings":"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"}
1
+ {"version":3,"file":"DocxNavigationPane.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/DocxNavigationPane.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport type { ElementNavigationItem } from '@/composables/useElementNavigation'\nimport Icon from '@/components/icons/Icon.vue'\n\nconst props = defineProps<{\n outlineItems: readonly ElementNavigationItem[]\n pageItems: readonly ElementNavigationItem[]\n activeOutlineId: string\n activePageId: string\n}>()\n\nconst emit = defineEmits<{ navigate: [item: ElementNavigationItem] }>()\nconst collapsed = defineModel<boolean>('collapsed', { default: false })\nconst view = defineModel<'outline' | 'pages'>('view', { default: 'outline' })\n\nfunction isActive(item: ElementNavigationItem): boolean {\n return (view.value === 'outline' ? props.activeOutlineId : props.activePageId) === item.id\n}\n</script>\n\n<template>\n <aside class=\"dn\" :class=\"{ 'dn--collapsed': collapsed }\" aria-label=\"文档导航\">\n <button\n v-if=\"collapsed\"\n type=\"button\"\n class=\"dn__reopen\"\n aria-label=\"展开文档导航\"\n title=\"展开文档导航\"\n @click=\"collapsed = false\"\n >\n <Icon name=\"chevronRight\" :size=\"15\" />\n </button>\n\n <template v-else>\n <header class=\"dn__header\">\n <div>\n <strong class=\"dn__title\">导航</strong>\n <span class=\"dn__count\">{{ pageItems.length }} 页</span>\n </div>\n <button\n type=\"button\"\n class=\"dn__collapse\"\n aria-label=\"收起文档导航\"\n title=\"收起文档导航\"\n @click=\"collapsed = true\"\n >\n <Icon name=\"chevronLeft\" :size=\"15\" />\n </button>\n </header>\n\n <div class=\"dn__tabs\" role=\"tablist\" aria-label=\"导航方式\">\n <button\n type=\"button\"\n role=\"tab\"\n class=\"dn__tab\"\n :class=\"{ 'dn__tab--active': view === 'outline' }\"\n :aria-selected=\"view === 'outline'\"\n @click=\"view = 'outline'\"\n >\n 目录\n </button>\n <button\n type=\"button\"\n role=\"tab\"\n class=\"dn__tab\"\n :class=\"{ 'dn__tab--active': view === 'pages' }\"\n :aria-selected=\"view === 'pages'\"\n @click=\"view = 'pages'\"\n >\n 页面\n </button>\n </div>\n\n <nav class=\"dn__list\" aria-label=\"文档目录\">\n <template v-if=\"view === 'outline'\">\n <button\n v-for=\"item in outlineItems\"\n :key=\"item.id\"\n type=\"button\"\n class=\"dn__outline-item\"\n :class=\"{ 'dn__item--active': isActive(item) }\"\n :style=\"{ paddingLeft: `${10 + item.level * 12}px` }\"\n :aria-current=\"isActive(item) ? 'location' : undefined\"\n :title=\"item.title\"\n @click=\"emit('navigate', item)\"\n >\n <span class=\"dn__marker\" />\n <span class=\"dn__label\">{{ item.title }}</span>\n </button>\n <p v-if=\"!outlineItems.length\" class=\"dn__empty\">\n 文档未设置标题层级,可切换到“页面”导航。\n </p>\n </template>\n\n <template v-else>\n <button\n v-for=\"(item, index) in pageItems\"\n :key=\"item.id\"\n type=\"button\"\n class=\"dn__page-item\"\n :class=\"{ 'dn__item--active': isActive(item) }\"\n :aria-current=\"isActive(item) ? 'page' : undefined\"\n @click=\"emit('navigate', item)\"\n >\n <span class=\"dn__page-sheet\">{{ index + 1 }}</span>\n <span class=\"dn__label\">{{ item.title }}</span>\n </button>\n </template>\n </nav>\n </template>\n </aside>\n</template>\n\n<style scoped>\n.dn {\n position: relative;\n flex: 0 0 166px;\n width: 166px;\n min-width: 0;\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: color-mix(in srgb, var(--surface) 94%, var(--surface-2));\n transition: flex-basis 160ms ease;\n}\n.dn--collapsed {\n flex-basis: 0;\n width: 0;\n border-right: 0;\n}\n.dn__header {\n min-height: 45px;\n padding: 9px 8px 8px 12px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 6px;\n border-bottom: 1px solid var(--border);\n}\n.dn__title {\n display: block;\n color: var(--text-strong);\n font-size: 12.5px;\n font-weight: 650;\n}\n.dn__count {\n display: block;\n margin-top: 1px;\n color: var(--faint);\n font-size: 10.5px;\n}\n.dn__collapse,\n.dn__reopen {\n border: 0;\n color: var(--muted);\n background: transparent;\n cursor: pointer;\n}\n.dn__collapse {\n width: 27px;\n height: 27px;\n display: grid;\n place-items: center;\n border-radius: 7px;\n}\n.dn__collapse:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__reopen {\n position: absolute;\n z-index: 3;\n top: 12px;\n right: -28px;\n width: 28px;\n height: 36px;\n padding: 0;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 0 9px 9px 0;\n background: var(--surface);\n box-shadow: var(--shadow);\n}\n.dn__reopen:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__tabs {\n margin: 8px 8px 4px;\n padding: 2px;\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.dn__tab {\n min-width: 0;\n padding: 5px 4px;\n border: 0;\n border-radius: 6px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n font-size: 11px;\n cursor: pointer;\n}\n.dn__tab--active {\n color: var(--text-strong);\n background: var(--surface);\n box-shadow: var(--shadow);\n}\n.dn__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n padding: 4px 6px 18px;\n scrollbar-width: thin;\n}\n.dn__outline-item,\n.dn__page-item {\n position: relative;\n width: 100%;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 7px;\n border: 0;\n border-radius: 7px;\n color: var(--muted);\n background: transparent;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dn__outline-item {\n min-height: 31px;\n padding-top: 5px;\n padding-right: 7px;\n padding-bottom: 5px;\n}\n.dn__page-item {\n padding: 6px;\n}\n.dn__outline-item:hover,\n.dn__page-item:hover {\n color: var(--text-strong);\n background: var(--bubble);\n}\n.dn__item--active {\n color: var(--text-strong);\n background: var(--accent-soft);\n}\n.dn__marker {\n flex: 0 0 auto;\n width: 3px;\n height: 15px;\n border-radius: 999px;\n background: var(--border-2);\n}\n.dn__item--active .dn__marker {\n background: var(--accent);\n}\n.dn__page-sheet {\n flex: 0 0 auto;\n width: 26px;\n height: 34px;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-2);\n border-radius: 3px;\n color: var(--muted);\n background: var(--surface);\n font-size: 9.5px;\n box-shadow: var(--shadow);\n}\n.dn__item--active .dn__page-sheet {\n border-color: var(--accent);\n color: var(--accent);\n}\n.dn__label {\n min-width: 0;\n overflow: hidden;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n font-size: 11.5px;\n line-height: 1.35;\n}\n.dn__empty {\n margin: 10px 8px;\n color: var(--faint);\n font-size: 10.5px;\n line-height: 1.55;\n}\n\n@container (max-width: 500px) {\n .dn {\n flex-basis: 136px;\n width: 136px;\n }\n .dn--collapsed {\n flex-basis: 0;\n width: 0;\n }\n}\n</style>\n"],"mappings":"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"}
@@ -2,7 +2,7 @@ import e from "../_virtual/_plugin-vue_export-helper.js";
2
2
  import t from "./DocxViewer.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/DocxViewer.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-f9695f8d"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-b4ce7b73"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8
 
@@ -1 +1 @@
1
- {"version":3,"file":"DocxViewer.js","names":[],"sources":["../../src/components/DocxViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { defineAsyncComponent, nextTick, onBeforeUnmount, shallowRef, watch } from 'vue'\nimport { renderAsync } from 'docx-preview'\nimport DocxEmbeddedFiles from '@/components/DocxEmbeddedFiles.vue'\nimport DocxNavigationPane from '@/components/DocxNavigationPane.vue'\nimport Icon from '@/components/icons/Icon.vue'\nimport type { ViewerFile } from '@/composables/useFileViewer'\nimport { useElementNavigation } from '@/composables/useElementNavigation'\nimport {\n extractDocxEmbeddedFile,\n readDocxEmbeddedFiles,\n type DocxEmbeddedFile,\n} from '@/lib/docxEmbeddings'\nimport { mountDocxEmbeddedPreviews, type DocxEmbeddedDomController } from '@/lib/docxEmbeddedDom'\nimport {\n buildDocxNavigation,\n buildDocxPageNavigation,\n type ParsedDocxModel,\n} from '@/lib/docxNavigation'\nimport { formatBytes } from '@/lib/format'\nimport { classifyViewerFile } from '@/lib/viewerKind'\n\n// The shared body lets an extracted package reuse the same viewer as a top-level file. Lazy loading breaks the\n// FileViewerBody → DocxViewer → FileViewerBody module cycle, matching ZipBrowser's nested-entry hand-off.\nconst FileViewerBody = defineAsyncComponent(() => import('@/components/FileViewerBody.vue'))\n\n/**\n * Side-viewer Word (`.docx`) renderer. Fetches the document bytes from the download URL and renders the\n * OOXML to HTML entirely in-browser via docx-preview (https://github.com/VolodymyrBaydalka/docxjs) — a\n * paginated, paper-like preview (white pages on a neutral canvas, like Word). No CDN / network beyond the\n * file fetch: docx-preview bundles its own jszip + renderer, and images are inlined as base64.\n *\n * Two entry shapes mirror the other browsers (DICOM/zip): a real download URL for a held attachment, or a\n * blob URL minted by the archive browsers from already-extracted bytes. docx-preview injects its own CSS\n * into the render container, prefixed with the `className` ('docx') so it can't leak onto the page.\n */\nconst props = defineProps<{\n /** Download/blob URL of the .docx bytes. */\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'ready' | 'error'\nconst status = shallowRef<Status>('loading')\nconst errorMsg = shallowRef('')\n// The element docx-preview renders into. Always mounted (v-show, not v-if) so the ref exists when we render.\nconst docEl = shallowRef<HTMLElement | null>(null)\nconst navView = shallowRef<'outline' | 'pages'>('outline')\nconst navCollapsed = shallowRef(false)\nconst { outlineItems, pageItems, activeOutlineId, activePageId, setItems, navigate } =\n useElementNavigation(docEl)\n\nlet documentBuffer: ArrayBuffer | null = null\nlet embeddedBlobUrl = ''\nlet embeddedSelectionRun = 0\nlet renderRun = 0\nlet documentScrollTop = 0\nlet inlineEmbedded: DocxEmbeddedDomController | null = null\nconst embeddedFiles = shallowRef<DocxEmbeddedFile[]>([])\nconst selectedEmbedded = shallowRef<DocxEmbeddedFile | null>(null)\nconst embeddedViewerFile = shallowRef<ViewerFile | null>(null)\nconst embeddedStatus = shallowRef<Status>('ready')\nconst embeddedError = shallowRef('')\nconst downloadBusyId = shallowRef('')\nconst downloadError = shallowRef('')\n\nfunction revokeEmbeddedBlob(): void {\n if (!embeddedBlobUrl) return\n URL.revokeObjectURL(embeddedBlobUrl)\n embeddedBlobUrl = ''\n}\n\nfunction clearInlineEmbedded(): void {\n inlineEmbedded?.dispose()\n inlineEmbedded = null\n}\n\nfunction closeEmbedded(restoreDocumentScroll = true): void {\n const shouldRestoreScroll = restoreDocumentScroll && selectedEmbedded.value !== null\n embeddedSelectionRun++\n selectedEmbedded.value = null\n embeddedViewerFile.value = null\n embeddedStatus.value = 'ready'\n embeddedError.value = ''\n revokeEmbeddedBlob()\n if (shouldRestoreScroll) {\n void nextTick(() => {\n if (!selectedEmbedded.value && docEl.value) docEl.value.scrollTop = documentScrollTop\n })\n }\n}\n\nfunction resetEmbedded(): void {\n closeEmbedded(false)\n clearInlineEmbedded()\n documentBuffer = null\n documentScrollTop = 0\n embeddedFiles.value = []\n downloadBusyId.value = ''\n downloadError.value = ''\n}\n\nasync function render(): Promise<void> {\n const el = docEl.value\n if (!props.url || !el) return\n const run = ++renderRun\n status.value = 'loading'\n errorMsg.value = ''\n resetEmbedded()\n setItems([], [])\n el.innerHTML = '' // drop any prior render (e.g. on retry / switching files)\n const url = props.url\n try {\n const res = await fetch(url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n // Stale guard: a newer render may have started while we awaited the fetch.\n if (renderRun !== run || props.url !== url || docEl.value !== el) return\n // Embedded packages are a best-effort enhancement: malformed relationship XML must not blank a Word file\n // that docx-preview can otherwise render. Parse concurrently because both consumers only read the buffer.\n const embeddedPromise = readDocxEmbeddedFiles(buf).catch((error) => {\n console.warn('DOCX embedded files unavailable:', error)\n return []\n })\n const document = (await renderAsync(buf, el, undefined, {\n className: 'docx', // CSS prefix → docx-preview's injected styles stay scoped to this subtree\n inWrapper: true, // wrap pages in the gray \"paper on desk\" canvas, like Word\n // The side panel is narrow (and resizable), so don't pin pages to the fixed A4 width — that overflows\n // and clips the left edge. Let pages fill the available width and reflow; height follows content.\n ignoreWidth: true,\n ignoreHeight: true,\n breakPages: true, // honor page breaks → still separate page sheets, stacked vertically\n renderHeaders: true,\n renderFooters: true,\n renderFootnotes: true,\n renderEndnotes: true,\n useBase64URL: true, // inline embedded images as data URLs (no blob-URL lifecycle to manage)\n })) as ParsedDocxModel\n const discoveredEmbeddedFiles = await embeddedPromise\n if (renderRun !== run || props.url !== url || docEl.value !== el) return\n let navigation\n try {\n navigation = buildDocxNavigation(document, el)\n } catch (error) {\n // A malformed/unsupported Word model must never turn an otherwise rendered document into an error page.\n console.warn('DOCX outline navigation unavailable:', error)\n navigation = { outline: [], pages: buildDocxPageNavigation(el) }\n }\n setItems(navigation.outline, navigation.pages)\n navView.value = navigation.outline.length ? 'outline' : 'pages'\n documentBuffer = buf\n embeddedFiles.value = discoveredEmbeddedFiles\n inlineEmbedded = mountDocxEmbeddedPreviews({\n root: el,\n buffer: buf,\n files: discoveredEmbeddedFiles,\n onOpen: (file) => void openEmbedded(file),\n })\n status.value = 'ready'\n } catch (e) {\n if (renderRun !== run) return\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nasync function openEmbedded(file: DocxEmbeddedFile): Promise<void> {\n if (!documentBuffer) return\n const buffer = documentBuffer\n let pendingUrl = ''\n documentScrollTop = docEl.value?.scrollTop ?? 0\n closeEmbedded(false)\n // closeEmbedded invalidates prior work; this run now becomes the active selection.\n const activeRun = ++embeddedSelectionRun\n selectedEmbedded.value = file\n embeddedStatus.value = 'loading'\n embeddedError.value = ''\n try {\n const bytes = await extractDocxEmbeddedFile(buffer, file)\n if (embeddedSelectionRun !== activeRun || documentBuffer !== buffer) return\n const url = URL.createObjectURL(new Blob([bytes], { type: file.contentType }))\n pendingUrl = url\n const viewerFile = classifyViewerFile({\n name: file.name,\n url,\n size: file.size,\n mime: file.contentType,\n })\n if (embeddedSelectionRun !== activeRun || selectedEmbedded.value !== file) {\n URL.revokeObjectURL(url)\n pendingUrl = ''\n return\n }\n embeddedBlobUrl = url\n pendingUrl = ''\n embeddedViewerFile.value = viewerFile\n embeddedStatus.value = 'ready'\n } catch (error) {\n if (pendingUrl) URL.revokeObjectURL(pendingUrl)\n if (embeddedSelectionRun !== activeRun) return\n embeddedError.value = error instanceof Error ? error.message : String(error)\n embeddedStatus.value = 'error'\n }\n}\n\nasync function downloadEmbedded(file: DocxEmbeddedFile): Promise<void> {\n if (!documentBuffer || downloadBusyId.value) return\n const buffer = documentBuffer\n downloadBusyId.value = file.id\n downloadError.value = ''\n let url = ''\n try {\n const bytes = await extractDocxEmbeddedFile(buffer, file)\n if (documentBuffer !== buffer) return\n url = URL.createObjectURL(new Blob([bytes], { type: file.contentType }))\n const anchor = document.createElement('a')\n anchor.href = url\n anchor.download = file.name\n document.body.appendChild(anchor)\n anchor.click()\n anchor.remove()\n } catch (error) {\n downloadError.value = error instanceof Error ? error.message : String(error)\n } finally {\n if (url) setTimeout(() => URL.revokeObjectURL(url), 1000)\n if (downloadBusyId.value === file.id) downloadBusyId.value = ''\n }\n}\n\nwatch(\n [() => props.url, docEl],\n ([url, element]) => {\n if (url && element) void render()\n },\n { immediate: true },\n)\nonBeforeUnmount(() => {\n renderRun++\n resetEmbedded()\n if (docEl.value) docEl.value.innerHTML = ''\n})\n</script>\n\n<template>\n <div class=\"dx\">\n <DocxNavigationPane\n v-if=\"status === 'ready' && !selectedEmbedded\"\n v-model:collapsed=\"navCollapsed\"\n v-model:view=\"navView\"\n :outline-items=\"outlineItems\"\n :page-items=\"pageItems\"\n :active-outline-id=\"activeOutlineId\"\n :active-page-id=\"activePageId\"\n @navigate=\"navigate\"\n />\n <div class=\"dx__stage\">\n <div v-if=\"selectedEmbedded\" class=\"dx__embedded-bar\">\n <button type=\"button\" class=\"dx__back\" title=\"返回 Word 文档\" @click=\"closeEmbedded()\">\n <Icon name=\"arrowLeft\" :size=\"16\" />\n </button>\n <span class=\"dx__embedded-title\" :title=\"selectedEmbedded.name\">\n {{ selectedEmbedded.label || selectedEmbedded.name }}\n </span>\n <span class=\"dx__embedded-size\">{{ formatBytes(selectedEmbedded.size) }}</span>\n <button\n type=\"button\"\n class=\"dx__download\"\n title=\"下载内嵌文件\"\n @click=\"downloadEmbedded(selectedEmbedded)\"\n >\n <Icon name=\"download\" :size=\"15\" />\n </button>\n </div>\n\n <p v-if=\"status === 'loading'\" class=\"dx__msg\">正在渲染 Word 文档…</p>\n <div v-else-if=\"status === 'error'\" class=\"dx__msg dx__msg--error\">\n <p>渲染失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"dx__retry\" @click=\"render\">重试</button>\n </div>\n <template v-else-if=\"selectedEmbedded\">\n <p v-if=\"embeddedStatus === 'loading'\" class=\"dx__msg\">正在读取内嵌文件…</p>\n <div v-else-if=\"embeddedStatus === 'error'\" class=\"dx__msg dx__msg--error\">\n <p>读取失败:{{ embeddedError }}</p>\n <button type=\"button\" class=\"dx__retry\" @click=\"openEmbedded(selectedEmbedded)\">\n 重试\n </button>\n </div>\n <FileViewerBody v-else-if=\"embeddedViewerFile\" :file=\"embeddedViewerFile\" mode=\"render\" />\n </template>\n\n <DocxEmbeddedFiles\n v-else-if=\"status === 'ready' && embeddedFiles.length\"\n :files=\"embeddedFiles\"\n :download-busy-id=\"downloadBusyId\"\n :error=\"downloadError\"\n @open=\"openEmbedded\"\n @download=\"downloadEmbedded\"\n />\n <!-- Always mounted (v-show) so docEl is available to docx-preview; hidden until render completes. -->\n <div v-show=\"status === 'ready' && !selectedEmbedded\" ref=\"docEl\" class=\"dx__doc\" />\n </div>\n </div>\n</template>\n\n<style scoped>\n.dx {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: row;\n background: var(--surface);\n container-type: inline-size;\n}\n.dx__stage {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 0;\n display: flex;\n flex-direction: column;\n}\n.dx__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.dx__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.dx__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.dx__retry:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.dx__embedded-bar {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n padding: 7px 10px 7px 8px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.dx__back,\n.dx__download {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 30px;\n height: 28px;\n padding: 0;\n color: var(--muted);\n background: transparent;\n border: none;\n border-radius: 7px;\n cursor: pointer;\n}\n.dx__back:hover,\n.dx__download:hover {\n color: var(--accent);\n background: var(--surface-2);\n}\n.dx__embedded-title {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n color: var(--text-strong);\n font-size: 12.5px;\n font-weight: 500;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.dx__embedded-size {\n flex-shrink: 0;\n color: var(--faint);\n font-size: 11px;\n font-variant-numeric: tabular-nums;\n}\n\n/* docx-preview intentionally skips OLE `w:object` nodes but leaves their empty <p>. The DOM adapter inserts\n * Word/WPS's inert raster snapshot, or a rendered PPTX first slide when the snapshot is browser-incompatible,\n * into that exact paragraph. The whole preview opens the real embedded file. */\n.dx__doc :deep(.docx-embedded-host) {\n min-height: 0;\n margin: 8px 0 12px;\n text-align: center;\n}\n.dx__doc :deep(.docx-embedded-preview) {\n position: relative;\n display: inline-flex;\n box-sizing: border-box;\n align-items: center;\n justify-content: center;\n gap: 10px;\n width: 100%;\n min-height: 72px;\n padding: 12px;\n overflow: hidden;\n color: #334155;\n background: #f8fafc;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);\n cursor: pointer;\n font-family: Arial, sans-serif;\n text-align: left;\n}\n.dx__doc :deep(.docx-embedded-preview:hover),\n.dx__doc :deep(.docx-embedded-preview:focus-visible) {\n border-color: #3370ff;\n box-shadow:\n 0 0 0 2px rgba(51, 112, 255, 0.16),\n 0 2px 8px rgba(15, 23, 42, 0.16);\n outline: none;\n}\n.dx__doc :deep(.docx-embedded-preview--image) {\n display: inline-block;\n min-height: 0;\n padding: 0;\n background: #fff;\n}\n.dx__doc :deep(.docx-embedded-preview--pptx) {\n display: block;\n min-height: 0;\n padding: 0;\n background: #fff;\n}\n.dx__doc :deep(.docx-embedded-preview--loading) {\n cursor: progress;\n}\n.dx__doc :deep(.docx-embedded-preview__pptx-slide) {\n width: 100%;\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n}\n.dx__doc :deep(.docx-embedded-preview__pptx-status) {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n color: #64748b;\n background: #f8fafc;\n font-size: 12px;\n font-weight: 600;\n}\n.dx__doc :deep(.docx-embedded-preview__image) {\n display: block;\n width: 100%;\n height: auto;\n object-fit: contain;\n}\n.dx__doc :deep(.docx-embedded-preview__hint) {\n position: absolute;\n right: 8px;\n bottom: 8px;\n padding: 5px 9px;\n color: #fff;\n background: rgba(15, 23, 42, 0.76);\n border-radius: 999px;\n font-size: 10px;\n font-weight: 600;\n letter-spacing: 0.02em;\n opacity: 0;\n pointer-events: none;\n transform: translateY(3px);\n transition:\n opacity 0.15s,\n transform 0.15s;\n}\n.dx__doc :deep(.docx-embedded-preview:hover .docx-embedded-preview__hint),\n.dx__doc :deep(.docx-embedded-preview:focus-visible .docx-embedded-preview__hint) {\n opacity: 1;\n transform: translateY(0);\n}\n.dx__doc :deep(.docx-embedded-preview__badge) {\n flex-shrink: 0;\n padding: 5px 7px;\n color: #166534;\n background: #dcfce7;\n border-radius: 5px;\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.04em;\n}\n.dx__doc :deep(.docx-embedded-preview__fallback-label) {\n min-width: 0;\n overflow: hidden;\n font-size: 12px;\n font-weight: 600;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* Scroll region holding the rendered document. The pages keep their native Word appearance (white paper,\n * black text) for fidelity; we only retint the surrounding \"desk\" to the app's neutral surface so it sits\n * in the panel naturally in both light and dark themes. :deep reaches docx-preview's injected DOM. */\n.dx__doc {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n background: var(--surface-2);\n}\n.dx__doc :deep(.docx-wrapper) {\n background: var(--surface-2);\n padding: 22px 16px 40px;\n gap: 16px;\n}\n/* Each page: a white sheet with a soft shadow. With ignoreWidth the page reflows to the panel width, so\n * pin it to fill the wrapper (box-sizing so the doc's own margins stay inside the sheet, not overflowing). */\n.dx__doc :deep(.docx-wrapper > section.docx) {\n width: 100%;\n max-width: 100%;\n box-sizing: border-box;\n box-shadow: 0 2px 14px rgba(0, 0, 0, 0.18);\n margin-bottom: 0;\n}\n/* Some Word docs anchor tables as \"floating\" objects (OOXML `w:tblpPr` — a table pinned to a fixed spot on\n * the page with body text wrapping around it). docx-preview approximates that with `float: left` + a fixed\n * point width. On a full A4 page the table lands where Word put it, but in this narrow, width-reflowing\n * panel the floats stack unpredictably and the surrounding paragraphs wrap into the gaps beside them — so on\n * resize the layout scrambles and dark cell shading appears to streak across (the reported \"背景乱串 / 排版\n * 混乱\"). Since we've already chosen reflow over pixel-exact A4 fidelity (ignoreWidth + section width:100%),\n * force every table back to a normal full-width block that stacks top-to-bottom at any panel width, and let\n * long cell content wrap instead of overflowing. */\n.dx__doc :deep(section.docx table) {\n float: none !important;\n width: 100% !important;\n max-width: 100%;\n margin-left: 0 !important;\n margin-right: 0 !important;\n}\n.dx__doc :deep(section.docx td),\n.dx__doc :deep(section.docx th) {\n overflow-wrap: break-word;\n}\n/* The page's own side margins (this doc uses ~90pt) would swallow most of a narrow panel now that tables\n * fill the width, so cap the horizontal padding while keeping the top/bottom page margin intact. */\n.dx__doc :deep(section.docx) {\n padding-left: clamp(14px, 5%, 90pt) !important;\n padding-right: clamp(14px, 5%, 90pt) !important;\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"DocxViewer.js","names":[],"sources":["../../src/components/DocxViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { defineAsyncComponent, nextTick, onBeforeUnmount, shallowRef, watch } from 'vue'\nimport { renderAsync } from 'docx-preview'\nimport DocxEmbeddedFiles from '@/components/DocxEmbeddedFiles.vue'\nimport DocxNavigationPane from '@/components/DocxNavigationPane.vue'\nimport Icon from '@/components/icons/Icon.vue'\nimport type { ViewerFile } from '@/composables/useFileViewer'\nimport { useElementNavigation } from '@/composables/useElementNavigation'\nimport {\n extractDocxEmbeddedFile,\n readDocxEmbeddedFiles,\n type DocxEmbeddedFile,\n} from '@/lib/docxEmbeddings'\nimport { mountDocxEmbeddedPreviews, type DocxEmbeddedDomController } from '@/lib/docxEmbeddedDom'\nimport {\n buildDocxNavigation,\n buildDocxPageNavigation,\n type ParsedDocxModel,\n} from '@/lib/docxNavigation'\nimport { formatBytes } from '@/lib/format'\nimport { applyDocxReadingTheme } from '@/lib/docxTheme'\nimport { classifyViewerFile } from '@/lib/viewerKind'\n\n// The shared body lets an extracted package reuse the same viewer as a top-level file. Lazy loading breaks the\n// FileViewerBody → DocxViewer → FileViewerBody module cycle, matching ZipBrowser's nested-entry hand-off.\nconst FileViewerBody = defineAsyncComponent(() => import('@/components/FileViewerBody.vue'))\n\n/**\n * Side-viewer Word (`.docx`) renderer. Fetches the document bytes from the download URL and renders the\n * OOXML to HTML entirely in-browser via docx-preview (https://github.com/VolodymyrBaydalka/docxjs) — a\n * paginated preview (plain pages follow the viewer; authored colors keep their paper). No CDN / network beyond the\n * file fetch: docx-preview bundles its own jszip + renderer, and images are inlined as base64.\n *\n * Two entry shapes mirror the other browsers (DICOM/zip): a real download URL for a held attachment, or a\n * blob URL minted by the archive browsers from already-extracted bytes. docx-preview injects its own CSS\n * into the render container, prefixed with the `className` ('docx') so it can't leak onto the page.\n */\nconst props = defineProps<{\n /** Download/blob URL of the .docx bytes. */\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'ready' | 'error'\nconst status = shallowRef<Status>('loading')\nconst errorMsg = shallowRef('')\n// The element docx-preview renders into. Always mounted (v-show, not v-if) so the ref exists when we render.\nconst docEl = shallowRef<HTMLElement | null>(null)\nconst navView = shallowRef<'outline' | 'pages'>('outline')\nconst navCollapsed = shallowRef(false)\nconst { outlineItems, pageItems, activeOutlineId, activePageId, setItems, navigate } =\n useElementNavigation(docEl)\n\nlet documentBuffer: ArrayBuffer | null = null\nlet embeddedBlobUrl = ''\nlet embeddedSelectionRun = 0\nlet renderRun = 0\nlet documentScrollTop = 0\nlet inlineEmbedded: DocxEmbeddedDomController | null = null\nconst embeddedFiles = shallowRef<DocxEmbeddedFile[]>([])\nconst selectedEmbedded = shallowRef<DocxEmbeddedFile | null>(null)\nconst embeddedViewerFile = shallowRef<ViewerFile | null>(null)\nconst embeddedStatus = shallowRef<Status>('ready')\nconst embeddedError = shallowRef('')\nconst downloadBusyId = shallowRef('')\nconst downloadError = shallowRef('')\n\nfunction revokeEmbeddedBlob(): void {\n if (!embeddedBlobUrl) return\n URL.revokeObjectURL(embeddedBlobUrl)\n embeddedBlobUrl = ''\n}\n\nfunction clearInlineEmbedded(): void {\n inlineEmbedded?.dispose()\n inlineEmbedded = null\n}\n\nfunction closeEmbedded(restoreDocumentScroll = true): void {\n const shouldRestoreScroll = restoreDocumentScroll && selectedEmbedded.value !== null\n embeddedSelectionRun++\n selectedEmbedded.value = null\n embeddedViewerFile.value = null\n embeddedStatus.value = 'ready'\n embeddedError.value = ''\n revokeEmbeddedBlob()\n if (shouldRestoreScroll) {\n void nextTick(() => {\n if (!selectedEmbedded.value && docEl.value) docEl.value.scrollTop = documentScrollTop\n })\n }\n}\n\nfunction resetEmbedded(): void {\n closeEmbedded(false)\n clearInlineEmbedded()\n documentBuffer = null\n documentScrollTop = 0\n embeddedFiles.value = []\n downloadBusyId.value = ''\n downloadError.value = ''\n}\n\nasync function render(): Promise<void> {\n const el = docEl.value\n if (!props.url || !el) return\n const run = ++renderRun\n status.value = 'loading'\n errorMsg.value = ''\n resetEmbedded()\n setItems([], [])\n el.innerHTML = '' // drop any prior render (e.g. on retry / switching files)\n const url = props.url\n try {\n const res = await fetch(url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n // Stale guard: a newer render may have started while we awaited the fetch.\n if (renderRun !== run || props.url !== url || docEl.value !== el) return\n // Embedded packages are a best-effort enhancement: malformed relationship XML must not blank a Word file\n // that docx-preview can otherwise render. Parse concurrently because both consumers only read the buffer.\n const embeddedPromise = readDocxEmbeddedFiles(buf).catch((error) => {\n console.warn('DOCX embedded files unavailable:', error)\n return []\n })\n const document = (await renderAsync(buf, el, undefined, {\n className: 'docx', // CSS prefix → docx-preview's injected styles stay scoped to this subtree\n inWrapper: true, // wrap pages in the gray \"paper on desk\" canvas, like Word\n // The side panel is narrow (and resizable), so don't pin pages to the fixed A4 width — that overflows\n // and clips the left edge. Let pages fill the available width and reflow; height follows content.\n ignoreWidth: true,\n ignoreHeight: true,\n breakPages: true, // honor page breaks → still separate page sheets, stacked vertically\n renderHeaders: true,\n renderFooters: true,\n renderFootnotes: true,\n renderEndnotes: true,\n useBase64URL: true, // inline embedded images as data URLs (no blob-URL lifecycle to manage)\n })) as ParsedDocxModel\n const discoveredEmbeddedFiles = await embeddedPromise\n if (renderRun !== run || props.url !== url || docEl.value !== el) return\n applyDocxReadingTheme(el)\n let navigation\n try {\n navigation = buildDocxNavigation(document, el)\n } catch (error) {\n // A malformed/unsupported Word model must never turn an otherwise rendered document into an error page.\n console.warn('DOCX outline navigation unavailable:', error)\n navigation = { outline: [], pages: buildDocxPageNavigation(el) }\n }\n setItems(navigation.outline, navigation.pages)\n navView.value = navigation.outline.length ? 'outline' : 'pages'\n documentBuffer = buf\n embeddedFiles.value = discoveredEmbeddedFiles\n inlineEmbedded = mountDocxEmbeddedPreviews({\n root: el,\n buffer: buf,\n files: discoveredEmbeddedFiles,\n onOpen: (file) => void openEmbedded(file),\n })\n status.value = 'ready'\n } catch (e) {\n if (renderRun !== run) return\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nasync function openEmbedded(file: DocxEmbeddedFile): Promise<void> {\n if (!documentBuffer) return\n const buffer = documentBuffer\n let pendingUrl = ''\n documentScrollTop = docEl.value?.scrollTop ?? 0\n closeEmbedded(false)\n // closeEmbedded invalidates prior work; this run now becomes the active selection.\n const activeRun = ++embeddedSelectionRun\n selectedEmbedded.value = file\n embeddedStatus.value = 'loading'\n embeddedError.value = ''\n try {\n const bytes = await extractDocxEmbeddedFile(buffer, file)\n if (embeddedSelectionRun !== activeRun || documentBuffer !== buffer) return\n const url = URL.createObjectURL(new Blob([bytes], { type: file.contentType }))\n pendingUrl = url\n const viewerFile = classifyViewerFile({\n name: file.name,\n url,\n size: file.size,\n mime: file.contentType,\n })\n if (embeddedSelectionRun !== activeRun || selectedEmbedded.value !== file) {\n URL.revokeObjectURL(url)\n pendingUrl = ''\n return\n }\n embeddedBlobUrl = url\n pendingUrl = ''\n embeddedViewerFile.value = viewerFile\n embeddedStatus.value = 'ready'\n } catch (error) {\n if (pendingUrl) URL.revokeObjectURL(pendingUrl)\n if (embeddedSelectionRun !== activeRun) return\n embeddedError.value = error instanceof Error ? error.message : String(error)\n embeddedStatus.value = 'error'\n }\n}\n\nasync function downloadEmbedded(file: DocxEmbeddedFile): Promise<void> {\n if (!documentBuffer || downloadBusyId.value) return\n const buffer = documentBuffer\n downloadBusyId.value = file.id\n downloadError.value = ''\n let url = ''\n try {\n const bytes = await extractDocxEmbeddedFile(buffer, file)\n if (documentBuffer !== buffer) return\n url = URL.createObjectURL(new Blob([bytes], { type: file.contentType }))\n const anchor = document.createElement('a')\n anchor.href = url\n anchor.download = file.name\n document.body.appendChild(anchor)\n anchor.click()\n anchor.remove()\n } catch (error) {\n downloadError.value = error instanceof Error ? error.message : String(error)\n } finally {\n if (url) setTimeout(() => URL.revokeObjectURL(url), 1000)\n if (downloadBusyId.value === file.id) downloadBusyId.value = ''\n }\n}\n\nwatch(\n [() => props.url, docEl],\n ([url, element]) => {\n if (url && element) void render()\n },\n { immediate: true },\n)\nonBeforeUnmount(() => {\n renderRun++\n resetEmbedded()\n if (docEl.value) docEl.value.innerHTML = ''\n})\n</script>\n\n<template>\n <div class=\"dx\">\n <DocxNavigationPane\n v-if=\"status === 'ready' && !selectedEmbedded\"\n v-model:collapsed=\"navCollapsed\"\n v-model:view=\"navView\"\n :outline-items=\"outlineItems\"\n :page-items=\"pageItems\"\n :active-outline-id=\"activeOutlineId\"\n :active-page-id=\"activePageId\"\n @navigate=\"navigate\"\n />\n <div class=\"dx__stage\">\n <div v-if=\"selectedEmbedded\" class=\"dx__embedded-bar\">\n <button type=\"button\" class=\"dx__back\" title=\"返回 Word 文档\" @click=\"closeEmbedded()\">\n <Icon name=\"arrowLeft\" :size=\"16\" />\n </button>\n <span class=\"dx__embedded-title\" :title=\"selectedEmbedded.name\">\n {{ selectedEmbedded.label || selectedEmbedded.name }}\n </span>\n <span class=\"dx__embedded-size\">{{ formatBytes(selectedEmbedded.size) }}</span>\n <button\n type=\"button\"\n class=\"dx__download\"\n title=\"下载内嵌文件\"\n @click=\"downloadEmbedded(selectedEmbedded)\"\n >\n <Icon name=\"download\" :size=\"15\" />\n </button>\n </div>\n\n <p v-if=\"status === 'loading'\" class=\"dx__msg\">正在渲染 Word 文档…</p>\n <div v-else-if=\"status === 'error'\" class=\"dx__msg dx__msg--error\">\n <p>渲染失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"dx__retry\" @click=\"render\">重试</button>\n </div>\n <template v-else-if=\"selectedEmbedded\">\n <p v-if=\"embeddedStatus === 'loading'\" class=\"dx__msg\">正在读取内嵌文件…</p>\n <div v-else-if=\"embeddedStatus === 'error'\" class=\"dx__msg dx__msg--error\">\n <p>读取失败:{{ embeddedError }}</p>\n <button type=\"button\" class=\"dx__retry\" @click=\"openEmbedded(selectedEmbedded)\">\n 重试\n </button>\n </div>\n <FileViewerBody v-else-if=\"embeddedViewerFile\" :file=\"embeddedViewerFile\" mode=\"render\" />\n </template>\n\n <DocxEmbeddedFiles\n v-else-if=\"status === 'ready' && embeddedFiles.length\"\n :files=\"embeddedFiles\"\n :download-busy-id=\"downloadBusyId\"\n :error=\"downloadError\"\n @open=\"openEmbedded\"\n @download=\"downloadEmbedded\"\n />\n <!-- Always mounted (v-show) so docEl is available to docx-preview; hidden until render completes. -->\n <div v-show=\"status === 'ready' && !selectedEmbedded\" ref=\"docEl\" class=\"dx__doc\" />\n </div>\n </div>\n</template>\n\n<style scoped>\n.dx {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: row;\n background: var(--surface);\n container-type: inline-size;\n}\n.dx__stage {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 0;\n display: flex;\n flex-direction: column;\n}\n.dx__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.dx__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.dx__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.dx__retry:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.dx__embedded-bar {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n padding: 7px 10px 7px 8px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n.dx__back,\n.dx__download {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 30px;\n height: 28px;\n padding: 0;\n color: var(--muted);\n background: transparent;\n border: none;\n border-radius: 7px;\n cursor: pointer;\n}\n.dx__back:hover,\n.dx__download:hover {\n color: var(--accent);\n background: var(--surface-2);\n}\n.dx__embedded-title {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n color: var(--text-strong);\n font-size: 12.5px;\n font-weight: 500;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.dx__embedded-size {\n flex-shrink: 0;\n color: var(--faint);\n font-size: 11px;\n font-variant-numeric: tabular-nums;\n}\n\n/* docx-preview intentionally skips OLE `w:object` nodes but leaves their empty <p>. The DOM adapter inserts\n * Word/WPS's inert raster snapshot, or a rendered PPTX first slide when the snapshot is browser-incompatible,\n * into that exact paragraph. The whole preview opens the real embedded file. */\n.dx__doc :deep(.docx-embedded-host) {\n min-height: 0;\n margin: 8px 0 12px;\n text-align: center;\n}\n.dx__doc :deep(.docx-embedded-preview) {\n position: relative;\n display: inline-flex;\n box-sizing: border-box;\n align-items: center;\n justify-content: center;\n gap: 10px;\n width: 100%;\n min-height: 72px;\n padding: 12px;\n overflow: hidden;\n color: var(--text);\n background: var(--surface-2);\n border: 1px solid var(--border-2);\n border-radius: 6px;\n box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);\n cursor: pointer;\n font-family: Arial, sans-serif;\n text-align: left;\n}\n.dx__doc :deep(.docx-embedded-preview:hover),\n.dx__doc :deep(.docx-embedded-preview:focus-visible) {\n border-color: var(--hl-attr);\n box-shadow:\n 0 0 0 2px color-mix(in srgb, var(--hl-attr) 16%, transparent),\n 0 2px 8px rgba(15, 23, 42, 0.16);\n outline: none;\n}\n.dx__doc :deep(.docx-embedded-preview--image) {\n display: inline-block;\n min-height: 0;\n padding: 0;\n background: #fff;\n}\n.dx__doc :deep(.docx-embedded-preview--pptx) {\n display: block;\n min-height: 0;\n padding: 0;\n background: #fff;\n}\n.dx__doc :deep(.docx-embedded-preview--loading) {\n cursor: progress;\n}\n.dx__doc :deep(.docx-embedded-preview__pptx-slide) {\n width: 100%;\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n}\n.dx__doc :deep(.docx-embedded-preview__pptx-status) {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n color: var(--muted);\n background: var(--surface-2);\n font-size: 12px;\n font-weight: 600;\n}\n.dx__doc :deep(.docx-embedded-preview__image) {\n display: block;\n width: 100%;\n height: auto;\n object-fit: contain;\n}\n.dx__doc :deep(.docx-embedded-preview__hint) {\n position: absolute;\n right: 8px;\n bottom: 8px;\n padding: 5px 9px;\n color: #fff;\n background: rgba(15, 23, 42, 0.76);\n border-radius: 999px;\n font-size: 10px;\n font-weight: 600;\n letter-spacing: 0.02em;\n opacity: 0;\n pointer-events: none;\n transform: translateY(3px);\n transition:\n opacity 0.15s,\n transform 0.15s;\n}\n.dx__doc :deep(.docx-embedded-preview:hover .docx-embedded-preview__hint),\n.dx__doc :deep(.docx-embedded-preview:focus-visible .docx-embedded-preview__hint) {\n opacity: 1;\n transform: translateY(0);\n}\n.dx__doc :deep(.docx-embedded-preview__badge) {\n flex-shrink: 0;\n padding: 5px 7px;\n color: var(--hl-name);\n background: color-mix(in srgb, var(--hl-name) 14%, var(--surface));\n border-radius: 5px;\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.04em;\n}\n.dx__doc :deep(.docx-embedded-preview__fallback-label) {\n min-width: 0;\n overflow: hidden;\n font-size: 12px;\n font-weight: 600;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* Scroll region holding the rendered document. The pages keep their native Word appearance (white paper,\n * black text) for fidelity; we only retint the surrounding \"desk\" to the app's neutral surface so it sits\n * in the panel naturally in both light and dark themes. :deep reaches docx-preview's injected DOM. */\n.dx__doc {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n background: var(--surface-2);\n}\n.dx__doc :deep(.docx-wrapper) {\n background: var(--surface-2);\n padding: 22px 16px 40px;\n gap: 16px;\n}\n/* Each page gets a soft shadow. With ignoreWidth the page reflows to the panel width, so\n * pin it to fill the wrapper (box-sizing so the doc's own margins stay inside the sheet, not overflowing). */\n.dx__doc :deep(.docx-wrapper > section.docx) {\n width: 100%;\n max-width: 100%;\n box-sizing: border-box;\n box-shadow: var(--shadow);\n margin-bottom: 0;\n}\n/* Complex pages retain their original paper/native controls; simple pages inherit the viewer scheme. */\n.dx__doc :deep(section.docx:not([data-fv-reading-theme='adaptive'])) {\n color-scheme: light;\n}\n/* Some Word docs anchor tables as \"floating\" objects (OOXML `w:tblpPr` — a table pinned to a fixed spot on\n * the page with body text wrapping around it). docx-preview approximates that with `float: left` + a fixed\n * point width. On a full A4 page the table lands where Word put it, but in this narrow, width-reflowing\n * panel the floats stack unpredictably and the surrounding paragraphs wrap into the gaps beside them — so on\n * resize the layout scrambles and dark cell shading appears to streak across (the reported \"背景乱串 / 排版\n * 混乱\"). Since we've already chosen reflow over pixel-exact A4 fidelity (ignoreWidth + section width:100%),\n * force every table back to a normal full-width block that stacks top-to-bottom at any panel width, and let\n * long cell content wrap instead of overflowing. */\n.dx__doc :deep(section.docx table) {\n float: none !important;\n width: 100% !important;\n max-width: 100%;\n margin-left: 0 !important;\n margin-right: 0 !important;\n}\n.dx__doc :deep(section.docx td),\n.dx__doc :deep(section.docx th) {\n overflow-wrap: break-word;\n}\n/* The page's own side margins (this doc uses ~90pt) would swallow most of a narrow panel now that tables\n * fill the width, so cap the horizontal padding while keeping the top/bottom page margin intact. */\n.dx__doc :deep(section.docx) {\n padding-left: clamp(14px, 5%, 90pt) !important;\n padding-right: clamp(14px, 5%, 90pt) !important;\n}\n</style>\n"],"mappings":""}