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:
co-authored by
Claude Opus 4.7
parent
e3a86fd25e
commit
5f88b11a8a
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "*.css" {
|
||||
const content: Record<string, string>;
|
||||
export default content;
|
||||
}
|
||||
@@ -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) ──────────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
"!src/**/*.test.ts",
|
||||
"!src/**/*.test.tsx",
|
||||
"!src/**/__tests__/**",
|
||||
"!src/**/*.d.ts",
|
||||
],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
|
||||
Reference in New Issue
Block a user