feat: add Office, PDF, and PPTX previews
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
* 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)
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user