feat: add Office, PDF, and PPTX previews

This commit is contained in:
HuanLinOTO
2026-08-10 16:46:01 +08:00
parent 4060bd53e1
commit a6befc8b6b
23 changed files with 5158 additions and 11 deletions
+46 -5
View File
@@ -13,11 +13,16 @@ import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
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 { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
import { isDarkScheme, subscribeColorScheme } from './theme.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'
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']
@@ -26,8 +31,8 @@ const MD_EXT = ['.md', '.markdown']
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; kind: 'text' | 'image' | 'md'; content: string; truncated: boolean }
| { status: 'binary' }
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx' | 'pptx' | 'pdf'; content: string; truncated: boolean }
| { status: 'binary' } // unknown binary / OLE legacy formats → download button
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
@@ -57,9 +62,26 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
setDraft(null)
setDirty(false)
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) => {
if (cancelled) return
const ext = extOfPath(path)
if (result.kind === 'binary') {
setLoad({ status: 'binary' })
return
@@ -207,7 +229,14 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
</div>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</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 && (
<>
{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} />
</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' && (
<div className={css.editorMd}>
<MarkdownText text={draft ?? load.content} />
+110
View File
@@ -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>
)
}
+131
View File
@@ -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
View File
@@ -5,7 +5,7 @@
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* 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 {
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: {
paneId: string
tabs: SidebarTab[]
@@ -63,6 +69,18 @@ export function TabBar(props: {
const [menuOpen, setMenuOpen] = 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 (
<div
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
@@ -79,6 +97,7 @@ export function TabBar(props: {
event.preventDefault()
event.stopPropagation()
setDragOver(false)
setTabDragging(false)
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
const payload = parseDrag(raw)
if (payload !== null) onDropTab(payload, null)
@@ -92,13 +111,16 @@ export function TabBar(props: {
title={tab.title}
draggable
onDragStart={(event) => {
setTabDragging(true)
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
event.dataTransfer.effectAllowed = 'move'
}}
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
onDrop={(event) => {
event.preventDefault()
event.stopPropagation()
setTabDragging(false)
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
const payload = parseDrag(raw)
if (payload !== null) onDropTab(payload, tab.id)
+20
View File
@@ -78,6 +78,16 @@ const zh = {
settingsWidthSuffix: '%',
settingsSaveFailed: '保存失败',
settingsConflict: '设置已被其他窗口修改,请重试',
binaryNoPreview: '此文件类型不支持预览',
downloadToView: '下载查看',
officeTooLarge: '文件过大,无法预览',
officeCorrupt: '文件损坏或格式无效',
officeEncrypted: '不支持加密文件',
officeLoadFailed: 'Office 预览组件加载失败',
previousSlide: '上一页',
nextSlide: '下一页',
zoom: '缩放',
zoomHint: 'Alt + 滚轮',
}
const en: Record<keyof typeof zh, string> = {
@@ -153,6 +163,16 @@ const en: Record<keyof typeof zh, string> = {
settingsWidthSuffix: '%',
settingsSaveFailed: 'Failed to save',
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). */
+48
View File
@@ -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
}
+249
View File
@@ -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>
)
}
+4
View File
@@ -0,0 +1,4 @@
/** Pure PDF extension dispatch, separate from React for unit testing. */
export function isPdfExt(ext: string): boolean {
return ext === '.pdf'
}
+246 -1
View File
@@ -639,6 +639,46 @@
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 {
padding: 12px 16px;
font: var(--dsw-font-xxs-12);
@@ -729,6 +769,208 @@
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 ──────────────────────────────────────────────────────────── */
.terminalWrap {
@@ -1066,7 +1308,10 @@
.gitLink:focus-visible,
.gitCommitButton: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-offset: -1px;
}
+14 -1
View File
@@ -9,7 +9,7 @@
* that merges the tab into the pane. The tree and all operations live in
* 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 clsx from 'clsx'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
@@ -127,6 +127,19 @@ function LeafView(props: {
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
const [dropZone, setDropZone] = useState<DropZone | null>(null)
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 (
<div
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
+260
View File
@@ -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 }
}