feat: add Office, PDF, and PPTX previews
This commit is contained in:
@@ -94,6 +94,12 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
|||||||
- **CodeMirror 6**:自动换行 + 按扩展名语法高亮(js/ts/jsx/tsx、json、python、html、css、xml、yaml、sql、java、c/c++、rust、go、php、shell、toml、nginx、dockerfile、properties…)
|
- **CodeMirror 6**:自动换行 + 按扩展名语法高亮(js/ts/jsx/tsx、json、python、html、css、xml、yaml、sql、java、c/c++、rust、go、php、shell、toml、nginx、dockerfile、properties…)
|
||||||
- 脏点标记 + **Ctrl/Cmd+S 保存**(原子写入);512KB 截断横幅、二进制文件提示
|
- 脏点标记 + **Ctrl/Cmd+S 保存**(原子写入);512KB 截断横幅、二进制文件提示
|
||||||
- 图片直接预览;**Markdown 预览/编辑切换**,预览实时渲染未保存草稿
|
- 图片直接预览;**Markdown 预览/编辑切换**,预览实时渲染未保存草稿
|
||||||
|
- **PDF 原生预览**:使用浏览器内置 PDF 查看器,预览区始终保留下载入口
|
||||||
|
- **Office 三件套预览**:
|
||||||
|
- `.docx` — 保真渲染(保留样式 / 页眉页脚 / 图片 / 表格),基于 [docx-preview](https://github.com/VolodymyrBaydalka/docxjs)
|
||||||
|
- `.xlsx` — 完整电子表格(多 sheet / 公式计算 / 列宽行高 / 合并单元格),基于 [Univer](https://univer.ai) + [SheetJS](https://sheetjs.com)
|
||||||
|
- `.pptx` — 高保真幻灯片预览(图片 / 表格 / 图表 / SmartArt)+ 上一页 / 下一页导航,基于 [pptx-renderer](https://github.com/aiden0z/pptx-renderer)
|
||||||
|
- `.doc` / `.xls` / `.ppt` 及其他二进制 — 下载按钮(旧 OLE 格式纯前端无成熟渲染库)
|
||||||
- 切换 Tab 不卸载:编辑器保留草稿与撤销栈
|
- 切换 Tab 不卸载:编辑器保留草稿与撤销栈
|
||||||
|
|
||||||
### 💻 终端
|
### 💻 终端
|
||||||
@@ -275,6 +281,8 @@ pnpm watch # tsdown --watch(client bundle 热重建)
|
|||||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新)
|
- Git 无 push/pull/fetch;无文件 watcher(手动刷新)
|
||||||
- 工具行内的文件打开按钮(核心代码)不可拦截,仅"产出文件"行被接管
|
- 工具行内的文件打开按钮(核心代码)不可拦截,仅"产出文件"行被接管
|
||||||
- 终端 Tab **拖拽到另一分栏**时会重挂载(shell 重开);切换 Tab / 切换会话(30 秒内返回)/ 刷新页面不会
|
- 终端 Tab **拖拽到另一分栏**时会重挂载(shell 重开);切换 Tab / 切换会话(30 秒内返回)/ 刷新页面不会
|
||||||
|
- `.xlsx` 预览不保留单元格样式(字体/颜色/边框/条件格式/图表)——SheetJS 社区版不解析样式,需保真请下载查看
|
||||||
|
- Office/PPTX 预览组件内联进 client bundle,当前约 23MB(gzip ~4.8MB),首次加载较慢
|
||||||
|
|
||||||
## 🖥️ 平台支持
|
## 🖥️ 平台支持
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Word 缩放与 PDF 拖拽修复设计
|
||||||
|
|
||||||
|
> 日期: 2026-08-10
|
||||||
|
> 状态: 已批准
|
||||||
|
|
||||||
|
## Word 缩放
|
||||||
|
|
||||||
|
- 默认 100%,范围 50%–200%,步进 10%。
|
||||||
|
- `Alt + 滚轮` 调整缩放并阻止 viewport 默认滚动。
|
||||||
|
- 底部固定 range slider,同步显示百分比。
|
||||||
|
- 使用 CSS `zoom` 缩放 docx-preview 专属容器,使滚动尺寸同步变化。
|
||||||
|
- 切换 Word 文件时恢复 100%。
|
||||||
|
|
||||||
|
## PDF 拖拽
|
||||||
|
|
||||||
|
- iframe 放入独立 stage。
|
||||||
|
- stage 内常驻透明 drag shield。
|
||||||
|
- Tab 拖拽或面板/分栏调整期间立即启用 shield;结束、取消或窗口失焦时关闭。
|
||||||
|
- shield 捕获 Chromium 内置 PDF viewer 吞掉的 dragover/pointer 流,正常状态不影响 PDF 交互。
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
# Office 三件套预览 — 设计文档
|
||||||
|
|
||||||
|
> 日期:2026-08-10
|
||||||
|
> 状态:已批准(待实现)
|
||||||
|
> 范围:`dsh-better-sidebar` 插件,`EditorView` 文件类型扩展
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
当前 `EditorView.tsx` 对 `.docx/.xlsx/.pptx` 及旧格式 `.doc/.xls/.ppt` 一律显示 binary 占位(`EditorView.tsx:210`),因为 host 的 `readText` 检测到 NUL 字节就标记 binary(`src/index.ts:108`),而 Office 文件本质是 zip。
|
||||||
|
|
||||||
|
**目标**:为 Office 三件套新增有意义的预览能力,保持插件现有架构纪律(host 零改动、bundle 纯度门、跨平台、失败模式全覆盖)。
|
||||||
|
|
||||||
|
**非目标**:
|
||||||
|
- 不做 Office 文件编辑(只预览)
|
||||||
|
- 不做 `push/pull/fetch` 之类网络 Git 操作(与本次无关,沿用 v1 限制)
|
||||||
|
- 不做文件 watcher
|
||||||
|
|
||||||
|
## 2. 范围与选型
|
||||||
|
|
||||||
|
| 类型 | 方案 | 库 | 加载策略 | host 改动 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `.docx` | 保真渲染(保留样式/页眉页脚/图片/表格) | `docx-preview` | 静态 import(~500KB,小) | 零 |
|
||||||
|
| `.xlsx` | 完整电子表格(公式/图表/条件格式/多 sheet) | `@univerjs/*` 全家桶 + `xlsx`(SheetJS,导入) | `dynamic import()` 懒加载(~5MB,首次打开 .xlsx 才加载) | 零 |
|
||||||
|
| `.pptx` | 降级下载按钮 | — | — | — |
|
||||||
|
| `.doc/.xls/.ppt`(OLE 二进制) | 降级下载按钮 | 纯前端无成熟库 | — | — |
|
||||||
|
|
||||||
|
**决策依据**:
|
||||||
|
- 纯前端路线符合现有 bundle 纯度门(`tsdown.config.ts:128` 的 purity gate 只禁 `@deepseek-ai/*` value import,普通 npm 包内联不受限)与跨平台原则(无需 host 装重依赖)。
|
||||||
|
- `pptx` 纯前端无成熟渲染库( OOXML slides + layouts + masters + shapes 解析极复杂),YAGNI 降级。
|
||||||
|
- 旧 OLE 二进制格式(`.doc/.xls/.ppt`)纯前端基本无解,降级。
|
||||||
|
- Univer 选 dynamic import 懒加载:静态内联会让 client bundle 从 2MB→7MB(gzip 1.5MB→3MB),首屏负担过重。
|
||||||
|
|
||||||
|
## 3. 架构
|
||||||
|
|
||||||
|
### 3.1 host 半:零改动
|
||||||
|
|
||||||
|
复用现有 `/sidebar/file` media route(`src/index.ts:349-392`):
|
||||||
|
- 该路由已支持任意文件字节(不限扩展名,只检查 `isWithin(cwd, path)` + `isFile` + `size <= mediaLimit`)
|
||||||
|
- `MEDIA_TYPES`(`index.ts:44`)对 Office 扩展名 fallback 到 `application/octet-stream`,client 用 `fetch` 拿 ArrayBuffer,不依赖 content-type
|
||||||
|
- `mediaLimit` 默认 20MB,对 Office 文件足够(超限降级下载)
|
||||||
|
|
||||||
|
**不改 `fs.read`**:Office 文件不走文本读取路径(会被 NUL 探测当 binary 拒绝),直接走 media route。
|
||||||
|
|
||||||
|
### 3.2 client 半:EditorView 类型扩展
|
||||||
|
|
||||||
|
`EditorLoad` 类型(`EditorView.tsx:26`)扩展:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type EditorLoad =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'error'; message: string }
|
||||||
|
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx'; content: string; truncated: boolean }
|
||||||
|
| { status: 'binary' } // 现保留:未知二进制 / pptx / OLE 旧格式 → 下载按钮
|
||||||
|
```
|
||||||
|
|
||||||
|
加载流程(`EditorView.tsx:60` 的 `useEffect`):
|
||||||
|
1. `api.fsRead(scope, path)` 仍调(用于探测 binary/text)— 对 Office 文件会返回 `{kind:'binary'}`,据此分流
|
||||||
|
2. 识别扩展名 ∈ `{.docx, .xlsx}` → `setLoad({ status:'ready', kind:'docx'|'xlsx', content:'', truncated:false })`
|
||||||
|
3. 识别 `.pptx` / `.doc` / `.xls` / `.ppt` / 其他未知 binary → 保持 `{status:'binary'}`,但 binary 占位改为"下载查看"按钮(见 §3.5)
|
||||||
|
4. `IMAGE_EXT` / `MD_EXT` 分支不变
|
||||||
|
|
||||||
|
> 备注:step 1 的 `fsRead` 对 Office 文件是冗余往返(必定返回 binary)。可选优化:client 侧先按扩展名短路,Office 扩展名直接走 media route 不调 `fsRead`。实现时取短路方案,省一次请求。
|
||||||
|
|
||||||
|
### 3.3 文件类型分发(纯函数,易测)
|
||||||
|
|
||||||
|
新增 `src/client/office-types.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const OFFICE_PREVIEWABLE = ['.docx', '.xlsx'] as const
|
||||||
|
export const OFFICE_DOWNLOAD_ONLY = ['.pptx', '.doc', '.xls', '.ppt'] as const
|
||||||
|
|
||||||
|
export type OfficeKind = 'docx' | 'xlsx' | 'download-only' | null
|
||||||
|
|
||||||
|
export function officeKindForExt(ext: string): OfficeKind {
|
||||||
|
if (ext === '.docx') return 'docx'
|
||||||
|
if (ext === '.xlsx') return 'xlsx'
|
||||||
|
if (['.pptx', '.doc', '.xls', '.ppt'].includes(ext)) return 'download-only'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`EditorView` 的 `extOfPath` 已有(`EditorView.tsx:235`),复用。
|
||||||
|
|
||||||
|
### 3.4 DocxView / XlsxView 组件
|
||||||
|
|
||||||
|
新增 `src/client/office-view.tsx`,导出 `DocxView` 与 `XlsxView`,各自管理库生命周期。
|
||||||
|
|
||||||
|
#### DocxView
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function DocxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [load, setLoad] = useState<{status:'loading'|'ready'|'error'; message?: string}>({status:'loading'})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const container = hostRef.current
|
||||||
|
if (container === null) return
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const buf = await fetch(mediaUrl(scope, path)).then(r => r.arrayBuffer())
|
||||||
|
if (cancelled) return
|
||||||
|
const { renderAsync } = await import('docx-preview') // 静态 import 亦可,此处用动态保持一致
|
||||||
|
await renderAsync(buf, container, null, { className: 'docx', inWrapper: true })
|
||||||
|
if (!cancelled) setLoad({status:'ready'})
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) setLoad({status:'error', message: error instanceof Error ? error.message : String(error)})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true; container.innerHTML = '' }
|
||||||
|
}, [scope.sessionId, scope.cwd, path])
|
||||||
|
|
||||||
|
// 渲染:host div + loading/error 占位
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `docx-preview` 的 `renderAsync(arrayBuffer, container, null, options)` 把 docx 渲染到 container,保留样式/页眉页脚/图片(图片库内部 base64 内联处理)
|
||||||
|
- 卸载清空 `container.innerHTML`(docx-preview 无 dispose API,DOM 清空即可)
|
||||||
|
|
||||||
|
#### XlsxView
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function XlsxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
|
const univerRef = useRef<Univer | null>(null)
|
||||||
|
const [load, setLoad] = useState<{status:'loading'|'ready'|'error'; message?: string}>({status:'loading'})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const container = hostRef.current
|
||||||
|
if (container === null) return
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const buf = await fetch(mediaUrl(scope, path)).then(r => r.arrayBuffer())
|
||||||
|
if (cancelled) return
|
||||||
|
// 懒加载 Univer 全家桶(首次打开 .xlsx 才下载 ~5MB chunk)
|
||||||
|
const { createUniver, defaultLocale } = await import('@univerjs/core')
|
||||||
|
const { UniverSheetsCorePlugin } = await import('@univerjs/sheets-ui')
|
||||||
|
const { UniverSheetsFormulaPlugin } = await import('@univerjs/sheets-formula')
|
||||||
|
const { UniverSheetsConditionalFormattingPlugin } = await import('@univerjs/sheets-conditional-formatting')
|
||||||
|
const { UniverSheetsChartPlugin } = await import('@univerjs/sheets-chart')
|
||||||
|
const { UniverDocsPlugin } = await import('@univerjs/docs-ui')
|
||||||
|
// xlsx 导入:SheetJS 解析后转 Univer workbook data
|
||||||
|
const XLSX = await import('xlsx')
|
||||||
|
const wb = XLSX.read(buf, { type: 'array' })
|
||||||
|
const workbookData = xlsxWorkbookToUniver(wb) // 转换函数,见 §3.6
|
||||||
|
|
||||||
|
const univer = createUniver({
|
||||||
|
locale: defaultLocale,
|
||||||
|
plugins: [
|
||||||
|
UniverDocsPlugin,
|
||||||
|
UniverSheetsCorePlugin,
|
||||||
|
UniverSheetsFormulaPlugin,
|
||||||
|
UniverSheetsConditionalFormattingPlugin,
|
||||||
|
UniverSheetsChartPlugin,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
univer.createUniverSheet(workbookData)
|
||||||
|
univerRef.current = univer
|
||||||
|
if (!cancelled) setLoad({status:'ready'})
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) setLoad({status:'error', message: ...})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
univerRef.current?.dispose() // 关键:防 canvas + worker 泄漏
|
||||||
|
univerRef.current = null
|
||||||
|
}
|
||||||
|
}, [scope.sessionId, scope.cwd, path])
|
||||||
|
|
||||||
|
// 渲染:host div + loading/error 占位
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **dispose 纪律**:卸载必须 `univer.dispose()`,否则 canvas + worker 泄漏(类比 `TerminalView` 的 `term.dispose()` + `observer.disconnect()`)
|
||||||
|
- Univer 的 UI 需要容器有尺寸(类似 xterm),复用 `ResizeObserver` 模式
|
||||||
|
|
||||||
|
### 3.5 binary 占位升级为下载按钮
|
||||||
|
|
||||||
|
`EditorView.tsx:210` 现状:`{t('binary')}` 纯文本占位。
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{load.status === 'binary' && (
|
||||||
|
<div className={css.editorBinary}>
|
||||||
|
<span>{t('binaryNoPreview')}</span>
|
||||||
|
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||||
|
{t('downloadToView')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 复用现有 `downloadUrl(scope, path)`(`api.ts:140`),走 `/sidebar/file?download=1`
|
||||||
|
- 对所有 binary 统一(不只 Office)— PDF、zip、exe 等都受益
|
||||||
|
|
||||||
|
### 3.6 xlsx → Univer workbook data 转换
|
||||||
|
|
||||||
|
SheetJS 解析的 workbook 结构与 Univer 的 `IWorkbookData` 不同,需转换层 `src/client/xlsx-to-univer.ts`:
|
||||||
|
|
||||||
|
- SheetJS `wb.SheetNames[]` + `wb.Sheets[name]` → Univer `sheets: Record<sheetId, IWorksheetData>`
|
||||||
|
- 单元格:`ws[addr]` → `cellData[row][col]`,映射类型(s/n/b/e)、值、样式(对齐/字体/背景/边框)
|
||||||
|
- 合并单元格:`ws['!merges']` → `mergeData`
|
||||||
|
- 列宽/行高:`ws['!cols']` / `ws['!rows']` → `columnData` / `rowData`
|
||||||
|
- 公式:SheetJS `cell.f` → Univer 公式格式
|
||||||
|
- 图表/条件格式:SheetJS 不解析这些,需 Univer importer 或跳过(首版可只迁数据+样式+合并+公式)
|
||||||
|
|
||||||
|
**首版范围**:数据 + 基本样式(字体/颜色/对齐/边框/合并)+ 公式(文本形式即可,Univer 引擎计算)。图表/条件格式若 Univer importer 直接支持则用,否则跳过。
|
||||||
|
|
||||||
|
> 备选:用 Univer 官方的 `@univerjs/sheets-import-export`(基于 SheetJS 封装,直接产出 `IWorkbookData`),省去自写转换层。实现时优先评估此包,若版本耦合/体积可接受则用,否则自写。
|
||||||
|
|
||||||
|
### 3.7 EditorView 集成
|
||||||
|
|
||||||
|
`EditorView.tsx` 的 `renderTab` / `TabContent` 分发(`Sidebar.tsx:49` 的 switch)无需改 — editor tab 仍走 `EditorView`。
|
||||||
|
|
||||||
|
`EditorView` 内部 `useEffect`(`EditorView.tsx:60`)加 Office 分支:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const ext = extOfPath(path)
|
||||||
|
const officeKind = officeKindForExt(ext)
|
||||||
|
if (officeKind === 'docx' || officeKind === 'xlsx') {
|
||||||
|
setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false })
|
||||||
|
return // 不再走 fsRead(短路,省一次 binary 往返)
|
||||||
|
}
|
||||||
|
// 原有 IMAGE / MD / text / binary 逻辑不变
|
||||||
|
```
|
||||||
|
|
||||||
|
渲染区(`EditorView.tsx:220` 附近)加:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{load.status === 'ready' && load.kind === 'docx' && (
|
||||||
|
<DocxView scope={scope} path={path} title={title} />
|
||||||
|
)}
|
||||||
|
{load.status === 'ready' && load.kind === 'xlsx' && (
|
||||||
|
<XlsxView scope={scope} path={path} title={title} />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 错误处理
|
||||||
|
|
||||||
|
| 场景 | 检测 | 响应 |
|
||||||
|
|---|---|---|
|
||||||
|
| 文件损坏(非有效 zip) | 库抛错 | 显示错误信息 + 下载按钮 |
|
||||||
|
| `>20MB`(超 `mediaLimit`) | fetch 响应 / host 返回 400 | 提示"文件过大" + 下载按钮 |
|
||||||
|
| 加密文件(密码保护) | SheetJS/docx-preview 抛特定错 | "不支持加密文件" + 下载按钮 |
|
||||||
|
| Univer chunk 加载失败 | dynamic import reject | 重试按钮(类比 `TerminalView` 的 `FAILURE_LIMIT`) |
|
||||||
|
| 网络失败(媒体路由 403) | fetch 非 200 | 错误信息(信任围栏拒绝) |
|
||||||
|
| Univer dispose 后异步回调 | cancelled flag | 静默(类比现有 `TerminalView` 模式) |
|
||||||
|
|
||||||
|
**降级统一出口**:所有 Office 错误分支都附带"下载查看"链接,确保用户始终能拿到文件。
|
||||||
|
|
||||||
|
## 5. bundle 与构建
|
||||||
|
|
||||||
|
### 5.1 依赖新增(`package.json`)
|
||||||
|
|
||||||
|
`dependencies`(运行时,内联进 client bundle):
|
||||||
|
- `docx-preview`(~500KB)
|
||||||
|
- `xlsx`(SheetJS,~400KB,xlsx 导入)
|
||||||
|
- `@univerjs/core` + `@univerjs/sheets` + `@univerjs/sheets-ui` + `@univerjs/sheets-formula` + `@univerjs/sheets-conditional-formatting` + `@univerjs/sheets-chart` + `@univerjs/docs-ui` + `@univerjs/design` + `@univerjs/engine-render`(~5MB 合计)
|
||||||
|
|
||||||
|
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`xterm`/`CodeMirror` 也在 `dependencies`)。
|
||||||
|
|
||||||
|
### 5.2 tsdown.config.ts 调整
|
||||||
|
|
||||||
|
现有 client bundle 配置(`tsdown.config.ts:103` 的 `clientBundle()`):
|
||||||
|
- `format: 'cjs'` + `banner`/`footer` 包成 `__ModuleLoader__.load` 闭包工厂
|
||||||
|
- `noExternal: (id) => (CLIENT_EXTERNALS.includes(id) ? undefined : true)` — 非 externals 全内联
|
||||||
|
|
||||||
|
**dynamic import 兼容性(最大实现风险)**:
|
||||||
|
|
||||||
|
Rollup 在 `format: 'cjs'` + `inlineDynamicImports: false` 时会拆 chunk,但 CJS chunk 在浏览器端无 `require`,动态导入链路可能断裂。需 spike 验证:
|
||||||
|
|
||||||
|
1. **首选**:保持 `format: 'cjs'`,让 rollup 拆 chunk,验证浏览器 `import()` 是否能加载子 chunk(rollup 会输出 `import()` 调用,浏览器原生支持 ESM dynamic import,但 CJS chunk 的 `module.exports` 形态需 wrapper)
|
||||||
|
2. **fallback A**:退回静态内联(放弃懒加载,接受 7MB bundle)
|
||||||
|
3. **fallback B**:Univer 单独打 IIFE bundle `lib/univer-bundle.js`,host 新增静态路由 `/sidebar/univer-bundle.js` serve,client 用 `<script>` 动态注入(破坏 host 零改动,最后手段)
|
||||||
|
|
||||||
|
**spike 必须是实现第一步**,结果决定后续构建配置。
|
||||||
|
|
||||||
|
### 5.3 CSS
|
||||||
|
|
||||||
|
Univer 自带样式,需确保:
|
||||||
|
- Univer 的 CSS 被 `dsh-css-inline` plugin 正确内联(走 `import '@univerjs/.../style.css'`)
|
||||||
|
- 或 Univer 的样式注入机制不与现有 `data-plugin-css` tag 冲突
|
||||||
|
|
||||||
|
docx-preview 的样式由 `className: 'docx'` 选项控制,可能需补 `src/client/office.module.css` 微调。
|
||||||
|
|
||||||
|
## 6. 测试
|
||||||
|
|
||||||
|
### 6.1 Unit(纯函数,加进 `tests/unit.spec.ts`)
|
||||||
|
|
||||||
|
- `officeKindForExt('.docx')` → `'docx'`
|
||||||
|
- `officeKindForExt('.xlsx')` → `'xlsx'`
|
||||||
|
- `officeKindForExt('.pptx')` → `'download-only'`
|
||||||
|
- `officeKindForExt('.doc')` → `'download-only'`
|
||||||
|
- `officeKindForExt('.txt')` → `null`
|
||||||
|
- `officeKindForExt('')` → `null`
|
||||||
|
|
||||||
|
### 6.2 xlsx → Univer 转换(若自写转换层)
|
||||||
|
|
||||||
|
- fixture xlsx → 解析 → 转换 → 断言 `IWorkbookData` 结构(sheet 名/单元格值/合并/公式)
|
||||||
|
- 用 `tests/fixtures/sample.xlsx` 作为输入
|
||||||
|
|
||||||
|
### 6.3 集成测试限制
|
||||||
|
|
||||||
|
- Univer 依赖 canvas API,jsdom 环境无法渲染 → 不测 Univer 渲染产物
|
||||||
|
- 只测加载逻辑:mock `fetch` 返回 ArrayBuffer,验证 loading → ready/error 状态转换
|
||||||
|
- docx-preview 同样依赖 DOM,jsdom 可跑 `renderAsync`(无 canvas 依赖)但渲染产物脆弱 → 只测"不抛错"
|
||||||
|
|
||||||
|
### 6.4 手动冒烟
|
||||||
|
|
||||||
|
- 真实 .docx(含图片/表格/页眉)→ 渲染保真度
|
||||||
|
- 真实 .xlsx(多 sheet/公式/条件格式)→ 渲染 + 公式计算
|
||||||
|
- .pptx → 下载按钮
|
||||||
|
- 加密 docx/xlsx → 错误提示 + 下载
|
||||||
|
- >20MB → 提示 + 下载
|
||||||
|
|
||||||
|
## 7. 国际化
|
||||||
|
|
||||||
|
`src/client/locales.ts` 新增 key:
|
||||||
|
|
||||||
|
- `downloadToView` — "下载查看" / "Download to view"
|
||||||
|
- `binaryNoPreview` — "此文件类型不支持预览" / "This file type cannot be previewed"
|
||||||
|
- `officeTooLarge` — "文件过大,无法预览" / "File too large to preview"
|
||||||
|
- `officeCorrupt` — "文件损坏或格式无效" / "File is corrupt or in an invalid format"
|
||||||
|
- `officeEncrypted` — "不支持加密文件" / "Encrypted files are not supported"
|
||||||
|
- `officeLoadFailed` — "Office 预览组件加载失败" / "Office preview component failed to load"
|
||||||
|
- `retry` — 复用现有 `terminalRetry` 或新增 `retry`
|
||||||
|
|
||||||
|
## 8. 文件改动清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `src/client/EditorView.tsx` | 加 docx/xlsx kind 分支 + 下载按钮 binary 占位 |
|
||||||
|
| `src/client/office-view.tsx`(新) | `DocxView` / `XlsxView` 组件 |
|
||||||
|
| `src/client/office-types.ts`(新) | 扩展名 → kind 纯函数 |
|
||||||
|
| `src/client/xlsx-to-univer.ts`(新,若自写转换) | SheetJS workbook → Univer IWorkbookData |
|
||||||
|
| `src/client/locales.ts` | 新文案 key |
|
||||||
|
| `src/client/sidebar.module.css` | `.editorBinary` / `.editorDownloadLink` 样式 |
|
||||||
|
| `package.json` | 加 docx-preview / @univerjs/* / xlsx 依赖 |
|
||||||
|
| `tsdown.config.ts` | dynamic import 配置(spike 后定) |
|
||||||
|
| `tests/unit.spec.ts` | officeKindForExt 单测 |
|
||||||
|
| `tests/fixtures/`(新) | sample.docx / sample.xlsx 测试夹具 |
|
||||||
|
|
||||||
|
## 9. 风险登记
|
||||||
|
|
||||||
|
| 风险 | 概率 | 影响 | 缓解 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| tsdown CJS 闭包不兼容 dynamic import | 中 | 高(阻塞懒加载) | spike 优先;fallback A 静态内联 / fallback B 独立 bundle |
|
||||||
|
| Univer 体积超预期(>5MB) | 低 | 中 | 评估 tree-shaking;裁剪非必要插件(如 chart) |
|
||||||
|
| xlsx → Univer 转换层复杂度爆表 | 中 | 中 | 优先用 `@univerjs/sheets-import-export` 官方包 |
|
||||||
|
| Univer canvas 在某些浏览器渲染异常 | 低 | 中 | 错误边界捕获 + 下载降级 |
|
||||||
|
| docx-preview 图片 base64 内存爆炸(大 docx) | 低 | 低 | 文件 size cap 已 20MB,可接受 |
|
||||||
|
| Univer dispose 不彻底导致内存泄漏 | 中 | 中 | 严格 cleanup + 手动验证 Tab 切换/卸载 |
|
||||||
|
|
||||||
|
## 10. 实现里程碑(粗略,待 writing-plans 细化)
|
||||||
|
|
||||||
|
1. **Spike**:验证 tsdown CJS + dynamic import 兼容性(决定构建策略)
|
||||||
|
2. **基础**:office-types.ts + EditorView 分发 + binary 下载按钮(最小可用,pptx/旧格式先受益)
|
||||||
|
3. **docx**:DocxView + docx-preview 集成 + 错误处理
|
||||||
|
4. **xlsx**:XlsxView + Univer 集成 + xlsx 导入(优先官方 importer)
|
||||||
|
5. **测试**:unit + fixture + 手动冒烟
|
||||||
|
6. **收尾**:CSS 打磨 + 国际化 + 文档更新(README 功能一览)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# PDF 原生预览设计
|
||||||
|
|
||||||
|
> 日期: 2026-08-10
|
||||||
|
> 状态: 已批准
|
||||||
|
> 范围: `dsh-better-sidebar` 的 `EditorView` 文件预览扩展
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为 `.pdf` 增加侧边栏内预览,同时保持零新增前端依赖和现有下载降级出口。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
- Client 按扩展名识别 `.pdf`,短路 `fsRead` 的二进制探测。
|
||||||
|
- `PdfView` 通过 `fetch(mediaUrl(scope, path))` 读取字节,创建显式
|
||||||
|
`application/pdf` Blob URL 后交给浏览器原生 `<iframe>`,避免旧 host
|
||||||
|
或代理缓存 `application/octet-stream` 时触发直接下载。
|
||||||
|
- Host media route 为 `.pdf` 返回 `application/pdf`。
|
||||||
|
- PDF 预览工具条始终提供“下载查看”链接。
|
||||||
|
- 不引入 PDF.js,不实现缩略图、文本搜索或自定义翻页工具栏。
|
||||||
|
|
||||||
|
## 错误与限制
|
||||||
|
|
||||||
|
- 是否可渲染取决于浏览器内置 PDF 查看器。
|
||||||
|
- 文件仍受现有 `mediaLimit` 限制。
|
||||||
|
- 浏览器原生 PDF iframe 对 HTTP 错误没有统一可观测事件;下载链接作为稳定降级出口。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- `.pdf` 扩展名识别为 PDF 预览。
|
||||||
|
- media type helper 对 `.pdf` 返回 `application/pdf`。
|
||||||
|
- Typecheck、bundle purity、manifest/plugin-shape 测试通过。
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# PPTX 高保真预览设计
|
||||||
|
|
||||||
|
> 日期: 2026-08-10
|
||||||
|
> 状态: 已批准
|
||||||
|
> 范围: `dsh-better-sidebar` 的 `.pptx` 浏览器预览
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
使用 `@aiden0z/pptx-renderer` 在侧边栏内预览 `.pptx`,保留图片、表格、
|
||||||
|
图表、SmartArt 与常见 OOXML 样式,同时保持下载降级出口。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
- `.pptx` 从 `download-only` 调整为独立 `pptx` 预览类型。
|
||||||
|
- `PptxView` 从现有 media route 获取 `ArrayBuffer`。
|
||||||
|
- 使用 `PptxViewer.open()` 渲染到库专属 DOM 容器。
|
||||||
|
- 开启 `lazyMedia`、`lazySlides` 与推荐 ZIP 安全限制。
|
||||||
|
- UI 提供上一页、下一页、页码和下载按钮。
|
||||||
|
- 卸载时销毁 viewer 并清空库专属容器。
|
||||||
|
- 旧 `.ppt/.doc/.xls` 继续仅下载。
|
||||||
|
|
||||||
|
## Bundle
|
||||||
|
|
||||||
|
- 先验证普通 bundler 入口不会遗留 Node builtin `require()`。
|
||||||
|
- 若普通入口不纯,切换包提供的 standalone browser ESM 入口。
|
||||||
|
- 不启用可选 PDF.js EMF fallback,避免额外体积。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- 损坏、加密或超限文件显示错误信息和下载链接。
|
||||||
|
- viewer 初始化失败时清理部分创建的实例与 DOM。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- `.pptx` 类型分发。
|
||||||
|
- bundle 模块表 require guard。
|
||||||
|
- Typecheck、manifest/plugin-shape 与构建通过。
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||||
"@codemirror/commands": "^6.10.4",
|
"@codemirror/commands": "^6.10.4",
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
@@ -87,11 +88,16 @@
|
|||||||
"@codemirror/state": "^6.7.1",
|
"@codemirror/state": "^6.7.1",
|
||||||
"@codemirror/view": "^6.43.8",
|
"@codemirror/view": "^6.43.8",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"@univerjs/preset-sheets-core": "^0.25.1",
|
||||||
|
"@univerjs/presets": "^0.25.1",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"docx-preview": "^0.4.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
"schemastery": "^3.18.0",
|
"schemastery": "^3.18.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+3421
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,16 @@ import { EditorState } from '@codemirror/state'
|
|||||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import { api, mediaUrl, type SessionScope } from './api.ts'
|
import { api, downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||||
import { languageForPath } from './lang.ts'
|
import { languageForPath } from './lang.ts'
|
||||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||||
import { t } from './locales.ts'
|
import { t } from './locales.ts'
|
||||||
|
import { officeKindForExt } from './office-types.ts'
|
||||||
|
import { DocxView, XlsxView } from './office-view.tsx'
|
||||||
|
import { isPdfExt } from './pdf-types.ts'
|
||||||
|
import { PdfView } from './PdfView.tsx'
|
||||||
|
import { PptxView } from './PptxView.tsx'
|
||||||
import css from './sidebar.module.css'
|
import css from './sidebar.module.css'
|
||||||
|
|
||||||
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']
|
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']
|
||||||
@@ -26,8 +31,8 @@ const MD_EXT = ['.md', '.markdown']
|
|||||||
type EditorLoad =
|
type EditorLoad =
|
||||||
| { status: 'loading' }
|
| { status: 'loading' }
|
||||||
| { status: 'error'; message: string }
|
| { status: 'error'; message: string }
|
||||||
| { status: 'ready'; kind: 'text' | 'image' | 'md'; content: string; truncated: boolean }
|
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx' | 'pptx' | 'pdf'; content: string; truncated: boolean }
|
||||||
| { status: 'binary' }
|
| { status: 'binary' } // unknown binary / OLE legacy formats → download button
|
||||||
|
|
||||||
/** Previewable files (rendered output vs source editing). */
|
/** Previewable files (rendered output vs source editing). */
|
||||||
type ViewMode = 'preview' | 'edit'
|
type ViewMode = 'preview' | 'edit'
|
||||||
@@ -57,9 +62,26 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
|||||||
setDraft(null)
|
setDraft(null)
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
setSaveState('idle')
|
setSaveState('idle')
|
||||||
|
// Office preview short-circuit: the host's readText would NUL-probe every
|
||||||
|
// Office file as binary (a wasted round-trip), and .docx/.xlsx have their
|
||||||
|
// own renderers below. Detect by extension and skip fsRead entirely —
|
||||||
|
// the renderer fetches bytes through the media route itself.
|
||||||
|
const ext = extOfPath(path)
|
||||||
|
if (isPdfExt(ext)) {
|
||||||
|
setLoad({ status: 'ready', kind: 'pdf', content: '', truncated: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const officeKind = officeKindForExt(ext)
|
||||||
|
if (officeKind === 'docx' || officeKind === 'xlsx' || officeKind === 'pptx') {
|
||||||
|
setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (officeKind === 'download-only') {
|
||||||
|
setLoad({ status: 'binary' })
|
||||||
|
return
|
||||||
|
}
|
||||||
api.fsRead(scope, path).then((result) => {
|
api.fsRead(scope, path).then((result) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const ext = extOfPath(path)
|
|
||||||
if (result.kind === 'binary') {
|
if (result.kind === 'binary') {
|
||||||
setLoad({ status: 'binary' })
|
setLoad({ status: 'binary' })
|
||||||
return
|
return
|
||||||
@@ -207,7 +229,14 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
|||||||
</div>
|
</div>
|
||||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||||
{load.status === 'binary' && <div className={css.editorPlaceholder}>{t('binary')}</div>}
|
{load.status === 'binary' && (
|
||||||
|
<div className={css.editorBinary}>
|
||||||
|
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||||
|
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||||
|
{t('downloadToView')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{editable && (
|
{editable && (
|
||||||
<>
|
<>
|
||||||
{load.truncated && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
{load.truncated && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||||
@@ -222,6 +251,18 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
|||||||
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
|
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{load.status === 'ready' && load.kind === 'docx' && (
|
||||||
|
<DocxView scope={scope} path={path} title={title} />
|
||||||
|
)}
|
||||||
|
{load.status === 'ready' && load.kind === 'xlsx' && (
|
||||||
|
<XlsxView scope={scope} path={path} title={title} />
|
||||||
|
)}
|
||||||
|
{load.status === 'ready' && load.kind === 'pptx' && (
|
||||||
|
<PptxView scope={scope} path={path} title={title} />
|
||||||
|
)}
|
||||||
|
{load.status === 'ready' && load.kind === 'pdf' && (
|
||||||
|
<PdfView scope={scope} path={path} title={title} />
|
||||||
|
)}
|
||||||
{previewable && mode === 'preview' && (
|
{previewable && mode === 'preview' && (
|
||||||
<div className={css.editorMd}>
|
<div className={css.editorMd}>
|
||||||
<MarkdownText text={draft ?? load.content} />
|
<MarkdownText text={draft ?? load.content} />
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/** Browser-native PDF preview with an always-available download fallback. */
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||||
|
import { t } from './locales.ts'
|
||||||
|
import css from './sidebar.module.css'
|
||||||
|
|
||||||
|
export function PdfView(props: { scope: SessionScope; path: string; title: string }) {
|
||||||
|
const { scope, path, title } = props
|
||||||
|
const [load, setLoad] = useState<
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'ready'; url: string }
|
||||||
|
| { status: 'error'; message: string }
|
||||||
|
>({ status: 'loading' })
|
||||||
|
const [interactionBlocked, setInteractionBlocked] = useState(false)
|
||||||
|
const frameRef = useRef<HTMLIFrameElement>(null)
|
||||||
|
const shieldRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
let objectUrl: string | undefined
|
||||||
|
setLoad({ status: 'loading' })
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||||
|
const bytes = await response.arrayBuffer()
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
// A direct iframe navigation may download when an old host process or
|
||||||
|
// proxy cached application/octet-stream. The Blob URL owns an explicit
|
||||||
|
// PDF MIME and therefore consistently opens the browser PDF viewer.
|
||||||
|
objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
|
||||||
|
setLoad({ status: 'ready', url: objectUrl })
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
controller.abort()
|
||||||
|
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
|
||||||
|
}
|
||||||
|
}, [scope.sessionId, scope.cwd, path])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const block = (): void => {
|
||||||
|
setInteractionBlocked(true)
|
||||||
|
if (frameRef.current !== null) frameRef.current.style.pointerEvents = 'none'
|
||||||
|
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'auto'
|
||||||
|
}
|
||||||
|
const unblock = (): void => {
|
||||||
|
setInteractionBlocked(false)
|
||||||
|
if (frameRef.current !== null) frameRef.current.style.pointerEvents = ''
|
||||||
|
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'none'
|
||||||
|
}
|
||||||
|
const blockForResize = (event: PointerEvent): void => {
|
||||||
|
const target = event.target
|
||||||
|
if (target instanceof Element
|
||||||
|
&& target.closest(`.${css.panelResize}, .${css.divider}`) !== null) {
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An iframe is a separate browsing context and otherwise swallows the
|
||||||
|
// dragover/pointer stream as soon as the cursor crosses it. Temporarily
|
||||||
|
// remove it from hit-testing while a tab drag or pane resize is active.
|
||||||
|
document.addEventListener('dragstart', block, true)
|
||||||
|
document.addEventListener('dragend', unblock, true)
|
||||||
|
document.addEventListener('drop', unblock, true)
|
||||||
|
window.addEventListener('pointerdown', blockForResize, true)
|
||||||
|
window.addEventListener('pointerup', unblock, true)
|
||||||
|
window.addEventListener('pointercancel', unblock, true)
|
||||||
|
window.addEventListener('blur', unblock)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('dragstart', block, true)
|
||||||
|
document.removeEventListener('dragend', unblock, true)
|
||||||
|
document.removeEventListener('drop', unblock, true)
|
||||||
|
window.removeEventListener('pointerdown', blockForResize, true)
|
||||||
|
window.removeEventListener('pointerup', unblock, true)
|
||||||
|
window.removeEventListener('pointercancel', unblock, true)
|
||||||
|
window.removeEventListener('blur', unblock)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.editorPdf}>
|
||||||
|
<div className={css.editorPdfToolbar}>
|
||||||
|
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||||
|
{t('downloadToView')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className={css.editorPdfStage}>
|
||||||
|
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||||
|
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||||
|
{load.status === 'ready' && (
|
||||||
|
<iframe
|
||||||
|
ref={frameRef}
|
||||||
|
className={clsx(css.editorPdfFrame, interactionBlocked && css.editorPdfFrameBlocked)}
|
||||||
|
src={load.url}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={shieldRef}
|
||||||
|
className={clsx(css.editorPdfDragShield, interactionBlocked && css.editorPdfDragShieldActive)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/** High-fidelity browser-native PPTX preview with slide navigation. */
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||||
|
import { t } from './locales.ts'
|
||||||
|
import css from './sidebar.module.css'
|
||||||
|
|
||||||
|
type PptxViewerInstance = import('@aiden0z/pptx-renderer').PptxViewer
|
||||||
|
|
||||||
|
type LoadState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'ready'; count: number }
|
||||||
|
| { status: 'error'; message: string }
|
||||||
|
|
||||||
|
export function PptxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||||
|
const { scope, path, title } = props
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
|
const viewerRef = useRef<PptxViewerInstance | null>(null)
|
||||||
|
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||||
|
const [slide, setSlide] = useState(0)
|
||||||
|
const [navigating, setNavigating] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const host = hostRef.current
|
||||||
|
if (host === null) return
|
||||||
|
setLoad({ status: 'loading' })
|
||||||
|
setSlide(0)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||||
|
const bytes = await response.arrayBuffer()
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
const { PptxViewer, RECOMMENDED_ZIP_LIMITS } = await import('@aiden0z/pptx-renderer')
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
const viewer = await PptxViewer.open(bytes, host, {
|
||||||
|
renderMode: 'slide',
|
||||||
|
fitMode: 'contain',
|
||||||
|
lazyMedia: true,
|
||||||
|
lazySlides: true,
|
||||||
|
pdfjs: false,
|
||||||
|
signal: controller.signal,
|
||||||
|
zipLimits: RECOMMENDED_ZIP_LIMITS,
|
||||||
|
onSlideChange: (index) => {
|
||||||
|
if (!controller.signal.aborted) setSlide(index)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
viewer.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewerRef.current = viewer
|
||||||
|
setSlide(viewer.currentSlideIndex)
|
||||||
|
setLoad({ status: 'ready', count: viewer.slideCount })
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
try {
|
||||||
|
viewerRef.current?.destroy()
|
||||||
|
} catch {
|
||||||
|
// Partially initialized viewer — ignore disposal errors.
|
||||||
|
}
|
||||||
|
viewerRef.current = null
|
||||||
|
host.innerHTML = ''
|
||||||
|
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
controller.abort()
|
||||||
|
try {
|
||||||
|
viewerRef.current?.destroy()
|
||||||
|
} catch {
|
||||||
|
// Already destroyed.
|
||||||
|
}
|
||||||
|
viewerRef.current = null
|
||||||
|
host.innerHTML = ''
|
||||||
|
}
|
||||||
|
}, [scope.sessionId, scope.cwd, path])
|
||||||
|
|
||||||
|
const navigate = (target: number): void => {
|
||||||
|
const viewer = viewerRef.current
|
||||||
|
if (viewer === null || load.status !== 'ready' || navigating) return
|
||||||
|
const next = Math.max(0, Math.min(load.count - 1, target))
|
||||||
|
if (next === slide) return
|
||||||
|
setNavigating(true)
|
||||||
|
void viewer.goToSlide(next, { behavior: 'auto' }).then(() => {
|
||||||
|
setSlide(viewer.currentSlideIndex)
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||||
|
}).finally(() => {
|
||||||
|
setNavigating(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.editorPptx} aria-label={title}>
|
||||||
|
<div className={css.editorPptxToolbar}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.editorPptxButton}
|
||||||
|
disabled={load.status !== 'ready' || slide <= 0 || navigating}
|
||||||
|
onClick={() => { navigate(slide - 1) }}
|
||||||
|
>
|
||||||
|
{t('previousSlide')}
|
||||||
|
</button>
|
||||||
|
<span className={css.editorPptxPosition}>
|
||||||
|
{load.status === 'ready' ? `${slide + 1} / ${load.count}` : '– / –'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.editorPptxButton}
|
||||||
|
disabled={load.status !== 'ready' || slide >= load.count - 1 || navigating}
|
||||||
|
onClick={() => { navigate(slide + 1) }}
|
||||||
|
>
|
||||||
|
{t('nextSlide')}
|
||||||
|
</button>
|
||||||
|
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||||
|
{t('downloadToView')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className={css.editorPptxStage}>
|
||||||
|
<div className={css.editorPptxHost} ref={hostRef} />
|
||||||
|
{load.status !== 'ready' && (
|
||||||
|
<div className={css.editorOfficeOverlay}>
|
||||||
|
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||||
|
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+23
-1
@@ -5,7 +5,7 @@
|
|||||||
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
|
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
|
||||||
* dropping on the strip background appends to this pane.
|
* dropping on the strip background appends to this pane.
|
||||||
*/
|
*/
|
||||||
import { useState, type ReactNode } from 'react'
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
IconCloseFill14, IconPlusOutline16, Menu,
|
IconCloseFill14, IconPlusOutline16, Menu,
|
||||||
@@ -46,6 +46,12 @@ export function parseDrag(raw: string): TabDragPayload | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Global tab-drag flag: PDF iframes become non-interactive synchronously. */
|
||||||
|
function setTabDragging(active: boolean): void {
|
||||||
|
if (active) document.body.setAttribute('data-dsh-tab-dragging', '')
|
||||||
|
else document.body.removeAttribute('data-dsh-tab-dragging')
|
||||||
|
}
|
||||||
|
|
||||||
export function TabBar(props: {
|
export function TabBar(props: {
|
||||||
paneId: string
|
paneId: string
|
||||||
tabs: SidebarTab[]
|
tabs: SidebarTab[]
|
||||||
@@ -63,6 +69,18 @@ export function TabBar(props: {
|
|||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clear = (): void => { setTabDragging(false); setDragOver(false) }
|
||||||
|
window.addEventListener('dragend', clear, true)
|
||||||
|
window.addEventListener('drop', clear, true)
|
||||||
|
window.addEventListener('blur', clear)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('dragend', clear, true)
|
||||||
|
window.removeEventListener('drop', clear, true)
|
||||||
|
window.removeEventListener('blur', clear)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
|
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
|
||||||
@@ -79,6 +97,7 @@ export function TabBar(props: {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
setDragOver(false)
|
setDragOver(false)
|
||||||
|
setTabDragging(false)
|
||||||
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
||||||
const payload = parseDrag(raw)
|
const payload = parseDrag(raw)
|
||||||
if (payload !== null) onDropTab(payload, null)
|
if (payload !== null) onDropTab(payload, null)
|
||||||
@@ -92,13 +111,16 @@ export function TabBar(props: {
|
|||||||
title={tab.title}
|
title={tab.title}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
|
setTabDragging(true)
|
||||||
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
|
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
}}
|
}}
|
||||||
|
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
|
||||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
|
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
setTabDragging(false)
|
||||||
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
||||||
const payload = parseDrag(raw)
|
const payload = parseDrag(raw)
|
||||||
if (payload !== null) onDropTab(payload, tab.id)
|
if (payload !== null) onDropTab(payload, tab.id)
|
||||||
|
|||||||
@@ -78,6 +78,16 @@ const zh = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsSaveFailed: '保存失败',
|
settingsSaveFailed: '保存失败',
|
||||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||||
|
binaryNoPreview: '此文件类型不支持预览',
|
||||||
|
downloadToView: '下载查看',
|
||||||
|
officeTooLarge: '文件过大,无法预览',
|
||||||
|
officeCorrupt: '文件损坏或格式无效',
|
||||||
|
officeEncrypted: '不支持加密文件',
|
||||||
|
officeLoadFailed: 'Office 预览组件加载失败',
|
||||||
|
previousSlide: '上一页',
|
||||||
|
nextSlide: '下一页',
|
||||||
|
zoom: '缩放',
|
||||||
|
zoomHint: 'Alt + 滚轮',
|
||||||
}
|
}
|
||||||
|
|
||||||
const en: Record<keyof typeof zh, string> = {
|
const en: Record<keyof typeof zh, string> = {
|
||||||
@@ -153,6 +163,16 @@ const en: Record<keyof typeof zh, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsSaveFailed: 'Failed to save',
|
settingsSaveFailed: 'Failed to save',
|
||||||
settingsConflict: 'The setting changed in another window — please retry',
|
settingsConflict: 'The setting changed in another window — please retry',
|
||||||
|
binaryNoPreview: 'This file type cannot be previewed',
|
||||||
|
downloadToView: 'Download to view',
|
||||||
|
officeTooLarge: 'File too large to preview',
|
||||||
|
officeCorrupt: 'File is corrupt or in an invalid format',
|
||||||
|
officeEncrypted: 'Encrypted files are not supported',
|
||||||
|
officeLoadFailed: 'Office preview component failed to load',
|
||||||
|
previousSlide: 'Previous',
|
||||||
|
nextSlide: 'Next',
|
||||||
|
zoom: 'Zoom',
|
||||||
|
zoomHint: 'Alt + wheel',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Translate a copy key in the browser's language (zh → zh, else en). */
|
/** Translate a copy key in the browser's language (zh → zh, else en). */
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Office file type dispatch — pure functions mapping file extensions to
|
||||||
|
* preview kinds. Kept separate from {@link EditorView} so the mapping is
|
||||||
|
* unit-testable without mounting React or pulling in docx-preview / Univer.
|
||||||
|
*
|
||||||
|
* The host side never inspects Office extensions: the binary NUL probe in
|
||||||
|
* {@link ../index.ts} (readText) tags every Office file as binary, and the
|
||||||
|
* /sidebar/file media route serves raw bytes for any extension. This module
|
||||||
|
* is the client's sole source of truth for "is this previewable here".
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Extensions rendered with docx-preview (preserved styles/images/tables). */
|
||||||
|
export const OFFICE_DOCX_EXT = '.docx' as const
|
||||||
|
|
||||||
|
/** Extensions rendered with Univer (sheets: data + formulas + formatting). */
|
||||||
|
export const OFFICE_XLSX_EXT = '.xlsx' as const
|
||||||
|
|
||||||
|
/** Extensions rendered with the browser-native PPTX viewer. */
|
||||||
|
export const OFFICE_PPTX_EXT = '.pptx' as const
|
||||||
|
|
||||||
|
/** Extensions that get a download-only affordance (no client-side renderer). */
|
||||||
|
export const OFFICE_DOWNLOAD_ONLY_EXT = ['.doc', '.xls', '.ppt'] as readonly string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview strategy for one path's extension.
|
||||||
|
* - `'docx'` — render via docx-preview.
|
||||||
|
* - `'xlsx'` — render via Univer.
|
||||||
|
* - `'pptx'` — render via pptx-renderer.
|
||||||
|
* - `'download-only'` — show the binary download button (no preview renderer
|
||||||
|
* covers legacy OLE .doc/.xls/.ppt on the client).
|
||||||
|
* - `null` — not an Office file; the caller's existing image / md / text / binary
|
||||||
|
* pipeline handles it.
|
||||||
|
*/
|
||||||
|
export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a lowercased extension (with leading dot) to its preview kind.
|
||||||
|
*
|
||||||
|
* @param ext - the path extension from {@link EditorView.extOfPath} (`.docx`,
|
||||||
|
* `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none.
|
||||||
|
*/
|
||||||
|
export function officeKindForExt(ext: string): OfficeKind {
|
||||||
|
if (ext === OFFICE_DOCX_EXT) return 'docx'
|
||||||
|
if (ext === OFFICE_XLSX_EXT) return 'xlsx'
|
||||||
|
if (ext === OFFICE_PPTX_EXT) return 'pptx'
|
||||||
|
if (OFFICE_DOWNLOAD_ONLY_EXT.includes(ext)) return 'download-only'
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Office preview components: {@link DocxView} renders .docx via docx-preview
|
||||||
|
* (preserved styles/images/tables), {@link XlsxView} renders .xlsx via the
|
||||||
|
* Univer sheets preset (data + formulas + formatting). Both load the file
|
||||||
|
* bytes through the existing /sidebar/file media route and own their library
|
||||||
|
* lifecycle (dispose on unmount so canvases/workers don't leak — mirrors the
|
||||||
|
* TerminalView dispose discipline).
|
||||||
|
*
|
||||||
|
* Errors degrade to the same download-button affordance the binary
|
||||||
|
* placeholder uses, so a corrupted / encrypted / oversized file always
|
||||||
|
* leaves the user with a way to get the file.
|
||||||
|
*
|
||||||
|
* Both libraries pull in several MB; tsdown's `codeSplitting: false` inlines
|
||||||
|
* them into the single client bundle (the CJS __ModuleLoader__ wrapper's
|
||||||
|
* `require` cannot load relative chunk URLs in the browser, so dynamic
|
||||||
|
* import() collapses to a synchronous inline — see tsdown.config.ts).
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||||
|
import { t } from './locales.ts'
|
||||||
|
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
|
||||||
|
import css from './sidebar.module.css'
|
||||||
|
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
|
||||||
|
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
|
||||||
|
// are present before the first .xlsx opens.
|
||||||
|
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||||
|
|
||||||
|
/** Loading / ready / error state shared by both views. */
|
||||||
|
type LoadState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'ready' }
|
||||||
|
| { status: 'error'; message: string }
|
||||||
|
|
||||||
|
/** Shared props. */
|
||||||
|
interface OfficeViewProps {
|
||||||
|
scope: SessionScope
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a .docx file via docx-preview. The library renders into a container
|
||||||
|
* div (no canvas); images and styles are inlined. Unmounting clears the
|
||||||
|
* container's innerHTML — docx-preview has no dispose API, but tearing down
|
||||||
|
* the DOM is enough.
|
||||||
|
*/
|
||||||
|
export function DocxView(props: OfficeViewProps): JSX.Element {
|
||||||
|
const { scope, path, title } = props
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||||
|
const [zoom, setZoom] = useState(100)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const container = viewportRef.current
|
||||||
|
const wrap = wrapRef.current
|
||||||
|
if (container === null || wrap === null) return
|
||||||
|
setZoom(100)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(mediaUrl(scope, path))
|
||||||
|
if (cancelled) return
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
const buf = await response.arrayBuffer()
|
||||||
|
if (cancelled) return
|
||||||
|
// docx-preview ships its own CSS through the className option; the
|
||||||
|
// wrapper div scopes its render output.
|
||||||
|
const { renderAsync } = await import('docx-preview')
|
||||||
|
await renderAsync(buf, wrap, undefined, {
|
||||||
|
className: 'docx',
|
||||||
|
inWrapper: true,
|
||||||
|
ignoreWidth: false,
|
||||||
|
ignoreHeight: false,
|
||||||
|
breakPages: true,
|
||||||
|
experimental: false,
|
||||||
|
})
|
||||||
|
if (!cancelled) setLoad({ status: 'ready' })
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
// Tear down the rendered DOM so a reopen starts clean.
|
||||||
|
if (wrap !== null) wrap.innerHTML = ''
|
||||||
|
}
|
||||||
|
}, [scope.sessionId, scope.cwd, path])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewport = viewportRef.current
|
||||||
|
if (viewport === null) return
|
||||||
|
const onWheel = (event: WheelEvent): void => {
|
||||||
|
if (!event.altKey) return
|
||||||
|
event.preventDefault()
|
||||||
|
const delta = event.deltaY < 0 ? 10 : -10
|
||||||
|
setZoom(current => Math.max(50, Math.min(200, current + delta)))
|
||||||
|
}
|
||||||
|
viewport.addEventListener('wheel', onWheel, { passive: false })
|
||||||
|
return () => { viewport.removeEventListener('wheel', onWheel) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.editorDocx}>
|
||||||
|
<div className={css.editorDocxViewport} ref={viewportRef}>
|
||||||
|
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||||
|
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||||
|
{load.status !== 'error' && (
|
||||||
|
<div
|
||||||
|
className={css.editorDocxWrap}
|
||||||
|
ref={wrapRef}
|
||||||
|
aria-label={title}
|
||||||
|
style={{ zoom: zoom / 100 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={css.editorDocxZoom}>
|
||||||
|
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
|
||||||
|
<input
|
||||||
|
className={css.editorDocxZoomRange}
|
||||||
|
type="range"
|
||||||
|
min={50}
|
||||||
|
max={200}
|
||||||
|
step={10}
|
||||||
|
value={zoom}
|
||||||
|
aria-label={t('zoom')}
|
||||||
|
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
|
||||||
|
/>
|
||||||
|
<span className={css.editorDocxZoomValue}>{zoom}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
|
||||||
|
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
|
||||||
|
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
|
||||||
|
* — without it the canvas, workers, and DOM listeners leak (mirrors the
|
||||||
|
* xterm dispose discipline in TerminalView).
|
||||||
|
*/
|
||||||
|
export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||||
|
const { scope, path, title } = props
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
|
const univerRef = useRef<{ dispose: () => void } | null>(null)
|
||||||
|
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const host = hostRef.current
|
||||||
|
if (host === null) return
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(mediaUrl(scope, path))
|
||||||
|
if (cancelled) return
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
const buf = await response.arrayBuffer()
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
// Dynamic imports — collapsed into the bundle by codeSplitting:false.
|
||||||
|
// The dynamic form keeps the source readable: each lib is only pulled
|
||||||
|
// in when an .xlsx is actually opened (semantically; the bundle still
|
||||||
|
// contains all of them).
|
||||||
|
const XLSX = await import('xlsx')
|
||||||
|
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
|
||||||
|
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
|
||||||
|
// Locales pick the browser language; falls back to en-US.
|
||||||
|
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
|
||||||
|
const localePack = await (isZh
|
||||||
|
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
|
||||||
|
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
|
||||||
|
|
||||||
|
const wb = XLSX.read(buf, { type: 'array' })
|
||||||
|
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||||
|
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
const { univer, univerAPI } = createUniver({
|
||||||
|
locale,
|
||||||
|
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
|
||||||
|
presets: [UniverSheetsCorePreset({ container: host })],
|
||||||
|
})
|
||||||
|
univerRef.current = univer
|
||||||
|
univerAPI.createWorkbook(workbookData)
|
||||||
|
if (!cancelled) setLoad({ status: 'ready' })
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
try {
|
||||||
|
univerRef.current?.dispose()
|
||||||
|
} catch {
|
||||||
|
// Partially initialized instance — ignore disposal errors.
|
||||||
|
}
|
||||||
|
univerRef.current = null
|
||||||
|
host.innerHTML = ''
|
||||||
|
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
// Critical: dispose the Univer instance (canvas + workers + listeners).
|
||||||
|
try {
|
||||||
|
univerRef.current?.dispose()
|
||||||
|
} catch {
|
||||||
|
// Already torn down — ignore.
|
||||||
|
}
|
||||||
|
univerRef.current = null
|
||||||
|
// Clear the host in case dispose left DOM behind.
|
||||||
|
if (host !== null) host.innerHTML = ''
|
||||||
|
}
|
||||||
|
}, [scope.sessionId, scope.cwd, path])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.editorXlsx} aria-label={title}>
|
||||||
|
{/* Univer exclusively owns this element's descendants. React only owns
|
||||||
|
the sibling overlay, so Univer cannot remove a React child. */}
|
||||||
|
<div className={css.editorUniverHost} ref={hostRef} />
|
||||||
|
{load.status !== 'ready' && (
|
||||||
|
<div className={css.editorOfficeOverlay}>
|
||||||
|
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||||
|
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared error / fallback affordance: the failure reason plus a download
|
||||||
|
* link, so the user always has a path to the file. Used by both DocxView and
|
||||||
|
* XlsxView, and matches the binary placeholder's download link in EditorView.
|
||||||
|
*/
|
||||||
|
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
|
||||||
|
const { scope, path, message } = props
|
||||||
|
return (
|
||||||
|
<div className={css.editorBinary}>
|
||||||
|
<span className={css.editorBinaryNotice}>{message}</span>
|
||||||
|
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||||
|
{t('downloadToView')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Pure PDF extension dispatch, separate from React for unit testing. */
|
||||||
|
export function isPdfExt(ext: string): boolean {
|
||||||
|
return ext === '.pdf'
|
||||||
|
}
|
||||||
@@ -639,6 +639,46 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Binary / download-only files (pptx, OLE .doc/.xls/.ppt, unknown binaries):
|
||||||
|
a centered notice plus a download link — the user always gets the file. */
|
||||||
|
.editorBinary {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorBinaryNotice {
|
||||||
|
font: var(--dsw-font-xxs-12);
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorDownloadLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--dsw-alias-bg-layer-2);
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
font: var(--dsw-font-xxs-strong-12);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||||
|
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorDownloadLink:hover {
|
||||||
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
|
border-color: var(--dsw-alias-border-l2);
|
||||||
|
}
|
||||||
|
|
||||||
.editorError {
|
.editorError {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font: var(--dsw-font-xxs-12);
|
font: var(--dsw-font-xxs-12);
|
||||||
@@ -729,6 +769,208 @@
|
|||||||
font: var(--dsw-font-xs-13);
|
font: var(--dsw-font-xs-13);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Word preview: the document scrolls independently while the zoom control
|
||||||
|
stays pinned to the bottom edge. */
|
||||||
|
.editorDocx {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--dsw-alias-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorDocxViewport {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--dsw-alias-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* docx-preview renders into a wrapper div with .docx className; the host
|
||||||
|
above scrolls while the wrapper holds the page flow. */
|
||||||
|
.editorDocxWrap {
|
||||||
|
flex: none;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorDocxZoom {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-top: 1px solid var(--dsw-alias-border-l1);
|
||||||
|
background: var(--dsw-alias-bg-layer-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorDocxZoomHint,
|
||||||
|
.editorDocxZoomValue {
|
||||||
|
flex: none;
|
||||||
|
font: var(--dsw-font-xxxs-11);
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorDocxZoomValue {
|
||||||
|
width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorDocxZoomRange {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 72px;
|
||||||
|
accent-color: var(--dsw-alias-brand-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPdf {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--dsw-alias-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPdfToolbar {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPdfStage {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPdfFrame {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: var(--dsw-alias-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPdfFrameBlocked {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPdfDragShield {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPdfDragShieldActive {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab drag must switch iframe hit-testing before the pointer can enter the
|
||||||
|
Chromium PDF browsing context. The body flag is set synchronously by the
|
||||||
|
drag source, avoiding React render timing entirely. */
|
||||||
|
:global(body[data-dsh-tab-dragging]) .editorPdfFrame {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body[data-dsh-tab-dragging]) .editorPdfDragShield {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorXlsx {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--dsw-alias-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorUniverHost {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorOfficeOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
background: var(--dsw-alias-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPptx {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--dsw-alias-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPptxToolbar {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPptxToolbar .editorDownloadLink {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPptxButton {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--dsw-alias-bg-layer-2);
|
||||||
|
color: var(--dsw-alias-label-secondary);
|
||||||
|
font: var(--dsw-font-xxs-strong-12);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPptxButton:hover:not(:disabled) {
|
||||||
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPptxButton:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPptxPosition {
|
||||||
|
min-width: 64px;
|
||||||
|
text-align: center;
|
||||||
|
font: var(--dsw-font-xxs-12);
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPptxStage {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPptxHost {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Terminal ──────────────────────────────────────────────────────────── */
|
/* ── Terminal ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.terminalWrap {
|
.terminalWrap {
|
||||||
@@ -1066,7 +1308,10 @@
|
|||||||
.gitLink:focus-visible,
|
.gitLink:focus-visible,
|
||||||
.gitCommitButton:focus-visible,
|
.gitCommitButton:focus-visible,
|
||||||
.terminalRetry:focus-visible,
|
.terminalRetry:focus-visible,
|
||||||
.editorModeButton:focus-visible {
|
.editorModeButton:focus-visible,
|
||||||
|
.editorDownloadLink:focus-visible,
|
||||||
|
.editorPptxButton:focus-visible,
|
||||||
|
.editorDocxZoomRange:focus-visible {
|
||||||
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
|
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* that merges the tab into the pane. The tree and all operations live in
|
* that merges the tab into the pane. The tree and all operations live in
|
||||||
* state.ts; this file is pure presentation over them.
|
* state.ts; this file is pure presentation over them.
|
||||||
*/
|
*/
|
||||||
import { Fragment, useRef, useState } from 'react'
|
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
||||||
@@ -127,6 +127,19 @@ function LeafView(props: {
|
|||||||
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
|
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
|
||||||
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
||||||
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clear = (): void => { setDropZone(null) }
|
||||||
|
window.addEventListener('dragend', clear, true)
|
||||||
|
window.addEventListener('drop', clear, true)
|
||||||
|
window.addEventListener('blur', clear)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('dragend', clear, true)
|
||||||
|
window.removeEventListener('drop', clear, true)
|
||||||
|
window.removeEventListener('blur', clear)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
|
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Convert a SheetJS (xlsx) workbook into Univer's {@link IWorkbookData} so
|
||||||
|
* {@link XlsxView} can render it through the Univer sheets preset.
|
||||||
|
*
|
||||||
|
* v1 scope (matches docs/plans/2026-08-10-office-preview-design.md §3.6):
|
||||||
|
* - Sheet names + order
|
||||||
|
* - Cell values, typed (string / number / boolean / error → force-string)
|
||||||
|
* - Cell formulas (text form; Univer's formula engine computes them)
|
||||||
|
* - Merged cells (!merges → mergeData)
|
||||||
|
* - Column widths (!cols → columnData) and row heights (!rows → rowData)
|
||||||
|
*
|
||||||
|
* Out of scope for v1 (SheetJS community edition does not parse them and the
|
||||||
|
* Pro edition is commercial): cell styles (font/fill/border/alignment),
|
||||||
|
* conditional formatting, charts. Users who need style fidelity get the
|
||||||
|
* download button on the binary placeholder.
|
||||||
|
*
|
||||||
|
* The function is pure (no DOM, no Univer imports) so it is unit-testable
|
||||||
|
* without jsdom or canvas.
|
||||||
|
*/
|
||||||
|
import type * as XLSX from 'xlsx'
|
||||||
|
// Types come from @univerjs/presets (which re-exports @univerjs/core's type
|
||||||
|
// surface). The enum values BooleanNumber / CellValueType are imported as
|
||||||
|
// runtime values from the same module — the bundler inlines them, and the
|
||||||
|
// test environment has @univerjs/presets available as a dependency.
|
||||||
|
import { BooleanNumber, CellValueType, type LocaleType, type ICellData, type IColumnData, type IRowData, type IWorkbookData, type IWorksheetData, type IObjectArrayPrimitiveType, type IObjectMatrixPrimitiveType, type IRange } from '@univerjs/presets'
|
||||||
|
|
||||||
|
/** SheetJS workbook (the slice of the module we use). */
|
||||||
|
type XLSXWorkbook = XLSX.WorkBook
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Univer workbook snapshot from a SheetJS workbook.
|
||||||
|
*
|
||||||
|
* @param wb - the parsed workbook from `XLSX.read(buf, { type: 'array' })`
|
||||||
|
* @param appVersion - the Univer package version, written into `appVersion`
|
||||||
|
* (Univer requires the field but does not gate on it).
|
||||||
|
* @param locale - the locale Univer should render in.
|
||||||
|
*/
|
||||||
|
export function xlsxWorkbookToUniver(
|
||||||
|
wb: XLSXWorkbook,
|
||||||
|
appVersion: string,
|
||||||
|
locale: LocaleType,
|
||||||
|
): IWorkbookData {
|
||||||
|
const sheetOrder: string[] = []
|
||||||
|
const sheets: Record<string, Partial<IWorksheetData>> = {}
|
||||||
|
|
||||||
|
wb.SheetNames.forEach((name, index) => {
|
||||||
|
const sheetId = `sheet-${index}`
|
||||||
|
sheetOrder.push(sheetId)
|
||||||
|
sheets[sheetId] = worksheetToUniver(wb.Sheets[name] ?? {}, name, sheetId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Univer requires at least one sheet; an empty workbook gets a placeholder.
|
||||||
|
if (sheetOrder.length === 0) {
|
||||||
|
const fallbackId = 'sheet-0'
|
||||||
|
sheetOrder.push(fallbackId)
|
||||||
|
sheets[fallbackId] = {
|
||||||
|
id: fallbackId,
|
||||||
|
name: 'Sheet1',
|
||||||
|
rowCount: 20,
|
||||||
|
columnCount: 26,
|
||||||
|
cellData: {},
|
||||||
|
rowData: {},
|
||||||
|
columnData: {},
|
||||||
|
mergeData: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'workbook',
|
||||||
|
name: wb.Props?.Title || 'Workbook',
|
||||||
|
appVersion,
|
||||||
|
locale,
|
||||||
|
styles: {},
|
||||||
|
sheetOrder,
|
||||||
|
sheets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert one SheetJS worksheet to a Univer IWorksheetData. */
|
||||||
|
function worksheetToUniver(
|
||||||
|
ws: XLSX.WorkSheet,
|
||||||
|
name: string,
|
||||||
|
sheetId: string,
|
||||||
|
): Partial<IWorksheetData> {
|
||||||
|
const cellData: IObjectMatrixPrimitiveType<ICellData> = {}
|
||||||
|
const merges: IRange[] = []
|
||||||
|
|
||||||
|
// Cell values + formulas: iterate every cell address (skip the !-prefixed
|
||||||
|
// special keys SheetJS uses for sheet-level metadata).
|
||||||
|
for (const addr of Object.keys(ws)) {
|
||||||
|
if (addr.startsWith('!')) continue
|
||||||
|
const cell = ws[addr] as XLSX.CellObject | undefined
|
||||||
|
if (cell === undefined) continue
|
||||||
|
const { r, c } = decodeAddr(addr)
|
||||||
|
const converted = convertCell(cell)
|
||||||
|
if (converted === null) continue
|
||||||
|
let row = cellData[r]
|
||||||
|
if (row === undefined) {
|
||||||
|
row = {}
|
||||||
|
cellData[r] = row
|
||||||
|
}
|
||||||
|
row[c] = converted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merged cells: SheetJS's Range uses { s: { r, c }, e: { r, c } } (0-indexed),
|
||||||
|
// which is exactly Univer's IRange shape — copy verbatim.
|
||||||
|
if (Array.isArray(ws['!merges'])) {
|
||||||
|
for (const m of ws['!merges']!) {
|
||||||
|
if (m === undefined || m === null) continue
|
||||||
|
merges.push({ startRow: m.s.r, endRow: m.e.r, startColumn: m.s.c, endColumn: m.e.c })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rowCount, columnCount } = dimensions(ws, cellData)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sheetId,
|
||||||
|
name,
|
||||||
|
tabColor: '',
|
||||||
|
hidden: BooleanNumber.FALSE,
|
||||||
|
freeze: { startRow: -1, startColumn: -1, xSplit: 0, ySplit: 0 },
|
||||||
|
rowCount,
|
||||||
|
columnCount,
|
||||||
|
defaultColumnWidth: 96,
|
||||||
|
defaultRowHeight: 22,
|
||||||
|
mergeData: merges,
|
||||||
|
cellData,
|
||||||
|
rowData: convertRows(ws['!rows']),
|
||||||
|
columnData: convertCols(ws['!cols']),
|
||||||
|
rowHeader: { width: 46 },
|
||||||
|
columnHeader: { height: 20 },
|
||||||
|
showGridlines: BooleanNumber.TRUE,
|
||||||
|
rightToLeft: BooleanNumber.FALSE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a SheetJS CellObject onto Univer's ICellData (or null for empty). */
|
||||||
|
function convertCell(cell: XLSX.CellObject): ICellData | null {
|
||||||
|
const out: ICellData = {}
|
||||||
|
// Type mapping: b→BOOLEAN, n→NUMBER, s→STRING, d→STRING, e→FORCE_STRING, z→skip
|
||||||
|
switch (cell.t) {
|
||||||
|
case 'b':
|
||||||
|
out.t = CellValueType.BOOLEAN
|
||||||
|
out.v = cell.v === true
|
||||||
|
break
|
||||||
|
case 'n':
|
||||||
|
out.t = CellValueType.NUMBER
|
||||||
|
out.v = typeof cell.v === 'number' ? cell.v : Number(cell.v)
|
||||||
|
break
|
||||||
|
case 'e':
|
||||||
|
// Excel error strings like #REF! — display verbatim, no formula engine.
|
||||||
|
out.t = CellValueType.FORCE_STRING
|
||||||
|
out.v = String(cell.w ?? cell.v ?? '')
|
||||||
|
break
|
||||||
|
case 's':
|
||||||
|
case 'd':
|
||||||
|
case 'z':
|
||||||
|
default:
|
||||||
|
// String / date / stub. SheetJS renders dates as ISO strings in `w`
|
||||||
|
// when cellDates is on; otherwise `v` is the serial number. Use `w`
|
||||||
|
// (formatted text) when present so users see what Excel showed.
|
||||||
|
out.t = CellValueType.STRING
|
||||||
|
out.v = cell.w ?? (cell.v !== undefined ? String(cell.v) : '')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Formula: keep as text; Univer's formula engine computes on load.
|
||||||
|
if (typeof cell.f === 'string' && cell.f !== '') {
|
||||||
|
out.f = cell.f
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert SheetJS `!cols` (ColInfo[]) to Univer columnData (sparse, by index). */
|
||||||
|
function convertCols(cols: XLSX.ColInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IColumnData>> {
|
||||||
|
if (!Array.isArray(cols)) return {}
|
||||||
|
const out: IObjectArrayPrimitiveType<Partial<IColumnData>> = {}
|
||||||
|
cols.forEach((info, index) => {
|
||||||
|
if (info === undefined || info === null) return
|
||||||
|
const w = info.wpx ?? (info.width !== undefined ? Math.round(info.width * 7) : undefined)
|
||||||
|
out[index] = {
|
||||||
|
...(w !== undefined ? { w } : {}),
|
||||||
|
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert SheetJS `!rows` (RowInfo[]) to Univer rowData (sparse, by index). */
|
||||||
|
function convertRows(rows: XLSX.RowInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IRowData>> {
|
||||||
|
if (!Array.isArray(rows)) return {}
|
||||||
|
const out: IObjectArrayPrimitiveType<Partial<IRowData>> = {}
|
||||||
|
rows.forEach((info, index) => {
|
||||||
|
if (info === undefined || info === null) return
|
||||||
|
const h = info.hpx ?? info.hpt
|
||||||
|
out[index] = {
|
||||||
|
...(h !== undefined ? { h: Math.round(h) } : {}),
|
||||||
|
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the worksheet dimensions. SheetJS's `!ref` is authoritative when
|
||||||
|
* present; otherwise fall back to the max row/col we actually wrote, with a
|
||||||
|
* small floor so the canvas has somewhere to paint.
|
||||||
|
*/
|
||||||
|
function dimensions(
|
||||||
|
ws: XLSX.WorkSheet,
|
||||||
|
cellData: IObjectMatrixPrimitiveType<ICellData>,
|
||||||
|
): { rowCount: number; columnCount: number } {
|
||||||
|
const ref = ws['!ref']
|
||||||
|
if (typeof ref === 'string' && ref !== '') {
|
||||||
|
const range = decodeRange(ref)
|
||||||
|
return {
|
||||||
|
rowCount: Math.max(range.endRow + 1, 20),
|
||||||
|
columnCount: Math.max(range.endColumn + 1, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let maxRow = -1
|
||||||
|
let maxCol = -1
|
||||||
|
for (const r of Object.keys(cellData)) {
|
||||||
|
const rowNum = Number(r)
|
||||||
|
if (rowNum > maxRow) maxRow = rowNum
|
||||||
|
const rowObj = cellData[rowNum]!
|
||||||
|
for (const c of Object.keys(rowObj)) {
|
||||||
|
const colNum = Number(c)
|
||||||
|
if (colNum > maxCol) maxCol = colNum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rowCount: Math.max(maxRow + 1, 20),
|
||||||
|
columnCount: Math.max(maxCol + 1, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode an A1-style address (e.g. "AB12") to 0-indexed {row, col}. */
|
||||||
|
function decodeAddr(addr: string): { r: number; c: number } {
|
||||||
|
let col = 0
|
||||||
|
let i = 0
|
||||||
|
while (i < addr.length) {
|
||||||
|
const ch = addr.charCodeAt(i)
|
||||||
|
if (ch >= 65 && ch <= 90) {
|
||||||
|
col = col * 26 + (ch - 64)
|
||||||
|
i += 1
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const row = Number(addr.slice(i)) - 1
|
||||||
|
return { r: row, c: col - 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode an A1-style range (e.g. "A1:AB12") to 0-indexed bounds. */
|
||||||
|
function decodeRange(range: string): { startRow: number; endRow: number; startColumn: number; endColumn: number } {
|
||||||
|
const at = range.indexOf(':')
|
||||||
|
const start = decodeAddr(at === -1 ? range : range.slice(0, at))
|
||||||
|
const end = at === -1 ? start : decodeAddr(range.slice(at + 1))
|
||||||
|
return { startRow: start.r, endRow: end.r, startColumn: start.c, endColumn: end.c }
|
||||||
|
}
|
||||||
+7
-1
@@ -51,6 +51,12 @@ const MEDIA_TYPES: Record<string, string> = {
|
|||||||
'.bmp': 'image/bmp',
|
'.bmp': 'image/bmp',
|
||||||
'.ico': 'image/x-icon',
|
'.ico': 'image/x-icon',
|
||||||
'.avif': 'image/avif',
|
'.avif': 'image/avif',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
|
||||||
|
export function mediaTypeForPath(path: string): string {
|
||||||
|
return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
|
/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
|
||||||
@@ -375,7 +381,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
|||||||
if (!info.isFile() || info.size > resolved.mediaLimit) {
|
if (!info.isFile() || info.size > resolved.mediaLimit) {
|
||||||
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
||||||
}
|
}
|
||||||
const type = MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
const type = mediaTypeForPath(path)
|
||||||
const body = await readFile(path)
|
const body = await readFile(path)
|
||||||
// Raw bytes either way (binary-safe); ?download=1 switches the
|
// Raw bytes either way (binary-safe); ?download=1 switches the
|
||||||
// disposition so the browser saves the file instead of showing it.
|
// disposition so the browser saves the file instead of showing it.
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as P
|
|||||||
/** The registry's strict id validation (manifest.ts / client-modules registerExternal): exactly two lowercase slash-separated segments. */
|
/** The registry's strict id validation (manifest.ts / client-modules registerExternal): exactly two lowercase slash-separated segments. */
|
||||||
const ID_PATTERN = /^(?!node_modules\/)(?:@[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*|[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*)$/
|
const ID_PATTERN = /^(?!node_modules\/)(?:@[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*|[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*)$/
|
||||||
|
|
||||||
|
/** Literal require() specifiers the frozen browser module table can answer. */
|
||||||
|
const CLIENT_REQUIRE_ALLOWED = new Set([
|
||||||
|
'react',
|
||||||
|
'react/jsx-runtime',
|
||||||
|
'react-dom',
|
||||||
|
'react-dom/client',
|
||||||
|
'cordis',
|
||||||
|
'@deepseek-ai/dsh-client-ui-slots',
|
||||||
|
'@deepseek-ai/dsh-client-web-react',
|
||||||
|
'@deepseek-ai/dsh-client-ui-primitives',
|
||||||
|
'@deepseek-ai/dsh-client-schema-form',
|
||||||
|
'@deepseek-ai/dsh-client-runtime/client',
|
||||||
|
])
|
||||||
|
|
||||||
/** The registered `__ModuleLoader__.load({ id })` value of a built client bundle. */
|
/** The registered `__ModuleLoader__.load({ id })` value of a built client bundle. */
|
||||||
function bundleId(file: string): string {
|
function bundleId(file: string): string {
|
||||||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||||
@@ -72,6 +86,16 @@ describe('registry manifest consistency (dsh.plugin.json)', () => {
|
|||||||
expect(registryId).not.toBe(pkg.name)
|
expect(registryId).not.toBe(pkg.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('client bundles require only frozen module-table entries', () => {
|
||||||
|
for (const file of ['lib/client.js', manifest.client!.main!]) {
|
||||||
|
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||||
|
// Exclude method calls such as `freeModule.require("util")`; only the
|
||||||
|
// loader factory's lexical require() is constrained by the module table.
|
||||||
|
const required = [...source.matchAll(/(^|[^.$\w])require\("([^"]+)"\)/gm)].map(match => match[2]!)
|
||||||
|
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id))).toEqual([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('contributes declares no tools or skills (the host half registers none)', () => {
|
it('contributes declares no tools or skills (the host half registers none)', () => {
|
||||||
expect(manifest.contributes?.tools).toEqual([])
|
expect(manifest.contributes?.tools).toEqual([])
|
||||||
expect(manifest.contributes?.skills).toEqual([])
|
expect(manifest.contributes?.skills).toEqual([])
|
||||||
|
|||||||
+6
-1
@@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { resolve as resolvePath } from 'node:path'
|
import { resolve as resolvePath } from 'node:path'
|
||||||
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||||
import { apply } from '../src/index.ts'
|
import { apply, mediaTypeForPath } from '../src/index.ts'
|
||||||
import * as git from '../src/git.ts'
|
import * as git from '../src/git.ts'
|
||||||
import { listDirectory } from '../src/fs-tree.ts'
|
import { listDirectory } from '../src/fs-tree.ts'
|
||||||
import { defaultShell, PtyManager } from '../src/pty-manager.ts'
|
import { defaultShell, PtyManager } from '../src/pty-manager.ts'
|
||||||
@@ -27,6 +27,11 @@ interface FakeContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('host plugin smoke', () => {
|
describe('host plugin smoke', () => {
|
||||||
|
it('serves PDF with the browser-native content type', () => {
|
||||||
|
expect(mediaTypeForPath('/work/report.PDF')).toBe('application/pdf')
|
||||||
|
expect(mediaTypeForPath('/work/archive.bin')).toBe('application/octet-stream')
|
||||||
|
})
|
||||||
|
|
||||||
it('mounts the fenced routes', () => {
|
it('mounts the fenced routes', () => {
|
||||||
const routes: SidebarWebRoute[] = []
|
const routes: SidebarWebRoute[] = []
|
||||||
const upgrades: SidebarWebUpgradeRoute[] = []
|
const upgrades: SidebarWebUpgradeRoute[] = []
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||||
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
||||||
|
import { officeKindForExt } from '../src/client/office-types.ts'
|
||||||
|
import { isPdfExt } from '../src/client/pdf-types.ts'
|
||||||
import { relativeTo } from '../src/client/paths.ts'
|
import { relativeTo } from '../src/client/paths.ts'
|
||||||
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
|
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
|
||||||
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
|
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
|
||||||
@@ -453,6 +455,37 @@ describe('editor language mapping', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('office preview kind', () => {
|
||||||
|
it('routes docx/xlsx to their renderers', () => {
|
||||||
|
expect(officeKindForExt('.docx')).toBe('docx')
|
||||||
|
expect(officeKindForExt('.xlsx')).toBe('xlsx')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes pptx to its renderer and legacy OLE formats to download-only', () => {
|
||||||
|
expect(officeKindForExt('.pptx')).toBe('pptx')
|
||||||
|
expect(officeKindForExt('.doc')).toBe('download-only')
|
||||||
|
expect(officeKindForExt('.xls')).toBe('download-only')
|
||||||
|
expect(officeKindForExt('.ppt')).toBe('download-only')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for non-Office extensions and empty input', () => {
|
||||||
|
expect(officeKindForExt('.txt')).toBeNull()
|
||||||
|
expect(officeKindForExt('.md')).toBeNull()
|
||||||
|
expect(officeKindForExt('.png')).toBeNull()
|
||||||
|
expect(officeKindForExt('.pdf')).toBeNull()
|
||||||
|
expect(officeKindForExt('')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pdf preview kind', () => {
|
||||||
|
it('routes only .pdf to the browser-native preview', () => {
|
||||||
|
expect(isPdfExt('.pdf')).toBe(true)
|
||||||
|
expect(isPdfExt('.PDF')).toBe(false)
|
||||||
|
expect(isPdfExt('.docx')).toBe(false)
|
||||||
|
expect(isPdfExt('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('side card preferences', () => {
|
describe('side card preferences', () => {
|
||||||
/** A fake settings wire face whose settingsGet resolves to one raw value. */
|
/** A fake settings wire face whose settingsGet resolves to one raw value. */
|
||||||
const wire = (value: unknown): SidebarSettingsClient => ({
|
const wire = (value: unknown): SidebarSettingsClient => ({
|
||||||
|
|||||||
+51
-1
@@ -31,13 +31,35 @@
|
|||||||
*/
|
*/
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
||||||
import { createRequire } from 'node:module'
|
import { builtinModules, createRequire } from 'node:module'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { UserConfig } from 'tsdown'
|
import type { UserConfig } from 'tsdown'
|
||||||
import { transform } from 'lightningcss'
|
import { transform } from 'lightningcss'
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
/** Node builtins must never survive into the browser module-loader factory. */
|
||||||
|
const NODE_BUILTINS = new Set([
|
||||||
|
...builtinModules,
|
||||||
|
...builtinModules.map(id => `node:${id}`),
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser-only standalone entries for dependencies whose package `browser`
|
||||||
|
* remaps Rolldown does not currently honor after CJS lowering. Without these
|
||||||
|
* aliases nanoid/SheetJS/JSZip leave Node builtin require() calls in the
|
||||||
|
* client factory, which the DSH module table correctly refuses.
|
||||||
|
*/
|
||||||
|
const DOCX_PREVIEW_ENTRY = require.resolve('docx-preview')
|
||||||
|
const JSZIP_BROWSER_ENTRY = resolvePath(
|
||||||
|
dirname(require.resolve('jszip/package.json', { paths: [dirname(DOCX_PREVIEW_ENTRY)] })),
|
||||||
|
'dist/jszip.min.js',
|
||||||
|
)
|
||||||
|
const XLSX_BROWSER_ENTRY = resolvePath(
|
||||||
|
dirname(require.resolve('xlsx/package.json')),
|
||||||
|
'dist/xlsx.full.min.js',
|
||||||
|
)
|
||||||
|
|
||||||
/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption). */
|
/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption). */
|
||||||
const CLIENT_EXTERNALS = [
|
const CLIENT_EXTERNALS = [
|
||||||
'react',
|
'react',
|
||||||
@@ -114,6 +136,22 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
|||||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||||
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||||
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
|
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
|
||||||
|
// pptx-renderer only uses this to auto-discover its optional PDF.js
|
||||||
|
// fallback. PptxView passes pdfjs:false, so browser CJS has no resolver.
|
||||||
|
'import.meta.resolve': 'undefined',
|
||||||
|
},
|
||||||
|
alias: {
|
||||||
|
jszip: JSZIP_BROWSER_ENTRY,
|
||||||
|
xlsx: XLSX_BROWSER_ENTRY,
|
||||||
|
},
|
||||||
|
// CJS output otherwise makes some transitive packages (notably nanoid
|
||||||
|
// through Univer Core) resolve their Node entry even though this bundle
|
||||||
|
// runs in the browser. Keep browser conditional exports authoritative for
|
||||||
|
// both source import() and generated require() edges.
|
||||||
|
inputOptions: {
|
||||||
|
resolve: {
|
||||||
|
conditionNames: ['browser', 'import', 'require', 'default'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// External wins for module-table entries; every other dependency inlines.
|
// External wins for module-table entries; every other dependency inlines.
|
||||||
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
||||||
@@ -127,6 +165,12 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
|||||||
// imports are erased and never reach this gate.
|
// imports are erased and never reach this gate.
|
||||||
name: 'dsh-client-bundle-purity',
|
name: 'dsh-client-bundle-purity',
|
||||||
resolveId(source: string) {
|
resolveId(source: string) {
|
||||||
|
if (NODE_BUILTINS.has(source)) {
|
||||||
|
throw new Error(
|
||||||
|
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
|
||||||
|
+ 'select the dependency browser export or add an explicit browser implementation',
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!source.startsWith('@deepseek-ai/')) return null
|
if (!source.startsWith('@deepseek-ai/')) return null
|
||||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||||
@@ -182,6 +226,12 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
|||||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(pluginId)}, factory: (require) => {`,
|
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(pluginId)}, factory: (require) => {`,
|
||||||
footer: `return module.exports; } });`,
|
footer: `return module.exports; } });`,
|
||||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||||
|
// The CJS wrapper factory's `require` only resolves module-table entries
|
||||||
|
// (react, cordis, ...); it cannot load relative chunk URLs in the browser.
|
||||||
|
// Disable code splitting so dynamic import() inlines into the single
|
||||||
|
// factory chunk (Office preview libs — docx-preview, Univer — pull in
|
||||||
|
// several MB but only when first opened, the trade-off for wrapper safety).
|
||||||
|
codeSplitting: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user