Add on-demand DOCX previews for note attachments

This commit is contained in:
tianma-if committed 2026-10-08 21:43:45 +08:00
1 parent c2869bf0dc
commit a4d391d36b
12 files changed
+287 -10

No files matched your search

+1
View File
@@ -27,6 +27,7 @@
"@uiw/react-codemirror": "^4.25.11",
"ai": "7.0.58",
"beautiful-mermaid": "^1.1.3",
"docx-preview": "0.4.1",
"highlight.js": "^11.11.1",
"html-to-image": "^1.11.13",
"lowlight": "^3.3.0",
@@ -0,0 +1,69 @@
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { buildDocxPreviewHtml } from "./docx-preview-html";
import { DocxPreviewTooLargeError, loadDocxPreviewBytes } from "./docx-preview-source";
type PreviewState =
| { status: "loading" }
| { status: "ready"; html: string }
| { status: "too-large" | "failed" };
export const DocxAttachmentPreview = ({ url, filename }: { url: string; filename: string }) => {
const { t } = useTranslation();
const [state, setState] = useState<PreviewState>({ status: "loading" });
useEffect(() => {
const controller = new AbortController();
let active = true;
setState({ status: "loading" });
void loadDocxPreviewBytes(url, controller.signal)
.then(async (bytes) => {
if (!active) return;
const { renderAsync } = await import("docx-preview");
if (!active) return;
const body = document.createElement("div");
const styles = document.createElement("div");
await renderAsync(bytes, body, styles, {
renderAltChunks: false,
renderComments: false,
renderChanges: false,
useBase64URL: true,
});
body.querySelectorAll("a").forEach((link) => {
link.removeAttribute("href");
link.removeAttribute("target");
link.setAttribute("tabindex", "-1");
});
if (active) setState({ status: "ready", html: buildDocxPreviewHtml(styles.innerHTML, body.innerHTML) });
})
.catch((error: unknown) => {
if (!active) return;
setState({ status: error instanceof DocxPreviewTooLargeError ? "too-large" : "failed" });
});
return () => {
active = false;
controller.abort();
};
}, [url]);
if (state.status === "ready") {
return (
<iframe
aria-label={t("wordViewer.previewLabel", { filename })}
sandbox=""
srcDoc={state.html}
className="block h-[min(72vh,52rem)] w-full border-0 bg-slate-100"
/>
);
}
return (
<span className="flex min-h-24 items-center justify-center gap-2 px-4 text-center text-sm text-slate-500" role="status">
{state.status === "loading" ? <Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" /> : null}
{t(state.status === "loading" ? "wordViewer.loading" : state.status === "too-large" ? "wordViewer.previewTooLarge" : "wordViewer.unavailable")}
</span>
);
};
@@ -0,0 +1,11 @@
import { expect, test } from "bun:test";
import { buildDocxPreviewHtml } from "./docx-preview-html.ts";
test("keeps document styling inside a restricted preview document", () => {
const html = buildDocxPreviewHtml("<style>.docx { color: red }</style>", "<p>Draft</p>");
expect(html).toContain("default-src 'none'");
expect(html).toContain("script-src 'none'");
expect(html).toContain("connect-src 'none'");
expect(html).toContain("img-src data:");
expect(html).toContain("<p>Draft</p>");
});
@@ -0,0 +1,8 @@
const PREVIEW_CSP = "default-src 'none'; script-src 'none'; connect-src 'none'; img-src data:; font-src data:; style-src 'unsafe-inline'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
/** The iframe also has an empty sandbox: document markup cannot run scripts or navigate its parent. */
export const buildDocxPreviewHtml = (styles: string, body: string) => `<!doctype html>
<html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">
${styles}
<style>html,body{margin:0;min-height:100%;background:#f1f5f9}a{pointer-events:none!important}</style>
</head><body>${body}</body></html>`;
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
DocxPreviewTooLargeError,
isPreviewableDocx,
loadDocxPreviewBytes,
MAX_INLINE_DOCX_BYTES,
} from "./docx-preview-source.ts";
const originalFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = originalFetch; });
describe("DOCX preview source", () => {
test("only offers inline preview for note-owned DOCX resources", () => {
expect(isPreviewableDocx("Report.DOCX", "/api/v1/resources/res_1/blob")).toBe(true);
expect(isPreviewableDocx("draft.docx", "edgeever-staged://stage_1")).toBe(true);
expect(isPreviewableDocx("old.doc", "/api/v1/resources/res_1/blob")).toBe(false);
expect(isPreviewableDocx("report.docx", "https://other.example/report.docx")).toBe(false);
});
test("reads staged desktop bytes without mutating the bridge buffer", async () => {
const original = new Uint8Array([1, 2, 3]);
const bridge = {
readResource: async () => { throw new Error("wrong source"); },
readStagedResource: async (id) => {
expect(id).toBe("stage_1");
return { bytes: original };
},
};
const bytes = await loadDocxPreviewBytes("edgeever-staged://stage_1", undefined, bridge);
expect(bytes).toEqual(original);
expect(bytes).not.toBe(original);
});
test("rejects a declared oversized resource before reading its body", async () => {
globalThis.fetch = async () => new Response("", {
headers: { "Content-Length": String(MAX_INLINE_DOCX_BYTES + 1) },
});
await expect(loadDocxPreviewBytes("/api/v1/resources/res_1/blob", undefined, undefined))
.rejects.toBeInstanceOf(DocxPreviewTooLargeError);
});
test("stops an oversized stream even without Content-Length", async () => {
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(MAX_INLINE_DOCX_BYTES));
controller.enqueue(new Uint8Array([1]));
controller.close();
},
}));
await expect(loadDocxPreviewBytes("/api/v1/resources/res_1/blob", undefined, undefined))
.rejects.toBeInstanceOf(DocxPreviewTooLargeError);
});
test("loads a small authenticated resource", async () => {
globalThis.fetch = async (_url, options) => {
expect(options.credentials).toBe("same-origin");
return new Response(new Uint8Array([4, 5, 6]));
};
await expect(loadDocxPreviewBytes("/api/v1/resources/res_1/blob", undefined, undefined))
.resolves.toEqual(new Uint8Array([4, 5, 6]));
});
});
@@ -0,0 +1,84 @@
import { normalizeAttachmentByteSize } from "@edgeever/shared";
export const MAX_INLINE_DOCX_BYTES = 10 * 1024 * 1024;
export class DocxPreviewTooLargeError extends Error {}
export const isPreviewableDocx = (filename: string, url: string) =>
/\.docx$/i.test(filename.trim())
&& (url.startsWith("/api/v1/resources/")
|| url.startsWith("edgeever-resource://")
|| url.startsWith("edgeever-staged://"));
const desktopResourceId = (url: string, scheme: "edgeever-resource:" | "edgeever-staged:") => {
try {
const parsed = new URL(url);
if (parsed.protocol !== scheme) return null;
const id = parsed.pathname.replace(/^\//, "") || parsed.hostname;
return id ? decodeURIComponent(id) : null;
} catch {
return null;
}
};
const requireAllowedSize = (size: number) => {
if (size > MAX_INLINE_DOCX_BYTES) throw new DocxPreviewTooLargeError();
};
export const loadDocxPreviewBytes = async (
url: string,
signal?: AbortSignal,
bridge: Pick<EdgeEverDesktopBridge, "readResource" | "readStagedResource"> | undefined =
typeof window === "undefined" ? undefined : window.edgeeverDesktop,
): Promise<Uint8Array> => {
const stagedId = desktopResourceId(url, "edgeever-staged:");
const resourceId = desktopResourceId(url, "edgeever-resource:");
if (bridge && (stagedId || resourceId)) {
const resource = stagedId
? await bridge.readStagedResource(stagedId)
: await bridge.readResource(resourceId!);
requireAllowedSize(resource.bytes.byteLength);
return new Uint8Array(resource.bytes);
}
if (!url.startsWith("/api/v1/resources/")) throw new Error("Unsupported DOCX resource URL");
const response = await fetch(url, { credentials: "same-origin", cache: "no-store", signal });
if (!response.ok) throw new Error(`DOCX resource request failed: ${response.status}`);
const declaredSize = normalizeAttachmentByteSize(response.headers.get("Content-Length"));
if (declaredSize !== null && declaredSize > MAX_INLINE_DOCX_BYTES) {
await response.body?.cancel().catch(() => undefined);
throw new DocxPreviewTooLargeError();
}
if (!response.body) {
const bytes = new Uint8Array(await response.arrayBuffer());
requireAllowedSize(bytes.byteLength);
return bytes;
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let byteLength = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
byteLength += value.byteLength;
requireAllowedSize(byteLength);
chunks.push(value);
}
} catch (error) {
await reader.cancel().catch(() => undefined);
throw error;
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
};
@@ -11,7 +11,9 @@ import { ChevronDown, ChevronUp, Download, ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AttachmentFileIcon } from "@/components/attachments/AttachmentFileIcon";
import { AudioAttachmentPlayer } from "@/components/attachments/AudioAttachmentPlayer";
import { DocxAttachmentPreview } from "@/components/attachments/DocxAttachmentPreview";
import { COMPACT_ATTACHMENT_WIDTH_CLASS } from "@/components/attachments/attachment-layout";
import { isPreviewableDocx, MAX_INLINE_DOCX_BYTES } from "@/components/attachments/docx-preview-source";
import { VideoAttachmentPlayer } from "@/components/attachments/VideoAttachmentPlayer";
import { ButtonTooltip } from "@/components/ui/button-tooltip";
import { useAttachmentByteSize } from "@/hooks/useAttachmentByteSize";
@@ -23,6 +25,7 @@ import { AttachmentTranscriptInline } from "./AttachmentTranscriptInline";
const FileAttachmentNodeView = ({ node, updateAttributes }: NodeViewProps) => {
const { t } = useTranslation();
const [viewerElement, setViewerElement] = useState<HTMLSpanElement | null>(null);
const [docxExpanded, setDocxExpanded] = useState(false);
const transcriptContext = useContext(AttachmentTranscriptContext);
const activeTranscript = transcriptContext?.transcript?.target.element
&& viewerElement?.contains(transcriptContext.transcript.target.element)
@@ -40,17 +43,24 @@ const FileAttachmentNodeView = ({ node, updateAttributes }: NodeViewProps) => {
const attachmentKind = resolveAttachmentKind(mimeType, filename || label);
const isAudio = attachmentKind === "audio";
const isVideo = attachmentKind === "video";
const isDocx = isPreviewableDocx(filename || label, resolvedUrl);
const docxPreviewAllowed = isDocx && (byteSize === null || byteSize <= MAX_INLINE_DOCX_BYTES);
const videoExpanded = isVideo && resolveFileDisplayMode(node.attrs.displayMode) === "inline";
const expandable = isVideo || docxPreviewAllowed;
const expanded = isVideo ? videoExpanded : docxExpanded;
const setVideoExpanded = (expanded: boolean) => {
updateAttributes({ displayMode: expanded ? "inline" : "compact" });
};
const toggleExpanded = () => isVideo ? setVideoExpanded(!videoExpanded) : setDocxExpanded(!docxExpanded);
const identity = (
<>
<AttachmentFileIcon mimeType={mimeType} filename={filename || label} className="h-5 w-5 shrink-0" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold text-slate-800">{label}</span>
<span className="truncate text-xs font-medium text-slate-500">{metadata}</span>
<span className="truncate text-xs font-medium text-slate-500">
{isDocx && !docxPreviewAllowed ? `${metadata} · ${t("wordViewer.previewTooLarge")}` : metadata}
</span>
</span>
</>
);
@@ -65,12 +75,12 @@ const FileAttachmentNodeView = ({ node, updateAttributes }: NodeViewProps) => {
>
<span ref={setViewerElement} className={cn("edgeever-file-viewer flex min-h-12 flex-col overflow-hidden rounded-xl border border-slate-200 bg-card shadow-sm", COMPACT_ATTACHMENT_WIDTH_CLASS)}>
<span data-edgeever-resource-toolbar className="flex min-h-12 items-center gap-2 px-3">
{isVideo ? (
{expandable ? (
<button
type="button"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left"
aria-expanded={videoExpanded}
onClick={() => setVideoExpanded(!videoExpanded)}
aria-expanded={expanded}
onClick={toggleExpanded}
>
{identity}
</button>
@@ -94,16 +104,16 @@ const FileAttachmentNodeView = ({ node, updateAttributes }: NodeViewProps) => {
<ExternalLink aria-hidden="true" />
</a>
</ButtonTooltip>
{isVideo ? (
<ButtonTooltip title={videoExpanded ? t("pdfViewer.collapse") : t("pdfViewer.expand")}>
{expandable ? (
<ButtonTooltip title={expanded ? t("pdfViewer.collapse") : t("pdfViewer.expand")}>
<button
type="button"
className="pdf-viewer-action"
aria-label={videoExpanded ? t("pdfViewer.collapse") : t("pdfViewer.expand")}
aria-expanded={videoExpanded}
onClick={() => setVideoExpanded(!videoExpanded)}
aria-label={expanded ? t("pdfViewer.collapse") : t("pdfViewer.expand")}
aria-expanded={expanded}
onClick={toggleExpanded}
>
{videoExpanded ? <ChevronUp aria-hidden="true" /> : <ChevronDown aria-hidden="true" />}
{expanded ? <ChevronUp aria-hidden="true" /> : <ChevronDown aria-hidden="true" />}
</button>
</ButtonTooltip>
) : null}
@@ -128,6 +138,11 @@ const FileAttachmentNodeView = ({ node, updateAttributes }: NodeViewProps) => {
/>
</span>
) : null}
{docxPreviewAllowed && docxExpanded && resolvedUrl ? (
<span className="border-t border-slate-200">
<DocxAttachmentPreview url={resolvedUrl} filename={filename || label} />
</span>
) : null}
</span>
{activeTranscript && transcriptContext ? (
<span className={cn("block", COMPACT_ATTACHMENT_WIDTH_CLASS)}>
+3
View File
@@ -245,6 +245,7 @@
"@uiw/react-codemirror": "^4.25.11",
"ai": "7.0.58",
"beautiful-mermaid": "^1.1.3",
"docx-preview": "0.4.1",
"highlight.js": "^11.11.1",
"html-to-image": "^1.11.13",
"lowlight": "^3.3.0",
@@ -2258,6 +2259,8 @@
"dnssd-advertise": ["dnssd-advertise@1.1.6", "", {}, "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg=="],
"docx-preview": ["docx-preview@0.4.1", "", { "dependencies": { "jszip": ">=3.0.0" } }, "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w=="],
"dom-align": ["dom-align@1.12.4", "", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
+6
View File
@@ -2015,6 +2015,12 @@ export const enUS = {
previous: "Previous PDF",
next: "Next PDF",
},
wordViewer: {
loading: "Loading Word preview…",
unavailable: "This Word document cannot be previewed. You can still download it or open it externally.",
previewTooLarge: "Preview disabled over 10 MiB",
previewLabel: "Word preview: {{filename}}",
},
audioPlayer: {
label: "Audio player: {{filename}}",
unavailable: "This audio format cannot be played on this device. You can still download it or open it externally.",
+6
View File
@@ -2015,6 +2015,12 @@ export const ja = {
previous: "前の PDF",
next: "次の PDF",
},
wordViewer: {
loading: "Word のプレビューを読み込み中…",
unavailable: "この Word 文書はプレビューできません。ダウンロードするか、外部で開くことはできます。",
previewTooLarge: "10 MiB 超のためプレビューしません",
previewLabel: "Word プレビュー:{{filename}}",
},
audioPlayer: {
label: "音声プレーヤー:{{filename}}",
unavailable: "この端末ではこの音声形式を再生できません。ダウンロードするか、外部で開くことはできます。",
+6
View File
@@ -2015,6 +2015,12 @@ export const pl = {
previous: "Poprzedni PDF",
next: "Następny PDF",
},
wordViewer: {
loading: "Wczytywanie podglądu Word…",
unavailable: "Nie można wyświetlić podglądu tego dokumentu Word. Nadal możesz go pobrać lub otworzyć w innej aplikacji.",
previewTooLarge: "Podgląd wyłączony powyżej 10 MiB",
previewLabel: "Podgląd Word: {{filename}}",
},
audioPlayer: {
label: "Odtwarzacz audio: {{filename}}",
unavailable: "Tego formatu audio nie można odtworzyć na tym urządzeniu. Nadal możesz go pobrać lub otworzyć w innej aplikacji.",
+6
View File
@@ -2015,6 +2015,12 @@ export const zhCN = {
previous: "上一个 PDF",
next: "下一个 PDF",
},
wordViewer: {
loading: "正在加载 Word 预览…",
unavailable: "无法预览此 Word 文档,你仍可下载或在外部打开。",
previewTooLarge: "超过 10 MiB,不在笔记内预览",
previewLabel: "Word 预览:{{filename}}",
},
audioPlayer: {
label: "音频播放器:{{filename}}",
unavailable: "当前设备无法播放此音频格式,你仍可下载或在外部打开。",