feat(file-preview): configurable large file policy with i18n and error reporting

- Add LargeFilePolicy union type ("default" | "off" | PreviewSizePolicyConfig)
  with validatePreviewSizePolicy / resolvePreviewSizePolicy helpers; validator
  rejects Infinity/NaN and inverted thresholds
- LargeFileGate accepts policy / onError / renderBlockedFallback props; all
  UI strings externalized via useLocale (5 new locale fields); block report
  dedup keyed on id+size+name so contract-violating id reuse still re-reports
- PluginPreviewRenderer passes through largeFilePolicy and
  renderLargeFileFallback; unsupported branch now also goes through gate
- downloadSource refactored by source kind (file/blob/arrayBuffer/url);
  caller mimeType overrides response Content-Type; network/HTTP errors
  normalized to PreviewError (REMOTE_CORS_ERROR / REMOTE_HTTP_ERROR)
- Tests cover custom maxBytes, tiered policy, FILE_TOO_LARGE onError,
  custom renderLargeFileFallback; tests pin LocaleProvider value={zhCN}
- Demo app shows 4 policy variants with onError telemetry pattern and
  policy-derived UI label (no hardcoded strings to drift)
- README documents the largeFilePolicy API and user-input safety pattern

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
lambert.li
2026-07-06 10:50:28 +08:00
co-authored by Claude Opus 4.7
parent e3a86fd25e
commit 5f88b11a8a
12 changed files with 1093 additions and 99 deletions
+56 -2
View File
@@ -13,6 +13,7 @@ https://coderlambert.github.io/filevista/
- 拖拽上传、多文件切换、TabCache 状态保持
- Legacy Renderer / Plugin Renderer 双引擎切换
- 按文件类型懒加载 Preview Adapter
- 可配置大文件预览策略(`largeFilePolicy`),支持 warning / confirm / block 三档阈值自定义、`onError` 错误上报与自定义降级 UI
- GitHub Actions CI 自动验证
- GitHub Pages 自动部署
@@ -96,6 +97,58 @@ CI 会自动执行 lint、test、build。Pages workflow 会自动构建并部署
- 远程 URL 预览依赖目标服务器 CORS 配置,若目标服务器未允许浏览器跨域访问,则无法直接预览
- 所有预览均在浏览器端执行,最终效果受浏览器能力影响
## 大文件预览策略
`PluginPreviewRenderer` 默认启用大文件保护:20 MB 提示、50 MB 需用户确认、100 MB 拦截预览(仅提供下载)。可通过 `largeFilePolicy` prop 自定义:
```tsx
import { PluginPreviewRenderer, validatePreviewSizePolicy } from "@lamberl-lee/file-preview";
// 1. 自定义阈值(warning / confirm 可选,置 null 禁用某档)
<PluginPreviewRenderer
file={file}
registry={registry}
largeFilePolicy={{
warningBytes: 20 * 1024 * 1024,
confirmBytes: 35 * 1024 * 1024,
maxBytes: 50 * 1024 * 1024,
}}
onError={(error) => {
if (error.code === "FILE_TOO_LARGE") {
// 上报到监控系统:error.details.actualBytes / maxBytes / fileType
}
}}
renderLargeFileFallback={({ file, maxBytes, download }) => (
<div>
{file.name} {maxBytes}
<button onClick={() => download().catch(console.error)}></button>
</div>
)}
/>
// 2. 关闭限制(仅适用于已在外层做过大小控制的场景)
<PluginPreviewRenderer file={file} largeFilePolicy="off" />
// 3. 使用默认策略(20 / 50 / 100 MB
<PluginPreviewRenderer file={file} largeFilePolicy="default" />
```
**接收用户输入的 maxBytes 时**,应先 try/catch `validatePreviewSizePolicy` 校验,非法值回退到 `"default"`
```tsx
const policy = useMemo(() => {
const maxBytes = userMB * 1024 * 1024;
try {
validatePreviewSizePolicy({ maxBytes });
return { maxBytes };
} catch {
return "default";
}
}, [userMB]);
```
完整示例参考 [apps/playground/src/app/large-file-policy-demo.tsx](apps/playground/src/app/large-file-policy-demo.tsx)。
## 文档
- 用户版支持矩阵:[docs/user-facing-preview-support.md](docs/user-facing-preview-support.md)
@@ -111,10 +164,11 @@ CI 会自动执行 lint、test、build。Pages workflow 会自动构建并部署
Stage 18:预览性能与大文件处理优化
```
候选方向:
候选方向(✅ 表示已落地)
- ✅ 大文件预览策略可配置化(`largeFilePolicy`,含 warning / confirm / block 三档)
- ✅ 统一错误边界(`onError` + `PreviewError`,覆盖 `FILE_TOO_LARGE` / `UNSUPPORTED_FILE_TYPE` / `RENDER_FAILED` 等 code
- 大文件读取进度提示
- PDF / Office 渲染取消机制
- Plugin 加载失败 fallback
- Worker 化部分解析任务
- 统一错误边界
@@ -0,0 +1,266 @@
"use client";
/**
* Demo: 在其他业务项目中使用 largeFilePolicy 控制最大文件预览大小。
*
* 这是提供给外部业务项目的参考示例,展示了所有 `largeFilePolicy` 的用法。
* 文件路径: apps/playground/src/app/large-file-policy-demo.tsx
*
* 在浏览器访问: http://localhost:3000 后临时替换渲染组件即可看到效果。
*/
import { useState, useCallback } from "react";
import type { FileInfo, LargeFilePolicy, PreviewSizePolicyConfig } from "@lamberl-lee/file-preview";
import { PluginPreviewRenderer } from "@lamberl-lee/file-preview";
import type { PreviewPluginRegistry } from "@lamberl-lee/file-preview";
const MB = 1024 * 1024;
// ─── 示例 1: 定义项目级别的预览大小策略 ─────────────────────────────────
//
// 使用 `satisfies PreviewSizePolicyConfig` 可以在写配置时获得自动补全
// 和类型检查,避免误写错属性名。
const PREVIEW_SIZE_POLICY = {
maxBytes: 10 * MB,
} satisfies PreviewSizePolicyConfig;
// ─── 示例 2: 分级策略(可选 warning + confirm) ─────────────────────────
const TIERED_POLICY = {
warningBytes: 20 * MB,
confirmBytes: 35 * MB,
maxBytes: 50 * MB,
} satisfies PreviewSizePolicyConfig;
// ─── 示例 3: 完全关闭限制 ──────────────────────────────────────────────
const NO_LIMIT_POLICY = "off" as const;
// ─── 示例 4: 使用默认策略(20 / 50 / 100 MB) ──────────────────────────
const DEFAULT_POLICY = "default" as const;
// ==========================================================================
// 组件: LargeFilePolicyDemo
// ==========================================================================
//
// 展示如何在业务组件中使用 PluginPreviewRenderer 的 largeFilePolicy
// 以及如何搭配 processRemoteUrl 的 maxBytes 做双层防护。
interface LargeFilePolicyDemoProps {
file: FileInfo;
registry: PreviewPluginRegistry;
/** 切换不同策略进行测试 */
policy?: "default" | "off" | "custom" | "tiered";
}
export function LargeFilePolicyDemo({
file,
registry,
policy = "custom",
}: LargeFilePolicyDemoProps) {
const [errorLog, setErrorLog] = useState<string[]>([]);
const handleError = useCallback(
(error: { code: string; message: string; details?: Record<string, unknown> }) => {
setErrorLog((prev) => [
`[${new Date().toISOString()}] ${error.code}: ${error.message}`,
...prev.slice(0, 9),
]);
// ─── 真实业务场景:统一错误上报 ──────────────────────────────────
if (error.code === "FILE_TOO_LARGE") {
// 这里对接你们自己的埋点/监控系统
// reportTelemetry({
// event: "file_preview_blocked",
// fileName: file.name,
// ...error.details,
// });
console.log("[业务] 文件过大被拦截", error.details);
}
if (error.code === "UNSUPPORTED_FILE_TYPE") {
console.log("[业务] 不支持的文件类型", error.details);
}
},
[],
);
// ─── 根据传入的 policy 名称选择对应配置 ────────────────────────────
const resolvedPolicy = (() => {
switch (policy) {
case "off":
return NO_LIMIT_POLICY;
case "custom":
return PREVIEW_SIZE_POLICY;
case "tiered":
return TIERED_POLICY;
default:
return DEFAULT_POLICY;
}
})();
return (
<div style={{ padding: 24, fontFamily: "system-ui" }}>
{/* ── 当前策略标签 ──────────────────────────────────────────────── */}
<div style={{ marginBottom: 16, display: "flex", gap: 8, alignItems: "center" }}>
<span style={{ fontWeight: 600, color: "#333" }}>:</span>
<span
style={{
padding: "2px 10px",
borderRadius: 4,
background: policy === "off" ? "#eee" : policy === "tiered" ? "#e8f5e9" : "#e3f2fd",
fontSize: 13,
fontFamily: "monospace",
}}
>
{formatPolicyLabel(resolvedPolicy)}
</span>
</div>
{/* ── 错误日志(展示 onError 回调收到的错误) ──────────────────── */}
{errorLog.length > 0 && (
<div
style={{
marginBottom: 16,
padding: 12,
background: "#fff3e0",
borderRadius: 6,
border: "1px solid #ffe0b2",
maxHeight: 200,
overflow: "auto",
}}
>
<div style={{ fontWeight: 600, marginBottom: 4, fontSize: 13 }}>onError :</div>
{errorLog.map((log, i) => (
<div key={i} style={{ fontSize: 11, fontFamily: "monospace", color: "#e65100" }}>
{log}
</div>
))}
</div>
)}
{/* ── 预览区域 ──────────────────────────────────────────────────── */}
<div
style={{
border: "1px solid #e0e0e0",
borderRadius: 8,
minHeight: 400,
overflow: "hidden",
}}
>
<PluginPreviewRenderer
file={file}
registry={registry}
largeFilePolicy={resolvedPolicy}
onError={handleError}
renderLargeFileFallback={({ file, maxBytes, download }) => (
// ─── 自定义超限降级页面(可选) ─────────────────────────────
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 400,
gap: 16,
padding: 24,
}}
>
<div style={{ fontSize: 48 }}>📁</div>
<h2 style={{ margin: 0, fontSize: 18, color: "#333" }}>
{file.name}
</h2>
<p style={{ margin: 0, color: "#666", fontSize: 14 }}>
{formatDisplaySize(file.size)}{" "}
{formatDisplaySize(maxBytes)}
</p>
<button
onClick={() => download().catch((e) => console.error(e))}
style={{
padding: "8px 24px",
borderRadius: 6,
border: "none",
background: "#1976d2",
color: "#fff",
cursor: "pointer",
fontSize: 14,
}}
>
</button>
</div>
)}
/>
</div>
</div>
);
}
function formatDisplaySize(bytes: number): string {
if (bytes < MB) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / MB).toFixed(1)} MB`;
}
/**
* 从 policy 对象派生展示文案,避免常量与 UI 标签脱钩。
*
* `LargeFilePolicy` 是联合类型(`"default" | "off" | PreviewSizePolicyConfig`),
* 三种情况都要覆盖。
*/
function formatPolicyLabel(policy: LargeFilePolicy): string {
if (policy === "default") return 'largeFilePolicy="default"';
if (policy === "off") return 'largeFilePolicy="off"';
const parts: string[] = [];
if (policy.warningBytes != null) {
parts.push(`warningBytes:${formatDisplaySize(policy.warningBytes)}`);
}
if (policy.confirmBytes != null) {
parts.push(`confirmBytes:${formatDisplaySize(policy.confirmBytes)}`);
}
parts.push(`maxBytes:${formatDisplaySize(policy.maxBytes)}`);
return `largeFilePolicy={{ ${parts.join(", ")} }}`;
}
// ==========================================================================
// 远程 URL 预览 + 双层大小限制(推荐模式)
// ==========================================================================
/**
* 当预览远程 URL 时,建议同时配置两层限制:
*
* 1. processRemoteUrl 的 maxBytes —— 在下载阶段拦截超大文件,节省带宽
* 2. PluginPreviewRenderer 的 largeFilePolicy —— 渲染阶段拦截
*
* 这样即使 processRemoteUrl 绕过(比如本地 File 对象),渲染时也能拦截。
*/
// import { processRemoteUrl, RemoteUrlError } from "@lamberl-lee/file-preview";
//
// export async function loadRemoteFile(url: string) {
// const file = await processRemoteUrl(url, {
// maxBytes: PREVIEW_SIZE_POLICY.maxBytes, // ← 下载阶段 50 MB 硬限制
// onProgress: (p) => console.log(`${p.percent}%`),
// });
//
// return file;
// }
//
// // 使用:
// // const file = await loadRemoteFile("https://example.com/large-report.pdf");
// // return (
// // <PluginPreviewRenderer
// // file={file}
// // registry={registry}
// // largeFilePolicy={PREVIEW_SIZE_POLICY} // ← 渲染阶段同样限制
// // onError={handleError}
// // />
// // );
// ==========================================================================
// 总结:对外暴露的类型可以直接 import
// ==========================================================================
// import type {
// PreviewSizePolicyConfig, // 自定义策略的类型
// LargeFilePolicy, // largeFilePolicy prop 的联合类型
// LargeFileBlockedContext, // renderLargeFileFallback 的上下文类型
// } from "@lamberl-lee/file-preview";
+107 -33
View File
@@ -1,16 +1,28 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { AlertTriangleIcon, DownloadIcon } from "./icons";
import type { FileInfo } from "./utils";
import { formatFileSize } from "./utils";
import { getPreviewSizePolicy } from "./performance-limits";
import { getPreviewSizePolicy, type LargeFilePolicy } from "./performance-limits";
import { PreviewFallback } from "./PreviewFallback";
import { PreviewError } from "./core/preview-error";
import { safelyInvoke } from "./core/safely-invoke";
import { useLocale } from "./core/i18n";
import { downloadSource } from "./core/download";
import "./styles/LargeFileGate.css";
interface LargeFileGateProps {
export interface LargeFileBlockedContext {
file: FileInfo;
actualBytes: number;
maxBytes: number;
download: () => Promise<void>;
}
export interface LargeFileGateProps {
file: FileInfo;
children: React.ReactNode;
policy?: LargeFilePolicy;
/**
* Bypass the gate entirely and render children as-is.
*
@@ -18,29 +30,41 @@ interface LargeFileGateProps {
* (or disable it for trusted, internal-only previews). The default
* `PluginPreviewRenderer` already applies this gate, so most consumers
* never instantiate `LargeFileGate` directly.
*
* `disabled=true` is equivalent to `policy="off"`.
*/
disabled?: boolean;
onError?: (error: PreviewError) => void;
renderBlockedFallback?: (
context: LargeFileBlockedContext,
) => React.ReactNode;
}
/**
* Self-contained large-file gate.
*
* Wraps a preview and, based on `file.size`, shows:
* - 20 MB+ : a non-blocking "may be slower" banner above the preview
* - 50 MB+ : a confirm prompt (user must click "Preview anyway")
* - 100 MB+: blocks preview entirely, offers download only
* Wraps a preview and, based on `file.size` and the configured `policy`,
* shows:
* - a non-blocking "may be slower" banner above the preview (warning)
* - a confirm prompt (user must click "Preview anyway")
* - blocks preview entirely, offers download only (block)
*
* The confirm state is internal and resets when `file.id` changes, so the
* gate is a drop-in wrapper — no external state plumbing required.
*
* Thresholds live in `PREVIEW_SIZE_LIMITS` (performance-limits.ts).
* Thresholds live in `PREVIEW_SIZE_LIMITS` and can be overridden via the
* `policy` prop (see `PreviewSizePolicyConfig`).
*/
export function LargeFileGate({
file,
children,
policy,
disabled = false,
onError,
renderBlockedFallback,
}: LargeFileGateProps) {
const [confirmed, setConfirmed] = useState(false);
const t = useLocale();
// Reset the confirm decision whenever the user switches to a different
// file — confirming one large file must not auto-confirm the next.
@@ -48,58 +72,105 @@ export function LargeFileGate({
setConfirmed(false);
}, [file.id]);
const policy = getPreviewSizePolicy({
const resolvedPolicy = disabled ? "off" : policy;
const sizePolicy = getPreviewSizePolicy({
size: file.size,
fileType: file.fileType,
policy: resolvedPolicy,
});
if (disabled || !policy.shouldWarn) {
// Avoid duplicate reports for the same file. Keyed on `file.id` plus
// `size`/`name` so that a caller reusing the same id for a different
// file (a contract violation, but a survivable one) still re-reports.
const blockReportedRef = useRef<string | null>(null);
useEffect(() => {
if (!sizePolicy.shouldBlock) {
blockReportedRef.current = null;
return;
}
const reportKey = `${file.id}::${file.size}::${file.name}`;
if (blockReportedRef.current === reportKey) return;
blockReportedRef.current = reportKey;
safelyInvoke(
onError,
new PreviewError(
"FILE_TOO_LARGE",
"File exceeds the configured preview limit.",
{
fileName: file.name,
details: {
actualBytes: file.size,
maxBytes: sizePolicy.maxBytes,
fileType: file.fileType,
},
},
),
);
}, [
file.id,
file.name,
file.size,
file.fileType,
onError,
sizePolicy.shouldBlock,
sizePolicy.maxBytes,
]);
if (disabled || !sizePolicy.shouldWarn) {
return <>{children}</>;
}
// Block: never render the preview, only offer download.
if (policy.shouldBlock) {
if (sizePolicy.shouldBlock) {
const fallbackContext: LargeFileBlockedContext = {
file,
actualBytes: file.size,
maxBytes: sizePolicy.maxBytes!,
download: () =>
downloadSource(file.source, file.name, file.type),
};
if (renderBlockedFallback) {
return <>{renderBlockedFallback(fallbackContext)}</>;
}
return (
<div className="fv-gate-confirm">
<AlertTriangleIcon size={48} className="fv-gate-confirm__icon fv-gate-confirm__icon--block" />
<div className="fv-gate-confirm__body">
<h3 className="fv-gate-confirm__title">File too large to preview</h3>
<p className="fv-gate-confirm__desc">{policy.message}</p>
<p className="fv-gate-confirm__meta">
{file.name} · {formatFileSize(file.size)}
</p>
</div>
<button
className="fv-btn fv-btn--outline"
onClick={() => downloadSource(file.source, file.name, file.type)}
>
<DownloadIcon size={16} /> Download original file
</button>
</div>
<PreviewFallback
kind="file-too-large"
file={file}
description={t.fileTooLargeBlockedDesc
.replace("{actualSize}", formatFileSize(file.size))
.replace("{maxSize}", formatFileSize(sizePolicy.maxBytes!))}
canDownload
/>
);
}
// Confirm: require an explicit "Preview anyway" before rendering.
if (policy.shouldConfirm && !confirmed) {
if (sizePolicy.shouldConfirm && !confirmed) {
return (
<div className="fv-gate-confirm">
<AlertTriangleIcon size={48} className="fv-gate-confirm__icon" />
<div className="fv-gate-confirm__body">
<h3 className="fv-gate-confirm__title">Large file preview</h3>
<p className="fv-gate-confirm__desc">{policy.message}</p>
<h3 className="fv-gate-confirm__title">{t.largeFilePreviewTitle}</h3>
<p className="fv-gate-confirm__desc">{sizePolicy.message}</p>
<p className="fv-gate-confirm__meta">
{file.name} · {formatFileSize(file.size)}
</p>
</div>
<div className="fv-gate-confirm__actions">
<button className="fv-btn fv-btn--primary" onClick={() => setConfirmed(true)}>
Preview anyway
{t.previewAnyway}
</button>
<button
className="fv-btn fv-btn--outline"
onClick={() => downloadSource(file.source, file.name, file.type)}
>
<DownloadIcon size={16} /> Download
<DownloadIcon size={16} /> {t.download}
</button>
</div>
</div>
@@ -113,7 +184,10 @@ export function LargeFileGate({
<div className="fv-gate-warning__inner">
<AlertTriangleIcon size={14} />
<span>
Large file: {formatFileSize(file.size)}. Preview may be slower.
{t.largeFileWarningBanner.replace(
"{fileSize}",
formatFileSize(file.size),
)}
</span>
</div>
</div>
@@ -10,7 +10,8 @@ import { UnsupportedPluginPreview } from "./preview-adapters/UnsupportedPluginPr
import { getPreviewSupportMeta } from "./support-status";
import { PreviewErrorBoundary } from "./PreviewErrorBoundary";
import { PreviewLoading } from "./PreviewLoading";
import { LargeFileGate } from "./LargeFileGate";
import { LargeFileGate, type LargeFileBlockedContext } from "./LargeFileGate";
import { type LargeFilePolicy } from "./performance-limits";
import { PreviewError, isPreviewError } from "./core/preview-error";
import type { PreviewErrorCode } from "./core/preview-error";
import { safelyInvoke } from "./core/safely-invoke";
@@ -126,8 +127,27 @@ export interface PluginPreviewRendererProps {
* default — real users upload unpredictable files.
* - `"off"`: no gate. Use only when the caller enforces its own size
* policy or is previewing trusted, size-bounded content.
* - `PreviewSizePolicyConfig`: custom thresholds for warning, confirmation,
* and blocking.
*
* ```tsx
* <PluginPreviewRenderer
* file={file}
* largeFilePolicy={{ maxBytes: 50 * 1024 * 1024 }}
* />
* ```
*/
largeFilePolicy?: "default" | "off";
largeFilePolicy?: LargeFilePolicy;
/**
* Custom fallback UI when the file exceeds the block threshold.
*
* When set, this replaces the default `PreviewFallback("file-too-large")`
* with the consumer's own component. The context includes `file`,
* `actualBytes`, `maxBytes`, and a `download` function.
*/
renderLargeFileFallback?: (
context: LargeFileBlockedContext,
) => React.ReactNode;
}
export function PluginPreviewRenderer({
@@ -136,6 +156,7 @@ export function PluginPreviewRenderer({
showPluginDebug = false,
onError,
largeFilePolicy = "default",
renderLargeFileFallback,
}: PluginPreviewRendererProps) {
const [retryKey, setRetryKey] = useState(0);
@@ -167,10 +188,12 @@ export function PluginPreviewRenderer({
setRetryKey((value) => value + 1);
}, [plugin]);
let content: React.ReactNode;
if (!plugin) {
const support = getPreviewSupportMeta(file.fileType);
return (
content = (
<UnsupportedPluginPreview
file={file}
title={support.status === "legacy-only" ? "Not Migrated Yet" : undefined}
@@ -185,37 +208,46 @@ export function PluginPreviewRenderer({
}
/>
);
} else {
content = (
<div className="fv-plugin-renderer">
{showPluginDebug && (
<div className="fv-plugin-debug">
<span className="fv-plugin-debug__label">Plugin Renderer</span>
<span></span>
<span>{plugin.name}</span>
<span className="fv-plugin-debug__id">{plugin.id}</span>
</div>
)}
<div className="fv-plugin-renderer__content">
<PreviewErrorBoundary
file={file}
pluginId={plugin.id}
pluginName={plugin.name}
resetKey={`${file.id}:${plugin.id}:${retryKey}`}
onRetry={handleRetry}
onError={onError}
>
<PluginContent plugin={plugin} file={file} onError={onError} />
</PreviewErrorBoundary>
</div>
</div>
);
}
const content = (
<div className="fv-plugin-renderer">
{showPluginDebug && (
<div className="fv-plugin-debug">
<span className="fv-plugin-debug__label">Plugin Renderer</span>
<span></span>
<span>{plugin.name}</span>
<span className="fv-plugin-debug__id">{plugin.id}</span>
</div>
)}
if (largeFilePolicy === "off") {
return <>{content}</>;
}
<div className="fv-plugin-renderer__content">
<PreviewErrorBoundary
file={file}
pluginId={plugin.id}
pluginName={plugin.name}
resetKey={`${file.id}:${plugin.id}:${retryKey}`}
onRetry={handleRetry}
onError={onError}
>
<PluginContent plugin={plugin} file={file} onError={onError} />
</PreviewErrorBoundary>
</div>
</div>
return (
<LargeFileGate
file={file}
policy={largeFilePolicy}
onError={onError}
renderBlockedFallback={renderLargeFileFallback}
>
{content}
</LargeFileGate>
);
// Default: protect against accidentally previewing huge files. The gate
// is a no-op for files under the 20 MB warning threshold, so normal-size
// previews render exactly as before.
if (largeFilePolicy === "off") return content;
return <LargeFileGate file={file}>{content}</LargeFileGate>;
}
@@ -10,11 +10,12 @@ import {
vi,
type Mock,
} from "vitest";
import type { ComponentType } from "react";
import type { ComponentType, ReactElement } from "react";
import { PluginPreviewRenderer } from "../PluginPreviewRenderer";
import { createPreviewPluginRegistry } from "../core/registry";
import type { PreviewPlugin } from "../core/plugin";
import type { FileInfo, FileType } from "../core/types";
import { LocaleProvider, zhCN } from "../core/i18n";
// ─── helpers ──────────────────────────────────────────────────────────────
@@ -207,8 +208,9 @@ describe("PluginPreviewRenderer load caching", () => {
// ─── large file policy (built-in LargeFileGate) ───────────────────────────
describe("PluginPreviewRenderer large-file policy", () => {
const BLOCK = 100 * 1024 * 1024; // 100 MB → block threshold
const BLOCK = 100 * 1024 * 1024 + 1; // 100 MB + 1 byte → block threshold exceeded
const CONFIRM = 50 * 1024 * 1024; // 50 MB → confirm threshold
const MB = 1024 * 1024;
function largeFile(fileType: FileType, size: number): FileInfo {
return {
@@ -221,12 +223,18 @@ describe("PluginPreviewRenderer large-file policy", () => {
};
}
// Pin locale to zh-CN so the text assertions below don't depend on the
// package's default locale (which could change in the future).
function renderWithLocale(ui: ReactElement) {
return render(<LocaleProvider value={zhCN}>{ui}</LocaleProvider>);
}
it("blocks preview for files >= 100 MB and offers download instead", async () => {
const stub = stubPlugin("pdf");
const registry = createPreviewPluginRegistry([stub.plugin]);
await act(async () => {
render(
renderWithLocale(
<PluginPreviewRenderer
file={largeFile("pdf", BLOCK)}
registry={registry}
@@ -234,10 +242,10 @@ describe("PluginPreviewRenderer large-file policy", () => {
);
});
// Block UI: title + download button, and the plugin must NOT have loaded.
expect(screen.getByText(/too large to preview/i)).toBeInTheDocument();
// Block UI: fallback rendered, and the plugin must NOT have loaded.
expect(screen.getByText(/大文件/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /download original file/i }),
screen.getByRole("button", { name: /下载原文件/ }),
).toBeInTheDocument();
expect(stub.load).not.toHaveBeenCalled();
});
@@ -247,7 +255,7 @@ describe("PluginPreviewRenderer large-file policy", () => {
const registry = createPreviewPluginRegistry([stub.plugin]);
await act(async () => {
render(
renderWithLocale(
<PluginPreviewRenderer
file={largeFile("pdf", CONFIRM)}
registry={registry}
@@ -256,13 +264,13 @@ describe("PluginPreviewRenderer large-file policy", () => {
});
// Confirm prompt visible; plugin not loaded yet.
expect(screen.getByText(/large file preview/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /preview anyway/i })).toBeInTheDocument();
expect(screen.getByText(/大文件预览/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /继续预览/ })).toBeInTheDocument();
expect(stub.load).not.toHaveBeenCalled();
// User confirms → preview loads.
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /preview anyway/i }));
fireEvent.click(screen.getByRole("button", { name: /继续预览/ }));
});
await screen.findByTestId("content");
expect(stub.load).toHaveBeenCalledOnce();
@@ -273,7 +281,7 @@ describe("PluginPreviewRenderer large-file policy", () => {
const registry = createPreviewPluginRegistry([stub.plugin]);
await act(async () => {
render(
renderWithLocale(
<PluginPreviewRenderer
file={largeFile("pdf", BLOCK)}
registry={registry}
@@ -284,7 +292,145 @@ describe("PluginPreviewRenderer large-file policy", () => {
// No block UI — the preview loads straight through.
await screen.findByTestId("content");
expect(screen.queryByText(/too large to preview/i)).not.toBeInTheDocument();
expect(screen.queryByText(/大文件/)).not.toBeInTheDocument();
});
describe("custom largeFilePolicy", () => {
it("does not load plugin when custom maxBytes limit is exceeded", async () => {
const load = vi.fn(async () => ({
default: ({ file }: { file: FileInfo }) => (
<div data-testid="content">{file.name}</div>
),
}));
const registry = createPreviewPluginRegistry([
{
id: "test-plugin",
name: "Test",
priority: 100,
match: () => true,
load,
},
]);
await act(async () => {
renderWithLocale(
<PluginPreviewRenderer
file={largeFile("pdf", 51 * MB)}
registry={registry}
largeFilePolicy={{ maxBytes: 50 * MB }}
/>,
);
});
expect(load).not.toHaveBeenCalled();
expect(screen.getByText(/大文件/)).toBeInTheDocument();
});
it("loads plugin when file is within custom maxBytes", async () => {
const load = vi.fn(async () => ({
default: ({ file }: { file: FileInfo }) => (
<div data-testid="content">{file.name}</div>
),
}));
const registry = createPreviewPluginRegistry([
{
id: "test-plugin",
name: "Test",
priority: 100,
match: () => true,
load,
},
]);
await act(async () => {
renderWithLocale(
<PluginPreviewRenderer
file={largeFile("pdf", 30 * MB)}
registry={registry}
largeFilePolicy={{ maxBytes: 50 * MB }}
/>,
);
});
await screen.findByTestId("content");
expect(load).toHaveBeenCalledOnce();
});
it("reports FILE_TOO_LARGE error on block", async () => {
const onError = vi.fn();
const registry = createPreviewPluginRegistry([]);
await act(async () => {
renderWithLocale(
<PluginPreviewRenderer
file={largeFile("pdf", 51 * MB)}
registry={registry}
largeFilePolicy={{ maxBytes: 50 * MB }}
onError={onError}
/>,
);
});
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: "FILE_TOO_LARGE" }),
);
});
it("renders custom large file fallback when renderLargeFileFallback is set", async () => {
const registry = createPreviewPluginRegistry([]);
await act(async () => {
renderWithLocale(
<PluginPreviewRenderer
file={largeFile("pdf", 51 * MB)}
registry={registry}
largeFilePolicy={{ maxBytes: 50 * MB }}
renderLargeFileFallback={({ file, maxBytes }) => (
<div data-testid="custom-fallback">
{file.name} exceeds {maxBytes}
</div>
)}
/>,
);
});
expect(screen.getByTestId("custom-fallback")).toHaveTextContent(
`large.pdf exceeds ${50 * MB}`,
);
});
it("with tiered policy: warns and confirms before blocking", async () => {
const stub = stubPlugin("pdf");
const registry = createPreviewPluginRegistry([stub.plugin]);
// File within confirm range
await act(async () => {
renderWithLocale(
<PluginPreviewRenderer
file={largeFile("pdf", 35 * MB)}
registry={registry}
largeFilePolicy={{
warningBytes: 20 * MB,
confirmBytes: 30 * MB,
maxBytes: 50 * MB,
}}
/>,
);
});
// Confirm prompt should show
expect(screen.getByText(/大文件预览/)).toBeInTheDocument();
expect(stub.load).not.toHaveBeenCalled();
// Confirm
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /继续预览/ }));
});
await screen.findByTestId("content");
expect(stub.load).toHaveBeenCalledOnce();
});
});
});
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import {
getPreviewSizeLevel,
getPreviewSizePolicy,
resolvePreviewSizePolicy,
validatePreviewSizePolicy,
PREVIEW_SIZE_LIMITS,
} from "../performance-limits";
@@ -61,6 +63,8 @@ describe("getPreviewSizePolicy", () => {
expect(policy.shouldConfirm).toBe(false);
expect(policy.shouldBlock).toBe(false);
expect(policy.message).toBeNull();
expect(policy.maxBytes).toBe(100 * 1024 * 1024);
expect(policy.actualBytes).toBe(5 * 1024 * 1024);
});
it("warning file has warn but no confirm/block", () => {
@@ -88,5 +92,162 @@ describe("getPreviewSizePolicy", () => {
expect(policy.shouldConfirm).toBe(false);
expect(policy.shouldBlock).toBe(true);
expect(policy.message).not.toBeNull();
expect(policy.maxBytes).toBe(100 * 1024 * 1024);
expect(policy.actualBytes).toBe(150 * 1024 * 1024);
});
it("policy='off' returns normal regardless of size", () => {
const policy = getPreviewSizePolicy({
size: 200 * 1024 * 1024,
policy: "off",
});
expect(policy.level).toBe("normal");
expect(policy.shouldWarn).toBe(false);
expect(policy.shouldConfirm).toBe(false);
expect(policy.shouldBlock).toBe(false);
expect(policy.maxBytes).toBeNull();
});
it("custom maxBytes: file at maxBytes is not blocked", () => {
const MB = 1024 * 1024;
const policy = getPreviewSizePolicy({
size: 50 * MB,
policy: { maxBytes: 50 * MB },
});
expect(policy.shouldBlock).toBe(false);
expect(policy.maxBytes).toBe(50 * MB);
});
it("custom maxBytes: file exceeding maxBytes is blocked", () => {
const MB = 1024 * 1024;
const policy = getPreviewSizePolicy({
size: 50.1 * MB,
policy: { maxBytes: 50 * MB },
});
expect(policy.level).toBe("block");
expect(policy.shouldBlock).toBe(true);
expect(policy.maxBytes).toBe(50 * MB);
expect(policy.actualBytes).toBe(50.1 * MB);
});
it("custom warningBytes solo", () => {
const MB = 1024 * 1024;
const policy = getPreviewSizePolicy({
size: 15 * MB,
policy: { maxBytes: 50 * MB, warningBytes: 10 * MB },
});
expect(policy.level).toBe("warning");
expect(policy.shouldWarn).toBe(true);
});
it("custom confirmBytes solo", () => {
const MB = 1024 * 1024;
const policy = getPreviewSizePolicy({
size: 30 * MB,
policy: { maxBytes: 50 * MB, confirmBytes: 25 * MB },
});
expect(policy.level).toBe("confirm");
expect(policy.shouldConfirm).toBe(true);
});
it("null warningBytes disables warning", () => {
const MB = 1024 * 1024;
const policy = getPreviewSizePolicy({
size: 40 * MB,
policy: { maxBytes: 50 * MB, warningBytes: null, confirmBytes: 30 * MB },
});
// 40 MB >= 30 MB (confirm) → confirm, no warning level because warning is disabled
expect(policy.level).toBe("confirm");
});
});
describe("resolvePreviewSizePolicy", () => {
it("default returns PREVIEW_SIZE_LIMITS values", () => {
const resolved = resolvePreviewSizePolicy("default");
expect(resolved.enabled).toBe(true);
expect(resolved.warningBytes).toBe(PREVIEW_SIZE_LIMITS.warning);
expect(resolved.confirmBytes).toBe(PREVIEW_SIZE_LIMITS.confirm);
expect(resolved.maxBytes).toBe(PREVIEW_SIZE_LIMITS.block);
});
it("off returns disabled", () => {
const resolved = resolvePreviewSizePolicy("off");
expect(resolved.enabled).toBe(false);
expect(resolved.warningBytes).toBeNull();
expect(resolved.confirmBytes).toBeNull();
expect(resolved.maxBytes).toBeNull();
});
it("custom config is reflected", () => {
const MB = 1024 * 1024;
const resolved = resolvePreviewSizePolicy({ maxBytes: 50 * MB });
expect(resolved.enabled).toBe(true);
expect(resolved.maxBytes).toBe(50 * MB);
expect(resolved.warningBytes).toBeNull();
expect(resolved.confirmBytes).toBeNull();
});
it("custom config with all thresholds", () => {
const MB = 1024 * 1024;
const resolved = resolvePreviewSizePolicy({
maxBytes: 100 * MB,
warningBytes: 20 * MB,
confirmBytes: 50 * MB,
});
expect(resolved.enabled).toBe(true);
expect(resolved.warningBytes).toBe(20 * MB);
expect(resolved.confirmBytes).toBe(50 * MB);
expect(resolved.maxBytes).toBe(100 * MB);
});
});
describe("validatePreviewSizePolicy", () => {
it("throws on zero maxBytes", () => {
expect(() => validatePreviewSizePolicy({ maxBytes: 0 })).toThrow(TypeError);
});
it("throws on negative maxBytes", () => {
expect(() => validatePreviewSizePolicy({ maxBytes: -1 })).toThrow(TypeError);
});
it("throws on non-finite maxBytes", () => {
expect(() => validatePreviewSizePolicy({ maxBytes: Infinity })).toThrow(TypeError);
expect(() => validatePreviewSizePolicy({ maxBytes: NaN })).toThrow(TypeError);
});
it("throws on zero warningBytes", () => {
expect(() =>
validatePreviewSizePolicy({ maxBytes: 100, warningBytes: 0 }),
).toThrow(TypeError);
});
it("throws on warningBytes >= confirmBytes", () => {
expect(() =>
validatePreviewSizePolicy({
maxBytes: 100,
warningBytes: 50,
confirmBytes: 50,
}),
).toThrow(TypeError);
});
it("throws on warningBytes >= maxBytes", () => {
const MB = 1024 * 1024;
expect(() =>
validatePreviewSizePolicy({
maxBytes: 50 * MB,
warningBytes: 50 * MB,
}),
).toThrow(TypeError);
});
it("throws on confirmBytes >= maxBytes", () => {
const MB = 1024 * 1024;
expect(() =>
validatePreviewSizePolicy({
maxBytes: 50 * MB,
confirmBytes: 50 * MB,
}),
).toThrow(TypeError);
});
});
+108 -11
View File
@@ -1,17 +1,11 @@
import type { PreviewSource } from "./types";
import { readSourceAsArrayBuffer } from "./source";
import { PreviewError } from "./preview-error";
export async function downloadSource(
source: PreviewSource,
function downloadBlobDirectly(
blobLike: Blob | File,
fileName: string,
mimeType?: string
): Promise<void> {
const buffer = await readSourceAsArrayBuffer(source);
const blob = new Blob([buffer], {
type: mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
): void {
const url = URL.createObjectURL(blobLike);
try {
const anchor = document.createElement("a");
@@ -22,3 +16,106 @@ export async function downloadSource(
URL.revokeObjectURL(url);
}
}
function downloadUrlDirectly(url: string, fileName: string): void {
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
}
async function downloadUrlWithHeaders(
source: Extract<PreviewSource, { kind: "url" }>,
fileName: string,
mimeType?: string,
): Promise<void> {
let response: Response;
try {
response = await fetch(source.url, { headers: source.headers });
} catch (error) {
// fetch rejects with a TypeError on network failure / CORS rejection —
// normalize to REMOTE_CORS_ERROR so consumers can branch on the code.
throw new PreviewError(
"REMOTE_CORS_ERROR",
`Failed to fetch ${source.url}: network error or blocked by CORS.`,
{
url: source.url,
cause: error,
details: {
originalName: error instanceof Error ? error.name : String(error),
},
},
);
}
if (!response.ok) {
throw new PreviewError(
"REMOTE_HTTP_ERROR",
`Download failed: ${response.status} ${response.statusText}`,
{
url: source.url,
details: { status: response.status, statusText: response.statusText },
},
);
}
const blob = await response.blob();
// Caller-supplied mimeType wins over the response's Content-Type so a
// misconfigured server (e.g. returning application/octet-stream) can't
// strip the file of its intended type.
const finalBlob = mimeType && blob.type !== mimeType
? new Blob([await blob.arrayBuffer()], { type: mimeType })
: blob;
downloadBlobDirectly(
finalBlob,
fileName || extractFileNameFromUrl(source.url),
);
}
function extractFileNameFromUrl(url: string): string {
try {
const parsed = new URL(url);
const segments = parsed.pathname
.split("/")
.filter(Boolean);
return segments[segments.length - 1] || "download";
} catch {
return "download";
}
}
export async function downloadSource(
source: PreviewSource,
fileName: string,
mimeType?: string,
): Promise<void> {
switch (source.kind) {
case "file":
return downloadBlobDirectly(source.file, fileName);
case "blob":
return downloadBlobDirectly(source.blob, fileName);
case "arrayBuffer":
return downloadBlobDirectly(
new Blob([source.buffer], {
type:
mimeType ||
source.mimeType ||
"application/octet-stream",
}),
fileName,
);
case "url":
if (
!source.headers ||
!Object.keys(source.headers).length
) {
return downloadUrlDirectly(source.url, fileName);
}
return downloadUrlWithHeaders(source, fileName, mimeType);
}
}
+15
View File
@@ -64,6 +64,11 @@ export interface LocaleMessages {
// Large file
largeFileHint: string;
largeFile: string;
fileTooLargeToPreview: string;
fileTooLargeBlockedDesc: string;
largeFilePreviewTitle: string;
previewAnyway: string;
largeFileWarningBanner: string;
largeFileRows: string;
largeFileCols: string;
largeFileImages: string;
@@ -199,6 +204,11 @@ export const zhCN: LocaleMessages = {
// Large file
largeFileHint: "当前文件较大,浏览器端解析可能需要更长时间,期间页面可能短暂卡顿。",
largeFile: "大文件",
fileTooLargeToPreview: "文件过大,无法预览",
fileTooLargeBlockedDesc: "文件大小 {actualSize},超过最大预览限制 {maxSize},请下载后查看。",
largeFilePreviewTitle: "大文件预览",
previewAnyway: "继续预览",
largeFileWarningBanner: "大文件:{fileSize},预览可能较慢。",
largeFileRows: "行",
largeFileCols: "列",
largeFileImages: "张图片",
@@ -332,6 +342,11 @@ export const enUS: LocaleMessages = {
// Large file
largeFileHint: "This file is large. Browser-side parsing may take longer and the page may briefly freeze.",
largeFile: "Large File",
fileTooLargeToPreview: "File too large to preview",
fileTooLargeBlockedDesc: "File size {actualSize} exceeds the maximum preview limit of {maxSize}. Please download to view.",
largeFilePreviewTitle: "Large file preview",
previewAnyway: "Preview anyway",
largeFileWarningBanner: "Large file: {fileSize}. Preview may be slower.",
largeFileRows: "rows",
largeFileCols: "cols",
largeFileImages: "images",
+4
View File
@@ -0,0 +1,4 @@
declare module "*.css" {
const content: Record<string, string>;
export default content;
}
+10 -1
View File
@@ -121,8 +121,13 @@ export {
// ─── Performance / size limits ────────────────────────────────────────────
export {
PREVIEW_SIZE_LIMITS,
resolvePreviewSizePolicy,
validatePreviewSizePolicy,
getPreviewSizeLevel,
getPreviewSizePolicy,
type LargeFilePolicy,
type PreviewSizePolicyConfig,
type ResolvedPreviewSizePolicy,
type PreviewSizeLevel,
type PreviewSizePolicy,
} from "./performance-limits";
@@ -132,7 +137,11 @@ export {
shouldHighlight,
truncateContent,
} from "./limits";
export { LargeFileGate } from "./LargeFileGate";
export {
LargeFileGate,
type LargeFileGateProps,
type LargeFileBlockedContext,
} from "./LargeFileGate";
// ─── Built-in plugins (base only) ──────────────────────────────────────────
//
+143 -8
View File
@@ -19,6 +19,116 @@ export interface PreviewSizePolicy {
shouldConfirm: boolean;
shouldBlock: boolean;
message: string | null;
maxBytes: number | null;
actualBytes: number;
}
export interface PreviewSizePolicyConfig {
maxBytes: number;
warningBytes?: number | null;
confirmBytes?: number | null;
}
export type LargeFilePolicy =
| "default"
| "off"
| PreviewSizePolicyConfig;
export interface ResolvedPreviewSizePolicy {
enabled: boolean;
warningBytes: number | null;
confirmBytes: number | null;
maxBytes: number | null;
}
export function validatePreviewSizePolicy(
policy: PreviewSizePolicyConfig,
): void {
if (
!Number.isFinite(policy.maxBytes) ||
policy.maxBytes <= 0
) {
throw new TypeError(
"largeFilePolicy.maxBytes must be a positive finite number.",
);
}
if (
policy.warningBytes != null &&
(!Number.isFinite(policy.warningBytes) || policy.warningBytes <= 0)
) {
throw new TypeError(
"largeFilePolicy.warningBytes must be a positive finite number.",
);
}
if (
policy.confirmBytes != null &&
(!Number.isFinite(policy.confirmBytes) || policy.confirmBytes <= 0)
) {
throw new TypeError(
"largeFilePolicy.confirmBytes must be a positive finite number.",
);
}
if (
policy.warningBytes != null &&
policy.confirmBytes != null &&
policy.warningBytes >= policy.confirmBytes
) {
throw new TypeError(
"warningBytes must be smaller than confirmBytes.",
);
}
if (
policy.warningBytes != null &&
policy.warningBytes >= policy.maxBytes
) {
throw new TypeError(
"warningBytes must be smaller than maxBytes.",
);
}
if (
policy.confirmBytes != null &&
policy.confirmBytes >= policy.maxBytes
) {
throw new TypeError(
"confirmBytes must be smaller than maxBytes.",
);
}
}
export function resolvePreviewSizePolicy(
policy: LargeFilePolicy = "default",
): ResolvedPreviewSizePolicy {
if (policy === "off") {
return {
enabled: false,
warningBytes: null,
confirmBytes: null,
maxBytes: null,
};
}
if (policy === "default") {
return {
enabled: true,
warningBytes: PREVIEW_SIZE_LIMITS.warning,
confirmBytes: PREVIEW_SIZE_LIMITS.confirm,
maxBytes: PREVIEW_SIZE_LIMITS.block,
};
}
validatePreviewSizePolicy(policy);
return {
enabled: true,
warningBytes: policy.warningBytes ?? null,
confirmBytes: policy.confirmBytes ?? null,
maxBytes: policy.maxBytes,
};
}
export function getPreviewSizeLevel(size: number): PreviewSizeLevel {
@@ -31,47 +141,72 @@ export function getPreviewSizeLevel(size: number): PreviewSizeLevel {
export function getPreviewSizePolicy(input: {
size: number;
fileType?: FileType;
policy?: LargeFilePolicy;
}): PreviewSizePolicy {
const level = getPreviewSizeLevel(input.size);
const resolved = resolvePreviewSizePolicy(
input.policy ?? "default",
);
if (level === "block") {
if (!resolved.enabled) {
return {
level,
level: "normal",
shouldWarn: false,
shouldConfirm: false,
shouldBlock: false,
message: null,
maxBytes: null,
actualBytes: input.size,
};
}
const { maxBytes, confirmBytes, warningBytes } = resolved;
if (maxBytes !== null && input.size > maxBytes) {
return {
level: "block",
shouldWarn: true,
shouldConfirm: false,
shouldBlock: true,
message:
"This file is very large and may freeze the browser. Browser-side preview is disabled by default.",
maxBytes,
actualBytes: input.size,
};
}
if (level === "confirm") {
if (confirmBytes !== null && input.size >= confirmBytes) {
return {
level,
level: "confirm",
shouldWarn: true,
shouldConfirm: true,
shouldBlock: false,
message:
"This file is large and may take time to preview. Continue only if you trust the file and your browser has enough memory.",
maxBytes,
actualBytes: input.size,
};
}
if (level === "warning") {
if (warningBytes !== null && input.size >= warningBytes) {
return {
level,
level: "warning",
shouldWarn: true,
shouldConfirm: false,
shouldBlock: false,
message:
"This file is relatively large. Preview may be slower depending on your browser and device.",
maxBytes,
actualBytes: input.size,
};
}
return {
level,
level: "normal",
shouldWarn: false,
shouldConfirm: false,
shouldBlock: false,
message: null,
maxBytes,
actualBytes: input.size,
};
}
+1
View File
@@ -29,6 +29,7 @@ export default defineConfig({
"!src/**/*.test.ts",
"!src/**/*.test.tsx",
"!src/**/__tests__/**",
"!src/**/*.d.ts",
],
format: ["esm"],
dts: true,