mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-30 18:31:36 +08:00
feat(preview): add microsoft office viewer
This commit is contained in:
@@ -39,6 +39,18 @@ const fileShare = {
|
||||
rootRef: 'root-file-ref',
|
||||
}
|
||||
|
||||
const officeShare = {
|
||||
...fileShare,
|
||||
token: 'office-share-e2e',
|
||||
matter: {
|
||||
name: 'Quarterly Report.docx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
size: 4096,
|
||||
isFolder: false,
|
||||
},
|
||||
rootRef: 'root-office-ref',
|
||||
}
|
||||
|
||||
async function mockFolderShare(page: Page) {
|
||||
await page.route(`**/api/shares/${folderShare.token}`, (route) => {
|
||||
if (route.request().method() !== 'GET') return route.continue()
|
||||
@@ -93,6 +105,23 @@ async function mockFileShare(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
async function mockOfficeShare(page: Page) {
|
||||
const resolvedDownloadUrl = 'https://files.example.test/Quarterly%20Report.docx'
|
||||
|
||||
await page.route(`**/api/shares/${officeShare.token}`, (route) => {
|
||||
if (route.request().method() !== 'GET') return route.continue()
|
||||
return route.fulfill({ json: officeShare })
|
||||
})
|
||||
|
||||
await page.route(`**/api/shares/${officeShare.token}/objects/${officeShare.rootRef}*`, (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.searchParams.get('downloadUrl') === '1') {
|
||||
return route.fulfill({ json: { downloadUrl: resolvedDownloadUrl } })
|
||||
}
|
||||
return route.fulfill({ body: 'office-doc-placeholder', contentType: officeShare.matter.type })
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoHorizontalOverflow(page: Page) {
|
||||
const hasHScroll = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
@@ -150,6 +179,21 @@ test.describe('Share access page shell', () => {
|
||||
await expectNoHorizontalOverflow(page)
|
||||
})
|
||||
|
||||
test('office file share embeds Microsoft Office Viewer with the existing download URL @desktop', async ({ page }) => {
|
||||
await mockOfficeShare(page)
|
||||
await page.goto(`/s/${officeShare.token}`)
|
||||
|
||||
await expect(page.getByTestId('page-header')).toContainText('Quarterly Report.docx')
|
||||
const frame = page.frameLocator('iframe[title="Quarterly Report.docx"]')
|
||||
await expect(frame.owner()).toBeVisible()
|
||||
|
||||
const src = await frame.owner().getAttribute('src')
|
||||
expect(src).toContain('https://view.officeapps.live.com/op/embed.aspx?src=')
|
||||
expect(decodeURIComponent(src ?? '')).toContain('https://files.example.test/Quarterly%20Report.docx')
|
||||
await expect(page.getByText('Microsoft Office Viewer requires a public file URL')).toHaveCount(0)
|
||||
await expectNoHorizontalOverflow(page)
|
||||
})
|
||||
|
||||
test('signed-in visitor sees avatar dropdown in the topbar @desktop', async ({ page }) => {
|
||||
await signUpAndGoToFiles(page)
|
||||
await mockFolderShare(page)
|
||||
|
||||
@@ -304,6 +304,23 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => {
|
||||
expect(res.headers.get('cache-control')).toBe('no-store')
|
||||
})
|
||||
|
||||
it('returns JSON downloadUrl for public share when requested by preview', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFile(db, orgId, { id: 'dl-url1', name: 'report.docx' })
|
||||
const share = await createShare(db, { matterId: 'dl-url1', orgId, creatorId, kind: 'landing' })
|
||||
|
||||
const rootRef = await fetchRootRef(app, share.token)
|
||||
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}?downloadUrl=1`, { redirect: 'manual' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('cache-control')).toBe('no-store')
|
||||
await expect(res.json()).resolves.toEqual({ downloadUrl: MOCK_PRESIGN_URL })
|
||||
})
|
||||
|
||||
it('returns 401 when password required and no cookie', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
|
||||
@@ -219,6 +219,7 @@ export const publicShares = new Hono<Env>()
|
||||
.get('/:token/objects/:ref', async (c) => {
|
||||
const token = c.req.param('token')
|
||||
const ref = c.req.param('ref')
|
||||
const returnUrl = c.req.query('downloadUrl') === '1'
|
||||
const db = c.get('platform').db
|
||||
|
||||
const resolved = await resolveShareByToken(db, token)
|
||||
@@ -270,6 +271,12 @@ export const publicShares = new Hono<Env>()
|
||||
if (!storage) return c.json({ error: 'Storage not found' }, 404)
|
||||
|
||||
const url = await s3.presignDownload(storage, targetMatter.object, targetMatter.name, PRESIGN_TTL_SECS)
|
||||
if (returnUrl) {
|
||||
const res = c.json({ downloadUrl: url })
|
||||
res.headers.set('Cache-Control', 'no-store')
|
||||
return res
|
||||
}
|
||||
|
||||
const res = c.redirect(url, 302)
|
||||
res.headers.set('Cache-Control', 'no-store')
|
||||
return res
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PreviewFile {
|
||||
}
|
||||
|
||||
const PdfPreview = lazy(() => import('./pdf-preview').then((m) => ({ default: m.PdfPreview })))
|
||||
const OfficePreview = lazy(() => import('./office-preview').then((m) => ({ default: m.OfficePreview })))
|
||||
const TextPreview = lazy(() => import('./text-preview').then((m) => ({ default: m.TextPreview })))
|
||||
const MediaPreview = lazy(() => import('./media-preview').then((m) => ({ default: m.MediaPreview })))
|
||||
|
||||
@@ -42,6 +43,8 @@ function FilePreviewContentInner({ file, previewType }: { file: PreviewFile; pre
|
||||
return <img src={file.downloadUrl} alt={file.name} className="max-h-full w-full rounded-md object-contain" />
|
||||
case 'pdf':
|
||||
return <PdfPreview url={file.downloadUrl} />
|
||||
case 'office':
|
||||
return <OfficePreview url={file.downloadUrl} filename={file.name} />
|
||||
case 'markdown':
|
||||
case 'code':
|
||||
case 'text':
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface OfficePreviewProps {
|
||||
url: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(url: string): string {
|
||||
return new URL(url, window.location.origin).href
|
||||
}
|
||||
|
||||
function shouldResolveDownloadUrl(url: string): boolean {
|
||||
const absoluteUrl = toAbsoluteUrl(url)
|
||||
return new URL(absoluteUrl).origin === window.location.origin && absoluteUrl.includes('/api/shares/')
|
||||
}
|
||||
|
||||
function withDownloadUrlQuery(url: string): string {
|
||||
const target = new URL(url, window.location.origin)
|
||||
target.searchParams.set('downloadUrl', '1')
|
||||
return target.href
|
||||
}
|
||||
|
||||
export function buildOfficeViewerUrl(url: string): string {
|
||||
const absoluteUrl = toAbsoluteUrl(url)
|
||||
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(absoluteUrl)}`
|
||||
}
|
||||
|
||||
export function OfficePreview({ url, filename }: OfficePreviewProps) {
|
||||
const { t } = useTranslation()
|
||||
const [resolvedUrl, setResolvedUrl] = useState(url)
|
||||
const [error, setError] = useState(false)
|
||||
const needsResolution = shouldResolveDownloadUrl(url)
|
||||
const [resolving, setResolving] = useState(needsResolution)
|
||||
const absoluteUrl = toAbsoluteUrl(resolvedUrl)
|
||||
const viewerUrl = useMemo(() => buildOfficeViewerUrl(resolvedUrl), [resolvedUrl])
|
||||
const isLocalUrl = absoluteUrl.startsWith('http://localhost') || absoluteUrl.startsWith('http://127.0.0.1')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setResolvedUrl(url)
|
||||
setError(false)
|
||||
setResolving(shouldResolveDownloadUrl(url))
|
||||
|
||||
if (!shouldResolveDownloadUrl(url)) return
|
||||
|
||||
fetch(withDownloadUrlQuery(url), { credentials: 'include' })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to resolve download URL')
|
||||
return res.json() as Promise<{ downloadUrl?: string }>
|
||||
})
|
||||
.then((body) => {
|
||||
if (!cancelled && body.downloadUrl) setResolvedUrl(body.downloadUrl)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setResolving(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [url])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-96 flex-col bg-background">
|
||||
{error && <div className="border-b px-4 py-2 text-sm text-destructive">{t('preview.loadError')}</div>}
|
||||
{!resolving && isLocalUrl && (
|
||||
<div className="border-b bg-muted/50 px-4 py-2 text-sm text-muted-foreground">
|
||||
{t('preview.officeLocalHint')}
|
||||
</div>
|
||||
)}
|
||||
{resolving ? (
|
||||
<div className="flex min-h-96 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
title={filename}
|
||||
src={viewerUrl}
|
||||
className="min-h-96 flex-1 border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -401,6 +401,7 @@
|
||||
"preview.unsupported": "Preview is not available for this file type.",
|
||||
"preview.loadError": "Failed to load file preview.",
|
||||
"preview.pageOf": "{{current}} / {{total}}",
|
||||
"preview.officeLocalHint": "Microsoft Office Viewer requires a public file URL. Localhost previews may not render.",
|
||||
"quota.storage": "Storage",
|
||||
"quota.usage": "{{used}} / {{total}} used",
|
||||
"quota.usageNoLimit": "{{used}} used",
|
||||
|
||||
@@ -401,6 +401,7 @@
|
||||
"preview.unsupported": "此文件类型暂不支持预览。",
|
||||
"preview.loadError": "加载文件预览失败。",
|
||||
"preview.pageOf": "{{current}} / {{total}}",
|
||||
"preview.officeLocalHint": "Microsoft Office Viewer 需要公网文件 URL,本地 localhost 预览可能无法渲染。",
|
||||
"quota.storage": "存储空间",
|
||||
"quota.usage": "{{used}} / {{total}} 已使用",
|
||||
"quota.usageNoLimit": "{{used}} 已使用",
|
||||
|
||||
@@ -41,6 +41,23 @@ describe('getPreviewType — pdf extension', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPreviewType — office extensions', () => {
|
||||
it('returns office for Word documents', () => {
|
||||
expect(getPreviewType('proposal.doc')).toBe('office')
|
||||
expect(getPreviewType('proposal.docx')).toBe('office')
|
||||
})
|
||||
|
||||
it('returns office for Excel workbooks', () => {
|
||||
expect(getPreviewType('budget.xls')).toBe('office')
|
||||
expect(getPreviewType('budget.xlsx')).toBe('office')
|
||||
})
|
||||
|
||||
it('returns office for PowerPoint presentations', () => {
|
||||
expect(getPreviewType('deck.ppt')).toBe('office')
|
||||
expect(getPreviewType('deck.pptx')).toBe('office')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPreviewType — markdown extensions', () => {
|
||||
it('returns markdown for .md', () => {
|
||||
expect(getPreviewType('README.md')).toBe('markdown')
|
||||
@@ -262,6 +279,13 @@ describe('getPreviewType — MIME type fallback', () => {
|
||||
expect(getPreviewType('file.unknown', 'application/pdf')).toBe('pdf')
|
||||
})
|
||||
|
||||
it('returns office from Microsoft Office MIME types when extension is unknown', () => {
|
||||
expect(
|
||||
getPreviewType('file.unknown', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
||||
).toBe('office')
|
||||
expect(getPreviewType('file.unknown', 'application/vnd.ms-excel')).toBe('office')
|
||||
})
|
||||
|
||||
it('returns markdown from text/markdown MIME type when extension is unknown', () => {
|
||||
expect(getPreviewType('file.unknown', 'text/markdown')).toBe('markdown')
|
||||
})
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
export type PreviewType = 'image' | 'pdf' | 'text' | 'markdown' | 'code' | 'audio' | 'video' | 'unsupported'
|
||||
export type PreviewType = 'image' | 'pdf' | 'office' | 'text' | 'markdown' | 'code' | 'audio' | 'video' | 'unsupported'
|
||||
|
||||
const extensionMap: Record<string, PreviewType> = {
|
||||
// Image
|
||||
@@ -14,6 +14,14 @@ const extensionMap: Record<string, PreviewType> = {
|
||||
// PDF
|
||||
pdf: 'pdf',
|
||||
|
||||
// Office
|
||||
doc: 'office',
|
||||
docx: 'office',
|
||||
xls: 'office',
|
||||
xlsx: 'office',
|
||||
ppt: 'office',
|
||||
pptx: 'office',
|
||||
|
||||
// Markdown
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
@@ -72,6 +80,12 @@ const extensionMap: Record<string, PreviewType> = {
|
||||
|
||||
const exactMimeMap: Record<string, PreviewType> = {
|
||||
'application/pdf': 'pdf',
|
||||
'application/msword': 'office',
|
||||
'application/vnd.ms-excel': 'office',
|
||||
'application/vnd.ms-powerpoint': 'office',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'office',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'office',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'office',
|
||||
'text/markdown': 'markdown',
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user