Repository navigation
FE-376: Add a DOCX preview to the entity editor #9995
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 9e186b9
Rename the PDF preview's search bar to FilePreviewSearch
0xRezaa 54ffc4e
Share the PDF preview's search bar between file previews
0xRezaa 1fe2371
Use the shared search bar in the DOCX preview
0xRezaa ec19459
Keep HASH's ProseMirror copies when deduplicating the lockfile
0xRezaa e063678
Scope the corrupt .docx test's spinner check to the page's main content
0xRezaa bfc3ba5
Split the DOCX preview test into one test per behaviour
0xRezaa 181dc00
Update `@stll/folio-react` to 0.26.0
0xRezaa 5c96238
Take the DOCX preview's message types from folio
0xRezaa 1fb1053
Clear the DOCX preview's previous hit when a search finds nothing
0xRezaa 25faa2f
Show the file's icon when the DOCX preview cannot draw a document
0xRezaa 3e6168d
Expect folio's refusal of an undrawable document in the e2e console c…
0xRezaa fea74d3
Move folio's missing `y-protocols` from `.yarnrc.yml` to the frontend…
0xRezaa 7152d9d
Build the test DOCX files with jszip
0xRezaa 74206fc
Tolerate folio's refusal only in the test that expects it
0xRezaa 838d77b
Keep the PDF preview's search hits in `pdf-search.tsx`
0xRezaa ab39797
Move folio's ProseMirror webpack rule out of `next.config.js`
0xRezaa 6c6e448
Delete folio's ProseMirror webpack rule
0xRezaa 44187a5
Update `@stll/folio-react` to 0.28.0
0xRezaa 236c11a
Reset the file preview when its file URL changes
0xRezaa 06a7019
Split the file-preview spec by format and slim the File URL tests
0xRezaa 54dd479
Focus the properties grid before the File URL cell in the file-previe…
0xRezaa dcf6663
Let the file-preview tests pass the pages each generator renders
0xRezaa ff3dba6
Drop the redundant credentials option from the DOCX download
0xRezaa 726bc56
Keep HASH's ProseMirror commands, keymap, inputrules and dropcursor v…
0xRezaa f460629
Keep `@babel/runtime` and `reselect` on the versions HASH already uses
0xRezaa 9351473
Click the hostile document's links in the DOCX preview test
0xRezaa 2f3124f
Test that the DOCX page counter follows scrolling and zoom
0xRezaa 640dfa5
Move the DOCX page reporting into a useReportedPages hook
0xRezaa a6db75c
Move the DOCX search into a useSearchHits hook
0xRezaa 93ccc12
Leave the DOCX zoom to the zoom buttons
0xRezaa a193e97
Keep the DOCX zoom between 25% and 400%
0xRezaa c36b1f7
Load only the English folio messages
0xRezaa 10dc8de
Return a plain function from useFindSearchHits
0xRezaa e11359a
Report DOCX download errors through useEffectEvent
0xRezaa 3a7d679
Inline the DOCX onUnavailable handler
0xRezaa e4dae31
Move PDF and DOCX file detection into getFileProperties
0xRezaa febe846
Name the previous and next search hit in FilePreviewSearch
0xRezaa bda1d37
Share page navigation between the PDF and DOCX previews
0xRezaa 1d71ea6
Share the file preview's maximum height between the PDF and DOCX prev…
0xRezaa c495fb8
Move the folio module declarations to the frontend's `folio.d.ts`
0xRezaa b97f668
Label the file preview's match-case button and show its pressed state
0xRezaa f528bbd
Round the page navigation's background to match its buttons
0xRezaa 546de22
Hide the file preview's "Open in new tab" button for DOCX files
0xRezaa 2a35212
Throttle the file preview search instead of debouncing it
0xRezaa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} | ||
| /> | ||
| </Box> | ||
| {pages.total > 0 && ( | ||
| <> | ||
| <Stack | ||
|
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> | ||
| ); | ||
| }; | ||
97 changes: 97 additions & 0 deletions
97
apps/hash-frontend/src/pages/shared/docx-preview/folio-document.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.