diff --git a/apps/web/package.json b/apps/web/package.json index 40216f64..ca80e54c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/attachments/DocxAttachmentPreview.tsx b/apps/web/src/components/attachments/DocxAttachmentPreview.tsx new file mode 100644 index 00000000..103f5921 --- /dev/null +++ b/apps/web/src/components/attachments/DocxAttachmentPreview.tsx @@ -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({ 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 ( +