Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a2a2ab3
FE-376: Add a DOCX preview to the entity editor
0xRezaa Oct 12, 2026
9e186b9
Rename the PDF preview's search bar to FilePreviewSearch
0xRezaa Oct 12, 2026
54ffc4e
Share the PDF preview's search bar between file previews
0xRezaa Oct 12, 2026
1fe2371
Use the shared search bar in the DOCX preview
0xRezaa Oct 12, 2026
ec19459
Keep HASH's ProseMirror copies when deduplicating the lockfile
0xRezaa Oct 12, 2026
e063678
Scope the corrupt .docx test's spinner check to the page's main content
0xRezaa Oct 12, 2026
bfc3ba5
Split the DOCX preview test into one test per behaviour
0xRezaa Oct 12, 2026
181dc00
Update `@stll/folio-react` to 0.26.0
0xRezaa Oct 12, 2026
5c96238
Take the DOCX preview's message types from folio
0xRezaa Oct 12, 2026
1fb1053
Clear the DOCX preview's previous hit when a search finds nothing
0xRezaa Oct 12, 2026
25faa2f
Show the file's icon when the DOCX preview cannot draw a document
0xRezaa Oct 12, 2026
3e6168d
Expect folio's refusal of an undrawable document in the e2e console c…
0xRezaa Oct 13, 2026
fea74d3
Move folio's missing `y-protocols` from `.yarnrc.yml` to the frontend…
0xRezaa Oct 13, 2026
7152d9d
Build the test DOCX files with jszip
0xRezaa Oct 13, 2026
74206fc
Tolerate folio's refusal only in the test that expects it
0xRezaa Oct 13, 2026
838d77b
Keep the PDF preview's search hits in `pdf-search.tsx`
0xRezaa Oct 13, 2026
ab39797
Move folio's ProseMirror webpack rule out of `next.config.js`
0xRezaa Oct 13, 2026
6c6e448
Delete folio's ProseMirror webpack rule
0xRezaa Oct 14, 2026
44187a5
Update `@stll/folio-react` to 0.28.0
0xRezaa Oct 14, 2026
236c11a
Reset the file preview when its file URL changes
0xRezaa Oct 14, 2026
06a7019
Split the file-preview spec by format and slim the File URL tests
0xRezaa Oct 14, 2026
54dd479
Focus the properties grid before the File URL cell in the file-previe…
0xRezaa Oct 14, 2026
dcf6663
Let the file-preview tests pass the pages each generator renders
0xRezaa Oct 14, 2026
ff3dba6
Drop the redundant credentials option from the DOCX download
0xRezaa Oct 14, 2026
726bc56
Keep HASH's ProseMirror commands, keymap, inputrules and dropcursor v…
0xRezaa Oct 14, 2026
f460629
Keep `@babel/runtime` and `reselect` on the versions HASH already uses
0xRezaa Oct 14, 2026
9351473
Click the hostile document's links in the DOCX preview test
0xRezaa Oct 14, 2026
2f3124f
Test that the DOCX page counter follows scrolling and zoom
0xRezaa Oct 14, 2026
640dfa5
Move the DOCX page reporting into a useReportedPages hook
0xRezaa Oct 15, 2026
a6db75c
Move the DOCX search into a useSearchHits hook
0xRezaa Oct 15, 2026
93ccc12
Leave the DOCX zoom to the zoom buttons
0xRezaa Oct 16, 2026
a193e97
Keep the DOCX zoom between 25% and 400%
0xRezaa Oct 16, 2026
c36b1f7
Load only the English folio messages
0xRezaa Oct 16, 2026
10dc8de
Return a plain function from useFindSearchHits
0xRezaa Oct 16, 2026
e11359a
Report DOCX download errors through useEffectEvent
0xRezaa Oct 16, 2026
3a7d679
Inline the DOCX onUnavailable handler
0xRezaa Oct 16, 2026
e4dae31
Move PDF and DOCX file detection into getFileProperties
0xRezaa Oct 16, 2026
febe846
Name the previous and next search hit in FilePreviewSearch
0xRezaa Oct 16, 2026
bda1d37
Share page navigation between the PDF and DOCX previews
0xRezaa Oct 16, 2026
1d71ea6
Share the file preview's maximum height between the PDF and DOCX prev…
0xRezaa Oct 16, 2026
c495fb8
Move the folio module declarations to the frontend's `folio.d.ts`
0xRezaa Oct 16, 2026
b97f668
Label the file preview's match-case button and show its pressed state
0xRezaa Oct 16, 2026
f528bbd
Round the page navigation's background to match its buttons
0xRezaa Oct 16, 2026
546de22
Hide the file preview's "Open in new tab" button for DOCX files
0xRezaa Oct 16, 2026
2a35212
Throttle the file preview search instead of debouncing it
0xRezaa Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/hash-frontend/folio.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* The frontend's `moduleResolution: "node"` ignores package `exports`, so
* TypeScript cannot find these folio subpaths' own types. Webpack resolves
* them at runtime.
*/

