Add on-demand DOCX previews for note attachments
This commit is contained in:
1 parent
c2869bf0dc
commit
a4d391d36b
12 files changed
+287
-10
No files matched your search
@@ -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)}>
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "この端末ではこの音声形式を再生できません。ダウンロードするか、外部で開くことはできます。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "当前设备无法播放此音频格式,你仍可下载或在外部打开。",
|
||||
|
||||
Reference in new issue
Block a user