/** `@stll/folio-react`'s types import this subpath. */
declare module "@stll/folio-core/ai-edits" {
export * from "@stll/folio-core/dist/ai-edits/index";
}

/** Re-exports `@stll/folio-core`'s English messages. */
declare module "@stll/folio-react/messages/en" {
export * from "@stll/folio-core/dist/i18n/messages/en.gen";
}
9 changes: 9 additions & 0 deletions apps/hash-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
"@sigma/edge-curve": "patch:@sigma/edge-curve@npm%3A3.0.0-beta.16#~/.yarn/patches/@sigma-edge-curve-npm-3.0.0-beta.16-3d8b985284.patch",
"@sigma/node-border": "3.0.0",
"@sigma/node-image": "3.0.0",
"@stll/folio-core": "0.60.0",
"@stll/folio-react": "0.28.0",
"@svgr/webpack": "8.1.0",
"@tanstack/react-table": "8.21.3",
"@tldraw/editor": "patch:@tldraw/editor@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-editor-npm-2.0.0-alpha.12-ba59bf001c.patch",
Expand Down Expand Up @@ -114,10 +116,13 @@
"prosemirror-collab": "1.3.0",
"prosemirror-commands": "1.3.1",
"prosemirror-dev-tools": "4.3.0",
"prosemirror-gapcursor": "1.4.1",
"prosemirror-history": "1.5.1",
"prosemirror-inputrules": "1.2.0",
"prosemirror-keymap": "1.2.0",
"prosemirror-model": "1.25.12",
"prosemirror-state": "1.4.4",
"prosemirror-tables": "1.8.5",
"prosemirror-transform": "1.12.2",
"prosemirror-view": "1.42.6",
"react": "19.2.6",
Expand All @@ -144,8 +149,12 @@
"signia-react": "0.1.5",
"url-regex-safe": "4.0.0",
"use-font-face-observer": "1.3.0",
"use-intl": "4.14.9",
"uuid": "14.0.0",
"web-worker": "1.4.1",
"y-prosemirror": "1.3.7",
"y-protocols": "1.0.7",
"yjs": "13.6.33",
"zod": "4.4.3"
},
"devDependencies": {
Expand Down
236 changes: 236 additions & 0 deletions apps/hash-frontend/src/pages/shared/docx-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { Box, CircularProgress, Stack, Typography } from "@mui/material";
import * as Sentry from "@sentry/nextjs";
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { FullScreen, useFullScreenHandle } from "react-full-screen";

import {
ArrowDownLeftAndArrowUpRightToCenterIcon,
ArrowUpRightAndArrowDownLeftFromCenterIcon,
MagnifyingGlassMinusLightIcon,
MagnifyingGlassPlusLightIcon,
} from "@hashintel/design-system";

import { FolioDocument } from "./docx-preview/folio-document";
import { fetchPresignedFileUrl } from "./fetch-presigned-file-url";
import { FilePreviewPageNavigation } from "./file-preview-page-navigation";
import { FilePreviewSearch } from "./file-preview-search";
import { GrayToBlueIconButton } from "./gray-to-blue-icon-button";
import { useFilePreviewMaxHeight } from "./use-file-preview-max-height";

import type {
DocxDocumentControls,
DocxDocumentPages,
} from "./docx-preview/folio-document";

const zoomStep = 0.2;

type DocxPreviewProps = {
/**
* Called when the document cannot be shown at all, so the caller can show
* the file's icon instead.
*/
onUnavailable: () => void;
showSearch: boolean;
setShowSearch: (showSearch: boolean) => void;
url: string;
};

export const DocxPreview = ({
onUnavailable,
showSearch,
setShowSearch,
url,
}: DocxPreviewProps) => {
const [data, setData] = useState<ArrayBuffer | null>(null);
const [pages, setPages] = useState<DocxDocumentPages>({
current: 1,
total: 0,
});
const [zoom, setZoom] = useState(1);
const [searchHitCount, setSearchHitCount] = useState(0);
const [selectedSearchHit, setSelectedSearchHit] = useState<number | null>(
null,
);

const controlsRef = useRef<DocxDocumentControls>(null);

const fullScreenHandle = useFullScreenHandle();

const height = useFilePreviewMaxHeight(fullScreenHandle.active);

/**
* An error before any page is laid out means the document cannot be shown.
* A later one, such as a part folio cannot draw, leaves the pages in place.
*/
const handleError = (error: unknown) => {
Sentry.captureException(error);
if (!controlsRef.current?.hasPages()) {
onUnavailable();
}
};

const handleDownloadError = useEffectEvent(handleError);

useEffect(() => {
let cancelled = false;

setData(null);

fetchPresignedFileUrl(url)
.then((presignedUrl) => fetch(presignedUrl))
.then((response) => {
if (!response.ok) {
throw new Error(
`Downloading ${url} failed with HTTP ${response.status}`,
);
}
return response.arrayBuffer();
})
.then((buffer) => {
if (!cancelled) {
setData(buffer);
}
})
.catch((error: unknown) => {
if (!cancelled) {
handleDownloadError(error);
}
});

return () => {
cancelled = true;
};
}, [url]);

const selectSearchHit = (index: number | null) => {
setSelectedSearchHit(index);

if (index !== null) {
controlsRef.current?.showHit(index);
}
};

const search = (text: string, ignoreCase: boolean) => {
const hitCount = controlsRef.current?.search(text, ignoreCase) ?? 0;
setSearchHitCount(hitCount);
selectSearchHit(hitCount > 0 ? 0 : null);
};

return (
<FullScreen
className="full-height-and-width-for-react-full-screen"
handle={fullScreenHandle}
>
<Box
sx={({ palette }) => ({
background: palette.blue[10],
border: `1px solid ${palette.gray[30]}`,
borderRadius: 2,
height,
overflow: "hidden",
position: "relative",
width: "100%",
})}
>
{data ? (
<Sentry.ErrorBoundary onError={onUnavailable}>
<FolioDocument
controlsRef={controlsRef}
data={data}
onError={handleError}
onPagesChange={setPages}
onZoomChange={setZoom}
/>
</Sentry.ErrorBoundary>
) : (
<Stack alignItems="center" justifyContent="center" height="100%">
<CircularProgress />
</Stack>
)}
<Box sx={{ position: "absolute", top: 20, left: 20, zIndex: 100 }}>
<FilePreviewSearch
closeSearch={() => setShowSearch(false)}
onSearch={search}
onSelectHit={selectSearchHit}
selectedIndex={selectedSearchHit}
showSearch={showSearch}
total={searchHitCount}
/>
Comment thread
0xRezaa marked this conversation as resolved.
</Box>
{pages.total > 0 && (
<>
<Stack
Comment thread
alex-e-leon marked this conversation as resolved.
direction="row"
alignItems="center"
gap={1}
sx={{
background: "rgba(255, 255, 255, 0.9)",
borderRadius: "4px",
position: "absolute",
bottom: 20,
left: 20,
zIndex: 20,
}}
>
<FilePreviewPageNavigation
currentPage={pages.current}
onSelectPage={(pageNumber) =>
controlsRef.current?.goToPage(pageNumber)
}
totalPages={pages.total}
/>
</Stack>
<Stack
alignItems="center"
direction="row"
gap={1.5}
sx={{
position: "absolute",
bottom: 20,
right: 20,
zIndex: 20,
}}
>
<Stack direction="row" alignItems="center" gap={1}>
<GrayToBlueIconButton
aria-label="Zoom out"
onClick={() => controlsRef.current?.zoomBy(-zoomStep)}
>
<MagnifyingGlassMinusLightIcon />
</GrayToBlueIconButton>
<GrayToBlueIconButton
aria-label="Zoom in"
onClick={() => controlsRef.current?.zoomBy(zoomStep)}
>
<MagnifyingGlassPlusLightIcon />
</GrayToBlueIconButton>
<Typography
sx={{
fontSize: 12,
color: ({ palette }) => palette.gray[70],
}}
>
{Math.round(zoom * 100)}%
</Typography>
</Stack>
<GrayToBlueIconButton
aria-label={
fullScreenHandle.active ? "Exit full screen" : "Full screen"
}
onClick={
fullScreenHandle[fullScreenHandle.active ? "exit" : "enter"]
}
>
{fullScreenHandle.active ? (
<ArrowDownLeftAndArrowUpRightToCenterIcon />
) : (
<ArrowUpRightAndArrowDownLeftFromCenterIcon />
)}
</GrayToBlueIconButton>
</Stack>
</>
)}
</Box>
</FullScreen>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import "@stll/folio-react/standalone.css";
import { Box, CircularProgress } from "@mui/material";
import { DocxEditor } from "@stll/folio-react";
import { messages as folioMessages } from "@stll/folio-react/messages/en";
import clamp from "lodash/clamp";
import { useImperativeHandle, useRef } from "react";
import { IntlProvider } from "use-intl";

import { useReportedPages } from "./folio-document/use-reported-pages";
import { useSearchHits } from "./folio-document/use-search-hits";

import type { DocxDocumentPages } from "./folio-document/use-reported-pages";
import type { DocxEditorRef } from "@stll/folio-react";
import type { Ref } from "react";

/**
* What `DocxPreview` can ask of the engine that draws the document. Nothing
* outside this file depends on the engine itself.
*/
export type DocxDocumentControls = {
/** Whether the engine shows at least one page. */
hasPages: () => boolean;
goToPage: (pageNumber: number) => void;
zoomBy: (step: number) => void;
/** Finds every hit of `text` and returns how many there are. */
search: (text: string, ignoreCase: boolean) => number;
showHit: (index: number) => void;
};

export type { DocxDocumentPages };

type FolioDocumentProps = {
controlsRef: Ref<DocxDocumentControls>;
data: ArrayBuffer;
onError: (error: Error) => void;
onPagesChange: (pages: DocxDocumentPages) => void;
onZoomChange: (zoom: number) => void;
};

const minZoom = 0.25;
const maxZoom = 4;

export const FolioDocument = ({
controlsRef,
data,
onError,
onPagesChange,
onZoomChange,
}: FolioDocumentProps) => {
const editorRef = useRef<DocxEditorRef>(null);
const containerRef = useRef<HTMLDivElement>(null);

const reportPages = useReportedPages(editorRef, onPagesChange);
const { search, showHit } = useSearchHits(editorRef);

useImperativeHandle(
controlsRef,
() => ({
/**
* folio reports one page before it has laid out any, so this looks for
* a drawn page instead.
*/
hasPages: () => !!containerRef.current?.querySelector(".layout-page"),
goToPage: (pageNumber) => editorRef.current?.scrollToPage(pageNumber),
zoomBy: (step) => {
const editor = editorRef.current;
if (!editor) {
return;
}
editor.setZoom(clamp(editor.getZoom() + step, minZoom, maxZoom));
onZoomChange(editor.getZoom());
},
search,
showHit,
}),
[onZoomChange, search, showHit],
);

return (
<IntlProvider locale="en" messages={folioMessages}>
<Box ref={containerRef} sx={{ height: "100%" }}>
<DocxEditor
ref={editorRef}
autoOpenReviewSidebar={false}
documentBuffer={data}
enableWheelZoom={false}
keyboardShortcuts="none"
loadingIndicator={<CircularProgress />}
onError={onError}
onScrollTopChange={reportPages}
readOnly
style={{ height: "100%" }}
/>
</Box>
</IntlProvider>
);
};
Loading
Loading